authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 09:57:22-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 06:57:22-07:00
log5661c6116c9d6e9e93e59cf067fc02dd3303ceef
tree4b4d56e17e19ffda09e868c0a1140a5914f36705
parent6d70e6d77824eed51b1531a2f42b2ed5cdae8501
signature Signed by PGP key B5690EEEBB952194

feat(web): drag pinned threads into your own order (#5581)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

34 files changed, 1407 insertions(+), 92 deletions(-)

apps/mobile/src/features/home/HomeRouteScreen.tsx+2
...@@ -44,6 +44,7 @@ export function HomeRouteScreen() {...@@ -44,6 +44,7 @@ export function HomeRouteScreen() {
44 unsnoozeThread,44 unsnoozeThread,
45 pinThread,45 pinThread,
46 unpinThread,46 unpinThread,
47 movePinnedThread,
47 unsettleThread,48 unsettleThread,
48 } = useThreadListActions();49 } = useThreadListActions();
49 const pendingTasks = usePendingNewTasks();50 const pendingTasks = usePendingNewTasks();
...@@ -170,6 +171,7 @@ export function HomeRouteScreen() {...@@ -170,6 +171,7 @@ export function HomeRouteScreen() {
170 onUnsettleThread={unsettleThread}171 onUnsettleThread={unsettleThread}
171 onPinThread={pinThread}172 onPinThread={pinThread}
172 onUnpinThread={unpinThread}173 onUnpinThread={unpinThread}
174 onMovePinnedThread={movePinnedThread}
173 onEnvironmentChange={setSelectedEnvironmentId}175 onEnvironmentChange={setSelectedEnvironmentId}
174 onProjectChange={setSelectedProjectKey}176 onProjectChange={setSelectedProjectKey}
175 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}177 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
apps/mobile/src/features/home/HomeScreen.tsx+44
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 threadSearchMatchKey,11 threadSearchMatchKey,
12 type EnvironmentThreadSearchMatch,12 type EnvironmentThreadSearchMatch,
13} from "@t3tools/client-runtime/state/thread-search";13} from "@t3tools/client-runtime/state/thread-search";
14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
14import type {15import type {
15 EnvironmentId,16 EnvironmentId,
16 SidebarProjectGroupingMode,17 SidebarProjectGroupingMode,
...@@ -110,6 +111,10 @@ interface HomeScreenProps {...@@ -110,6 +111,10 @@ interface HomeScreenProps {
110 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;111 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
111 readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;112 readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
112 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;113 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
114 readonly onMovePinnedThread: (
115 thread: EnvironmentThreadShell,
116 direction: "up" | "down",
117 ) => Promise<boolean>;
113 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;118 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
114 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;119 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
115 readonly onNewThreadInProject: (project: EnvironmentProject) => void;120 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
...@@ -521,6 +526,12 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -521,6 +526,12 @@ export function HomeScreen(props: HomeScreenProps) {
521 },526 },
522 [props.onPinThread],527 [props.onPinThread],
523 );528 );
529 const handleMovePinnedThread = useCallback(
530 (thread: EnvironmentThreadShell, direction: "up" | "down") => {
531 void props.onMovePinnedThread(thread, direction);
532 },
533 [props.onMovePinnedThread],
534 );
524 const handleUnpinThread = useCallback(535 const handleUnpinThread = useCallback(
525 (thread: EnvironmentThreadShell) => {536 (thread: EnvironmentThreadShell) => {
526 void props.onUnpinThread(thread);537 void props.onUnpinThread(thread);
...@@ -595,6 +606,29 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -595,6 +606,29 @@ export function HomeScreen(props: HomeScreenProps) {
595 }606 }
596 return supported;607 return supported;
597 }, [serverConfigs]);608 }, [serverConfigs]);
609 const pinReorderEnvironmentIds = useMemo(() => {
610 const supported = new Set<EnvironmentId>();
611 for (const [environmentId, config] of serverConfigs) {
612 if (config.environment.capabilities.threadPinReorder === true) {
613 supported.add(environmentId);
614 }
615 }
616 return supported;
617 }, [serverConfigs]);
618 // Canonical arranged pinned order (reorder-capable threads only) for the
619 // Move up/down position flags. Computed from all shells, not the rendered
620 // list, so search/scope filtering never disables or misdirects a move.
621 const arrangedPinnedKeys = useMemo(() => {
622 const pinned = sortPinnedThreadsByOrderKey(
623 props.threads.filter(
624 (thread) =>
625 thread.pinnedAt != null &&
626 thread.archivedAt === null &&
627 pinReorderEnvironmentIds.has(thread.environmentId),
628 ),
629 );
630 return pinned.map((thread) => `${thread.environmentId}:${thread.id}`);
631 }, [pinReorderEnvironmentIds, props.threads]);
598 const threadListV2Layout = useMemo(() => {632 const threadListV2Layout = useMemo(() => {
599 if (!threadListV2Enabled)633 if (!threadListV2Enabled)
600 return {634 return {
...@@ -781,11 +815,18 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -781,11 +815,18 @@ export function HomeScreen(props: HomeScreenProps) {
781 onSettleThread={handleSettleThread}815 onSettleThread={handleSettleThread}
782 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}816 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
783 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}817 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
818 pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)}
819 canMovePinnedUp={arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0}
820 canMovePinnedDown={(() => {
821 const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`);
822 return index !== -1 && index < arrangedPinnedKeys.length - 1;
823 })()}
784 onSnoozeThread={handleSnoozeThread}824 onSnoozeThread={handleSnoozeThread}
785 onUnsnoozeThread={handleUnsnoozeThread}825 onUnsnoozeThread={handleUnsnoozeThread}
786 onUnsettleThread={handleUnsettleThread}826 onUnsettleThread={handleUnsettleThread}
787 onPinThread={handlePinThread}827 onPinThread={handlePinThread}
788 onUnpinThread={handleUnpinThread}828 onUnpinThread={handleUnpinThread}
829 onMovePinnedThread={handleMovePinnedThread}
789 onChangeRequestState={handleChangeRequestState}830 onChangeRequestState={handleChangeRequestState}
790 projectCwd={831 projectCwd={
791 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null832 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
...@@ -798,6 +839,8 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -798,6 +839,8 @@ export function HomeScreen(props: HomeScreenProps) {
798 [839 [
799 handleChangeRequestState,840 handleChangeRequestState,
800 handleDeleteThread,841 handleDeleteThread,
842 arrangedPinnedKeys,
843 handleMovePinnedThread,
801 handlePinThread,844 handlePinThread,
802 handleSettleThread,845 handleSettleThread,
803 handleSnoozeThread,846 handleSnoozeThread,
...@@ -807,6 +850,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -807,6 +850,7 @@ export function HomeScreen(props: HomeScreenProps) {
807 handleSwipeableWillOpen,850 handleSwipeableWillOpen,
808 handleUnsettleThread,851 handleUnsettleThread,
809 pinningEnvironmentIds,852 pinningEnvironmentIds,
853 pinReorderEnvironmentIds,
810 projectByKey,854 projectByKey,
811 projectCwdByKey,855 projectCwdByKey,
812 props.onArchiveThread,856 props.onArchiveThread,
apps/mobile/src/features/home/useThreadListActions.ts+110-2
...@@ -8,9 +8,14 @@ import { Alert } from "react-native";...@@ -8,9 +8,14 @@ import { Alert } from "react-native";
8import { showConfirmDialog } from "../../components/ConfirmDialogHost";8import { showConfirmDialog } from "../../components/ConfirmDialogHost";
9import { scopedThreadKey } from "../../lib/scopedEntities";9import { scopedThreadKey } from "../../lib/scopedEntities";
10import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";10import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
11import {
12 pinOrderKeyBetween,
13 planPinnedMove,
14 sortPinnedThreadsByOrderKey,
15} from "@t3tools/client-runtime/state/thread-sort";
11import { appAtomRegistry } from "../../state/atom-registry";16import { appAtomRegistry } from "../../state/atom-registry";
12import { environmentServerConfigsAtom } from "../../state/server";17import { environmentServerConfigsAtom } from "../../state/server";
13import { threadEnvironment } from "../../state/threads";18import { environmentThreadShells, threadEnvironment } from "../../state/threads";
14import { useAtomCommand } from "../../state/use-atom-command";19import { useAtomCommand } from "../../state/use-atom-command";
1520
16/** Version skew: never send settle/unsettle to a server that predates them21/** Version skew: never send settle/unsettle to a server that predates them
...@@ -36,6 +41,13 @@ function environmentSupportsPinning(environmentId: EnvironmentThreadShell["envir...@@ -36,6 +41,13 @@ function environmentSupportsPinning(environmentId: EnvironmentThreadShell["envir
36 );41 );
37}42}
3843
44function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) {
45 return (
46 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
47 .threadPinReorder === true
48 );
49}
50
39type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";51type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";
4052
41const ACTION_VERBS: Record<ThreadListAction, string> = {53const ACTION_VERBS: Record<ThreadListAction, string> = {
...@@ -211,6 +223,10 @@ export function useThreadListActions(): {...@@ -211,6 +223,10 @@ export function useThreadListActions(): {
211 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;223 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
212 readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;224 readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
213 readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;225 readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
226 readonly movePinnedThread: (
227 thread: EnvironmentThreadShell,
228 direction: "up" | "down",
229 ) => Promise<boolean>;
214} {230} {
215 const executeAction = useThreadActionExecutor();231 const executeAction = useThreadActionExecutor();
216 const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false });232 const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false });
...@@ -331,9 +347,21 @@ export function useThreadListActions(): {...@@ -331,9 +347,21 @@ export function useThreadListActions(): {
331 return false;347 return false;
332 }348 }
333 selectionHaptic();349 selectionHaptic();
350 // Same placement as web: a fresh pin takes the top of the arranged
351 // run. Servers that predate reordering get the bare pin (keyless).
352 let orderKey: string | undefined;
353 if (environmentSupportsPinReorder(thread.environmentId)) {
354 const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
355 let firstKey: string | null = null;
356 for (const shell of shells) {
357 if (shell.pinnedAt == null || shell.pinOrderKey == null) continue;
358 if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey;
359 }
360 orderKey = pinOrderKeyBetween(null, firstKey) ?? undefined;
361 }
334 const result = await pinMutation({362 const result = await pinMutation({
335 environmentId: thread.environmentId,363 environmentId: thread.environmentId,
336 input: { threadId: thread.id },364 input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) },
337 });365 });
338 if (result._tag === "Failure") {366 if (result._tag === "Failure") {
339 const error = Cause.squash(result.cause);367 const error = Cause.squash(result.cause);
...@@ -378,6 +406,85 @@ export function useThreadListActions(): {...@@ -378,6 +406,85 @@ export function useThreadListActions(): {
378 [unpinMutation],406 [unpinMutation],
379 );407 );
380408
409 // Move up / Move down for the pinned block. Computed against the CANONICAL
410 // keyed pinned order (not the rendered list), so the move is valid even
411 // while search or a project scope filters rows: the same fractional-key
412 // scheme web dragging uses, one write to one thread per move (plus a
413 // one-time section materialization when legacy keyless pins are involved).
414 const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, {
415 reportFailure: false,
416 });
417 // One move at a time: a second tap before the first write's event lands
418 // would plan from the same stale snapshot and silently collapse two moves
419 // into one — same double-dispatch guard as snoozeThread.
420 const movePinnedInFlightRef = useRef(false);
421 const movePinnedThread = useCallback(
422 async (thread: EnvironmentThreadShell, direction: "up" | "down") => {
423 if (movePinnedInFlightRef.current) return false;
424 if (!environmentSupportsPinReorder(thread.environmentId)) {
425 Alert.alert(
426 "Could not move thread",
427 "This environment's server does not support pinned reordering yet. Update the server to reorder pins.",
428 );
429 return false;
430 }
431 const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
432 const pinned = sortPinnedThreadsByOrderKey(
433 shells.filter(
434 (shell) =>
435 shell.pinnedAt != null &&
436 shell.archivedAt === null &&
437 environmentSupportsPinReorder(shell.environmentId),
438 ),
439 );
440 const orderedIds = pinned.map((shell) => scopedThreadKey(shell.environmentId, shell.id));
441 const assignments = planPinnedMove({
442 orderedIds,
443 keysById: new Map(
444 pinned.map((shell) => [
445 scopedThreadKey(shell.environmentId, shell.id),
446 shell.pinOrderKey ?? null,
447 ]),
448 ),
449 movedId: scopedThreadKey(thread.environmentId, thread.id),
450 direction,
451 });
452 if (assignments === null || assignments.length === 0) return false;
453 const shellByKey = new Map(
454 pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
455 );
456 selectionHaptic();
457 movePinnedInFlightRef.current = true;
458 try {
459 for (const assignment of assignments) {
460 const target = shellByKey.get(assignment.id);
461 if (target === undefined) continue;
462 const result = await reorderPinnedMutation({
463 environmentId: target.environmentId,
464 input: { threadId: target.id, orderKey: assignment.orderKey },
465 });
466 if (result._tag === "Failure") {
467 const error = Cause.squash(result.cause);
468 Alert.alert(
469 "Could not move thread",
470 error instanceof Error && error.message.trim().length > 0
471 ? error.message
472 : "The pinned thread could not be moved.",
473 );
474 // No rollback: keys already written are valid orderings on their
475 // own (each write is a complete, consistent placement), so a
476 // partial materialization leaves the list sensible, not corrupt.
477 return false;
478 }
479 }
480 return true;
481 } finally {
482 movePinnedInFlightRef.current = false;
483 }
484 },
485 [reorderPinnedMutation],
486 );
487
381 const confirmDeleteThread = useConfirmDeleteThread(executeAction);488 const confirmDeleteThread = useConfirmDeleteThread(executeAction);
382489
383 return {490 return {
...@@ -389,6 +496,7 @@ export function useThreadListActions(): {...@@ -389,6 +496,7 @@ export function useThreadListActions(): {
389 unsettleThread,496 unsettleThread,
390 pinThread,497 pinThread,
391 unpinThread,498 unpinThread,
499 movePinnedThread,
392 };500 };
393}501}
394502
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+36
...@@ -11,6 +11,7 @@ import { LegendList } from "@legendapp/list/react-native";...@@ -11,6 +11,7 @@ import { LegendList } from "@legendapp/list/react-native";
11import type { MenuAction } from "@react-native-menu/menu";11import type { MenuAction } from "@react-native-menu/menu";
12import { useAtomValue } from "@effect/atom-react";12import { useAtomValue } from "@effect/atom-react";
13import type { EnvironmentId } from "@t3tools/contracts";13import type { EnvironmentId } from "@t3tools/contracts";
14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
14import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";15import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
15import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";16import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
16import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native";17import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native";
...@@ -209,6 +210,7 @@ function ThreadNavigationSidebarPane(...@@ -209,6 +210,7 @@ function ThreadNavigationSidebarPane(
209 unsettleThread,210 unsettleThread,
210 pinThread,211 pinThread,
211 unpinThread,212 unpinThread,
213 movePinnedThread,
212 } = useThreadListActions();214 } = useThreadListActions();
213 const threadListV2Enabled = useThreadListV2Enabled();215 const threadListV2Enabled = useThreadListV2Enabled();
214 const pendingTasks = usePendingNewTasks();216 const pendingTasks = usePendingNewTasks();
...@@ -494,6 +496,28 @@ function ThreadNavigationSidebarPane(...@@ -494,6 +496,28 @@ function ThreadNavigationSidebarPane(
494 }496 }
495 return supported;497 return supported;
496 }, [serverConfigs]);498 }, [serverConfigs]);
499 const pinReorderEnvironmentIds = useMemo(() => {
500 const supported = new Set<EnvironmentId>();
501 for (const [environmentId, config] of serverConfigs) {
502 if (config.environment.capabilities.threadPinReorder === true) {
503 supported.add(environmentId);
504 }
505 }
506 return supported;
507 }, [serverConfigs]);
508 // Canonical arranged pinned order for Move up/down flags — computed from
509 // all shells so search/scope filtering never disables a valid move.
510 const arrangedPinnedKeys = useMemo(() => {
511 const pinned = sortPinnedThreadsByOrderKey(
512 threads.filter(
513 (thread) =>
514 thread.pinnedAt != null &&
515 thread.archivedAt === null &&
516 pinReorderEnvironmentIds.has(thread.environmentId),
517 ),
518 );
519 return pinned.map((thread) => `${thread.environmentId}:${thread.id}`);
520 }, [pinReorderEnvironmentIds, threads]);
497 const threadListV2Layout = useMemo(() => {521 const threadListV2Layout = useMemo(() => {
498 if (!threadListV2Enabled)522 if (!threadListV2Enabled)
499 return {523 return {
...@@ -930,11 +954,20 @@ function ThreadNavigationSidebarPane(...@@ -930,11 +954,20 @@ function ThreadNavigationSidebarPane(
930 onSettleThread={settleThread}954 onSettleThread={settleThread}
931 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}955 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
932 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}956 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
957 pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)}
958 canMovePinnedUp={
959 arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0
960 }
961 canMovePinnedDown={(() => {
962 const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`);
963 return index !== -1 && index < arrangedPinnedKeys.length - 1;
964 })()}
933 onSnoozeThread={snoozeThread}965 onSnoozeThread={snoozeThread}
934 onUnsnoozeThread={unsnoozeThread}966 onUnsnoozeThread={unsnoozeThread}
935 onUnsettleThread={unsettleThread}967 onUnsettleThread={unsettleThread}
936 onPinThread={pinThread}968 onPinThread={pinThread}
937 onUnpinThread={unpinThread}969 onUnpinThread={unpinThread}
970 onMovePinnedThread={movePinnedThread}
938 onChangeRequestState={handleChangeRequestState}971 onChangeRequestState={handleChangeRequestState}
939 projectCwd={projectCwdByKey.get(scopeKey) ?? null}972 projectCwd={projectCwdByKey.get(scopeKey) ?? null}
940 onSwipeableClose={handleSwipeableClose}973 onSwipeableClose={handleSwipeableClose}
...@@ -1055,13 +1088,16 @@ function ThreadNavigationSidebarPane(...@@ -1055,13 +1088,16 @@ function ThreadNavigationSidebarPane(
1055 },1088 },
1056 [1089 [
1057 archiveThread,1090 archiveThread,
1091 arrangedPinnedKeys,
1058 confirmDeletePendingTask,1092 confirmDeletePendingTask,
1059 confirmDeleteThread,1093 confirmDeleteThread,
1060 handleChangeRequestState,1094 handleChangeRequestState,
1061 handleSelectThread,1095 handleSelectThread,
1062 handleSwipeableClose,1096 handleSwipeableClose,
1063 handleSwipeableWillOpen,1097 handleSwipeableWillOpen,
1098 movePinnedThread,
1064 openPendingTask,1099 openPendingTask,
1100 pinReorderEnvironmentIds,
1065 pinThread,1101 pinThread,
1066 pinningEnvironmentIds,1102 pinningEnvironmentIds,
1067 projectByKey,1103 projectByKey,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+44-1
...@@ -354,6 +354,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -354,6 +354,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
354 readonly snoozeSupported: boolean;354 readonly snoozeSupported: boolean;
355 /** False on servers that predate thread.pin/unpin. */355 /** False on servers that predate thread.pin/unpin. */
356 readonly pinningSupported: boolean;356 readonly pinningSupported: boolean;
357 /** False on servers that predate thread.pin.reorder. Gates the pinned
358 Move up / Move down menu items. */
359 readonly pinReorderSupported?: boolean;
360 readonly onMovePinnedThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void;
361 /** Position flags for the pinned block so the menu disables the move that
362 would fall off the end of the list. */
363 readonly canMovePinnedUp?: boolean;
364 readonly canMovePinnedDown?: boolean;
357 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;365 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
358 readonly onSwipeableClose: (methods: SwipeableMethods) => void;366 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
359 /** Reports this row's live PR state up so the partition can auto-settle367 /** Reports this row's live PR state up so the partition can auto-settle
...@@ -382,6 +390,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -382,6 +390,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
382 onArchiveThread,390 onArchiveThread,
383 onPinThread,391 onPinThread,
384 onUnpinThread,392 onUnpinThread,
393 onMovePinnedThread,
385 onChangeRequestState,394 onChangeRequestState,
386 } = props;395 } = props;
387 const snoozedRow = props.snoozed === true;396 const snoozedRow = props.snoozed === true;
...@@ -416,6 +425,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -416,6 +425,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
416 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);425 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
417 const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);426 const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);
418 const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);427 const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);
428 const handleMovePinnedUp = useCallback(
429 () => onMovePinnedThread?.(thread, "up"),
430 [onMovePinnedThread, thread],
431 );
432 const handleMovePinnedDown = useCallback(
433 () => onMovePinnedThread?.(thread, "down"),
434 [onMovePinnedThread, thread],
435 );
419 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);436 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
420437
421 // Swipe: the v2 primary action is the lifecycle transition. Every settled438 // Swipe: the v2 primary action is the lifecycle transition. Every settled
...@@ -459,12 +476,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -459,12 +476,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
459 () =>476 () =>
460 props.pinningSupported477 props.pinningSupported
461 ? [478 ? [
479 ...(pinnedRow && props.pinReorderSupported === true
480 ? [
481 {
482 id: "move-pin-up",
483 title: "Move up",
484 image: "arrow.up",
485 attributes: { disabled: props.canMovePinnedUp !== true },
486 } satisfies MenuAction,
487 {
488 id: "move-pin-down",
489 title: "Move down",
490 image: "arrow.down",
491 attributes: { disabled: props.canMovePinnedDown !== true },
492 } satisfies MenuAction,
493 ]
494 : []),
462 pinnedRow495 pinnedRow
463 ? { id: "unpin", title: "Unpin", image: "pin.slash" }496 ? { id: "unpin", title: "Unpin", image: "pin.slash" }
464 : { id: "pin", title: "Pin", image: "pin" },497 : { id: "pin", title: "Pin", image: "pin" },
465 ]498 ]
466 : [],499 : [],
467 [pinnedRow, props.pinningSupported],500 [
501 pinnedRow,
502 props.canMovePinnedDown,
503 props.canMovePinnedUp,
504 props.pinReorderSupported,
505 props.pinningSupported,
506 ],
468 );507 );
469 const snoozableCardMenuActions = useMemo<MenuAction[]>(508 const snoozableCardMenuActions = useMemo<MenuAction[]>(
470 () => [509 () => [
...@@ -491,6 +530,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -491,6 +530,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
491 if (nativeEvent.event === "unsnooze") handleUnsnooze();530 if (nativeEvent.event === "unsnooze") handleUnsnooze();
492 if (nativeEvent.event === "pin") handlePin();531 if (nativeEvent.event === "pin") handlePin();
493 if (nativeEvent.event === "unpin") handleUnpin();532 if (nativeEvent.event === "unpin") handleUnpin();
533 if (nativeEvent.event === "move-pin-up") handleMovePinnedUp();
534 if (nativeEvent.event === "move-pin-down") handleMovePinnedDown();
494 if (nativeEvent.event === "archive") handleArchive();535 if (nativeEvent.event === "archive") handleArchive();
495 if (nativeEvent.event === "delete") handleDelete();536 if (nativeEvent.event === "delete") handleDelete();
496 const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({537 const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({
...@@ -507,6 +548,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -507,6 +548,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
507 [548 [
508 handleArchive,549 handleArchive,
509 handleDelete,550 handleDelete,
551 handleMovePinnedDown,
552 handleMovePinnedUp,
510 handlePin,553 handlePin,
511 handleSettle,554 handleSettle,
512 handleSnooze,555 handleSnooze,
apps/mobile/src/features/threads/threadListV2.ts+4-3
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";9import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
10import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";10import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
11import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";11import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
12import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
12import type { EnvironmentId, ProjectId } from "@t3tools/contracts";13import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
1314
14import type { PendingNewTask } from "../../state/use-pending-new-tasks";15import type { PendingNewTask } from "../../state/use-pending-new-tasks";
...@@ -383,8 +384,8 @@ export function buildThreadListV2Items(input: {...@@ -383,8 +384,8 @@ export function buildThreadListV2Items(input: {
383 input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;384 input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
384 // Visibility parity with web: snooze outranks everything, including a385 // Visibility parity with web: snooze outranks everything, including a
385 // pin — a snoozed thread leaves the list until it wakes (or raises its386 // pin — a snoozed thread leaves the list until it wakes (or raises its
386 // hand). The pin survives underneath, so a woken thread reappears at387 // hand). The pin (and its pinOrderKey) survives underneath, so a woken
387 // its original spot in the creation-ordered pinned block.388 // thread reappears at its exact spot in the pinned block.
388 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {389 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
389 snoozed.push(thread);390 snoozed.push(thread);
390 if (391 if (
...@@ -444,7 +445,7 @@ export function buildThreadListV2Items(input: {...@@ -444,7 +445,7 @@ export function buildThreadListV2Items(input: {
444 );445 );
445446
446 const items: ThreadListV2Item[] = [];447 const items: ThreadListV2Item[] = [];
447 for (const thread of sortThreadsForListV2(pinned)) {448 for (const thread of sortPinnedThreadsByOrderKey(pinned)) {
448 items.push({449 items.push({
449 thread,450 thread,
450 variant: "card",451 variant: "card",
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -146,6 +146,7 @@ export const make = Effect.gen(function* () {...@@ -146,6 +146,7 @@ export const make = Effect.gen(function* () {
146 threadSettlement: true,146 threadSettlement: true,
147 threadSnooze: true,147 threadSnooze: true,
148 threadPinning: true,148 threadPinning: true,
149 threadPinReorder: true,
149 threadTitleRegeneration: true,150 threadTitleRegeneration: true,
150 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),151 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
151 ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),152 ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+20
...@@ -612,6 +612,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -612,6 +612,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
612 snoozedUntil: null,612 snoozedUntil: null,
613 snoozedAt: null,613 snoozedAt: null,
614 pinnedAt: null,614 pinnedAt: null,
615 pinOrderKey: null,
615 titleRegenerationRequestId: null,616 titleRegenerationRequestId: null,
616 titleRegenerationStartedAt: null,617 titleRegenerationStartedAt: null,
617 latestUserMessageAt: null,618 latestUserMessageAt: null,
...@@ -728,6 +729,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -728,6 +729,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
728 yield* projectionThreadRepository.upsert({729 yield* projectionThreadRepository.upsert({
729 ...existingRow.value,730 ...existingRow.value,
730 pinnedAt: event.payload.pinnedAt,731 pinnedAt: event.payload.pinnedAt,
732 ...(event.payload.pinOrderKey !== undefined
733 ? { pinOrderKey: event.payload.pinOrderKey }
734 : {}),
731 updatedAt: event.payload.updatedAt,735 updatedAt: event.payload.updatedAt,
732 });736 });
733 return;737 return;
...@@ -743,6 +747,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -743,6 +747,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
743 yield* projectionThreadRepository.upsert({747 yield* projectionThreadRepository.upsert({
744 ...existingRow.value,748 ...existingRow.value,
745 pinnedAt: null,749 pinnedAt: null,
750 pinOrderKey: null,
751 updatedAt: event.payload.updatedAt,
752 });
753 return;
754 }
755
756 case "thread.pin-reordered": {
757 const existingRow = yield* projectionThreadRepository.getById({
758 threadId: event.payload.threadId,
759 });
760 if (Option.isNone(existingRow)) {
761 return;
762 }
763 yield* projectionThreadRepository.upsert({
764 ...existingRow.value,
765 pinOrderKey: event.payload.orderKey,
746 updatedAt: event.payload.updatedAt,766 updatedAt: event.payload.updatedAt,
747 });767 });
748 return;768 return;
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+8-2
...@@ -87,6 +87,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -87,6 +87,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
87 pending_approval_count,87 pending_approval_count,
88 pending_user_input_count,88 pending_user_input_count,
89 has_actionable_proposed_plan,89 has_actionable_proposed_plan,
90 pinned_at,
91 pin_order_key,
90 created_at,92 created_at,
91 updated_at,93 updated_at,
92 deleted_at94 deleted_at
...@@ -105,6 +107,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -105,6 +107,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
105 1,107 1,
106 0,108 0,
107 0,109 0,
110 '2026-02-24T00:00:01.000Z',
111 'gm',
108 '2026-02-24T00:00:02.000Z',112 '2026-02-24T00:00:02.000Z',
109 '2026-02-24T00:00:03.000Z',113 '2026-02-24T00:00:03.000Z',
110 NULL114 NULL
...@@ -317,7 +321,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -317,7 +321,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
317 settledAt: null,321 settledAt: null,
318 snoozedUntil: null,322 snoozedUntil: null,
319 snoozedAt: null,323 snoozedAt: null,
320 pinnedAt: null,324 pinnedAt: "2026-02-24T00:00:01.000Z",
325 pinOrderKey: "gm",
321 titleRegeneration: null,326 titleRegeneration: null,
322 deletedAt: null,327 deletedAt: null,
323 messages: [328 messages: [
...@@ -433,7 +438,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -433,7 +438,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
433 settledAt: null,438 settledAt: null,
434 snoozedUntil: null,439 snoozedUntil: null,
435 snoozedAt: null,440 snoozedAt: null,
436 pinnedAt: null,441 pinnedAt: "2026-02-24T00:00:01.000Z",
442 pinOrderKey: "gm",
437 titleRegeneration: null,443 titleRegeneration: null,
438 session: {444 session: {
439 threadId: ThreadId.make("thread-1"),445 threadId: ThreadId.make("thread-1"),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10
...@@ -423,6 +423,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -423,6 +423,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
423 snoozed_until AS "snoozedUntil",423 snoozed_until AS "snoozedUntil",
424 snoozed_at AS "snoozedAt",424 snoozed_at AS "snoozedAt",
425 pinned_at AS "pinnedAt",425 pinned_at AS "pinnedAt",
426 pin_order_key AS "pinOrderKey",
426 title_regeneration_request_id AS "titleRegenerationRequestId",427 title_regeneration_request_id AS "titleRegenerationRequestId",
427 title_regeneration_started_at AS "titleRegenerationStartedAt",428 title_regeneration_started_at AS "titleRegenerationStartedAt",
428 latest_user_message_at AS "latestUserMessageAt",429 latest_user_message_at AS "latestUserMessageAt",
...@@ -458,6 +459,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -458,6 +459,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
458 snoozed_until AS "snoozedUntil",459 snoozed_until AS "snoozedUntil",
459 snoozed_at AS "snoozedAt",460 snoozed_at AS "snoozedAt",
460 pinned_at AS "pinnedAt",461 pinned_at AS "pinnedAt",
462 pin_order_key AS "pinOrderKey",
461 title_regeneration_request_id AS "titleRegenerationRequestId",463 title_regeneration_request_id AS "titleRegenerationRequestId",
462 title_regeneration_started_at AS "titleRegenerationStartedAt",464 title_regeneration_started_at AS "titleRegenerationStartedAt",
463 latest_user_message_at AS "latestUserMessageAt",465 latest_user_message_at AS "latestUserMessageAt",
...@@ -495,6 +497,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -495,6 +497,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
495 snoozed_until AS "snoozedUntil",497 snoozed_until AS "snoozedUntil",
496 snoozed_at AS "snoozedAt",498 snoozed_at AS "snoozedAt",
497 pinned_at AS "pinnedAt",499 pinned_at AS "pinnedAt",
500 pin_order_key AS "pinOrderKey",
498 title_regeneration_request_id AS "titleRegenerationRequestId",501 title_regeneration_request_id AS "titleRegenerationRequestId",
499 title_regeneration_started_at AS "titleRegenerationStartedAt",502 title_regeneration_started_at AS "titleRegenerationStartedAt",
500 latest_user_message_at AS "latestUserMessageAt",503 latest_user_message_at AS "latestUserMessageAt",
...@@ -932,6 +935,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -932,6 +935,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
932 snoozed_until AS "snoozedUntil",935 snoozed_until AS "snoozedUntil",
933 snoozed_at AS "snoozedAt",936 snoozed_at AS "snoozedAt",
934 pinned_at AS "pinnedAt",937 pinned_at AS "pinnedAt",
938 pin_order_key AS "pinOrderKey",
935 title_regeneration_request_id AS "titleRegenerationRequestId",939 title_regeneration_request_id AS "titleRegenerationRequestId",
936 title_regeneration_started_at AS "titleRegenerationStartedAt",940 title_regeneration_started_at AS "titleRegenerationStartedAt",
937 latest_user_message_at AS "latestUserMessageAt",941 latest_user_message_at AS "latestUserMessageAt",
...@@ -1562,6 +1566,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1562,6 +1566,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1562 snoozedUntil: row.snoozedUntil,1566 snoozedUntil: row.snoozedUntil,
1563 snoozedAt: row.snoozedAt,1567 snoozedAt: row.snoozedAt,
1564 pinnedAt: row.pinnedAt,1568 pinnedAt: row.pinnedAt,
1569 pinOrderKey: row.pinOrderKey ?? null,
1565 titleRegeneration: mapTitleRegeneration(row),1570 titleRegeneration: mapTitleRegeneration(row),
1566 deletedAt: row.deletedAt,1571 deletedAt: row.deletedAt,
1567 messages: messagesByThread.get(row.threadId) ?? [],1572 messages: messagesByThread.get(row.threadId) ?? [],
...@@ -1766,6 +1771,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1766,6 +1771,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1766 snoozedUntil: row.snoozedUntil,1771 snoozedUntil: row.snoozedUntil,
1767 snoozedAt: row.snoozedAt,1772 snoozedAt: row.snoozedAt,
1768 pinnedAt: row.pinnedAt,1773 pinnedAt: row.pinnedAt,
1774 pinOrderKey: row.pinOrderKey ?? null,
1769 titleRegeneration: mapTitleRegeneration(row),1775 titleRegeneration: mapTitleRegeneration(row),
1770 deletedAt: row.deletedAt,1776 deletedAt: row.deletedAt,
1771 messages: [],1777 messages: [],
...@@ -1901,6 +1907,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1901,6 +1907,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1901 snoozedUntil: row.snoozedUntil,1907 snoozedUntil: row.snoozedUntil,
1902 snoozedAt: row.snoozedAt,1908 snoozedAt: row.snoozedAt,
1903 pinnedAt: row.pinnedAt,1909 pinnedAt: row.pinnedAt,
1910 pinOrderKey: row.pinOrderKey ?? null,
1904 titleRegeneration: mapTitleRegeneration(row),1911 titleRegeneration: mapTitleRegeneration(row),
1905 session: sessionByThread.get(row.threadId) ?? null,1912 session: sessionByThread.get(row.threadId) ?? null,
1906 latestUserMessageAt: row.latestUserMessageAt,1913 latestUserMessageAt: row.latestUserMessageAt,
...@@ -2045,6 +2052,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2045,6 +2052,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2045 snoozedUntil: row.snoozedUntil,2052 snoozedUntil: row.snoozedUntil,
2046 snoozedAt: row.snoozedAt,2053 snoozedAt: row.snoozedAt,
2047 pinnedAt: row.pinnedAt,2054 pinnedAt: row.pinnedAt,
2055 pinOrderKey: row.pinOrderKey ?? null,
2048 titleRegeneration: mapTitleRegeneration(row),2056 titleRegeneration: mapTitleRegeneration(row),
2049 session: sessionByThread.get(row.threadId) ?? null,2057 session: sessionByThread.get(row.threadId) ?? null,
2050 latestUserMessageAt: row.latestUserMessageAt,2058 latestUserMessageAt: row.latestUserMessageAt,
...@@ -2321,6 +2329,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2321,6 +2329,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2321 snoozedUntil: threadRow.value.snoozedUntil,2329 snoozedUntil: threadRow.value.snoozedUntil,
2322 snoozedAt: threadRow.value.snoozedAt,2330 snoozedAt: threadRow.value.snoozedAt,
2323 pinnedAt: threadRow.value.pinnedAt,2331 pinnedAt: threadRow.value.pinnedAt,
2332 pinOrderKey: threadRow.value.pinOrderKey ?? null,
2324 titleRegeneration: mapTitleRegeneration(threadRow.value),2333 titleRegeneration: mapTitleRegeneration(threadRow.value),
2325 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,2334 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
2326 latestUserMessageAt: threadRow.value.latestUserMessageAt,2335 latestUserMessageAt: threadRow.value.latestUserMessageAt,
...@@ -2441,6 +2450,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2441,6 +2450,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2441 snoozedUntil: threadRow.value.snoozedUntil,2450 snoozedUntil: threadRow.value.snoozedUntil,
2442 snoozedAt: threadRow.value.snoozedAt,2451 snoozedAt: threadRow.value.snoozedAt,
2443 pinnedAt: threadRow.value.pinnedAt,2452 pinnedAt: threadRow.value.pinnedAt,
2453 pinOrderKey: threadRow.value.pinOrderKey ?? null,
2444 titleRegeneration: mapTitleRegeneration(threadRow.value),2454 titleRegeneration: mapTitleRegeneration(threadRow.value),
2445 deletedAt: null,2455 deletedAt: null,
2446 messages: messageRows.map((row) => {2456 messages: messageRows.map((row) => {
apps/server/src/orchestration/Schemas.ts+2
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema,15 ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema,
16 ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema,16 ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema,
17 ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema,17 ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema,
18 ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema,
18 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,19 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
19 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,20 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
20 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,21 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
...@@ -46,6 +47,7 @@ export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema;...@@ -46,6 +47,7 @@ export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema;
46export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;47export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;
47export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema;48export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema;
48export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema;49export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema;
50export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema;
4951
50export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;52export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
51export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;53export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;
apps/server/src/orchestration/decider.pinned.test.ts+98
...@@ -16,6 +16,7 @@ const PINNED_AT = "1969-12-30T00:00:00.000Z";...@@ -16,6 +16,7 @@ const PINNED_AT = "1969-12-30T00:00:00.000Z";
1616
17function makeReadModel(input: {17function makeReadModel(input: {
18 readonly pinnedAt?: string | null;18 readonly pinnedAt?: string | null;
19 readonly pinOrderKey?: string | null;
19 readonly archivedAt?: string | null;20 readonly archivedAt?: string | null;
20 readonly settledOverride?: "settled" | "active" | null;21 readonly settledOverride?: "settled" | "active" | null;
21 readonly settledAt?: string | null;22 readonly settledAt?: string | null;
...@@ -44,6 +45,7 @@ function makeReadModel(input: {...@@ -44,6 +45,7 @@ function makeReadModel(input: {
44 snoozedUntil: input.snoozedUntil ?? null,45 snoozedUntil: input.snoozedUntil ?? null,
45 snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? PINNED_AT : null),46 snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? PINNED_AT : null),
46 pinnedAt: input.pinnedAt ?? null,47 pinnedAt: input.pinnedAt ?? null,
48 pinOrderKey: input.pinOrderKey ?? null,
47 deletedAt: null,49 deletedAt: null,
48 messages: [],50 messages: [],
49 proposedPlans: [],51 proposedPlans: [],
...@@ -223,4 +225,100 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => {...@@ -223,4 +225,100 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => {
223 expect(error._tag).toBe("OrchestrationCommandInvariantError");225 expect(error._tag).toBe("OrchestrationCommandInvariantError");
224 }),226 }),
225 );227 );
228
229 it.effect("a fresh pin carries the client's order key", () =>
230 Effect.gen(function* () {
231 const event = yield* decideOrchestrationCommand({
232 command: {
233 type: "thread.pin",
234 commandId: CommandId.make("cmd-pin-keyed"),
235 threadId: ThreadId.make("thread-1"),
236 orderKey: "g",
237 },
238 readModel: makeReadModel({}),
239 });
240 const events = Array.isArray(event) ? event : [event];
241 expect(events[0]?.type).toBe("thread.pinned");
242 if (events[0]?.type === "thread.pinned") {
243 expect(events[0].payload.pinOrderKey).toBe("g");
244 }
245 }),
246 );
247
248 it.effect(
249 "re-pinning ignores the incoming order key so raced pins cannot move a placed thread",
250 () =>
251 Effect.gen(function* () {
252 const event = yield* decideOrchestrationCommand({
253 command: {
254 type: "thread.pin",
255 commandId: CommandId.make("cmd-pin-keyed-again"),
256 threadId: ThreadId.make("thread-1"),
257 orderKey: "t",
258 },
259 readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }),
260 });
261 const events = Array.isArray(event) ? event : [event];
262 expect(events[0]?.type).toBe("thread.pinned");
263 if (events[0]?.type === "thread.pinned") {
264 expect(events[0].payload.pinOrderKey).toBeUndefined();
265 }
266 }),
267 );
268
269 it.effect("reorders a pinned thread, stamping the new key", () =>
270 Effect.gen(function* () {
271 const event = yield* decideOrchestrationCommand({
272 command: {
273 type: "thread.pin.reorder",
274 commandId: CommandId.make("cmd-reorder"),
275 threadId: ThreadId.make("thread-1"),
276 orderKey: "m",
277 },
278 readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }),
279 });
280 const events = Array.isArray(event) ? event : [event];
281 expect(events[0]?.type).toBe("thread.pin-reordered");
282 if (events[0]?.type === "thread.pin-reordered") {
283 expect(events[0].payload.orderKey).toBe("m");
284 // A real move stamps the command time (the test clock), not the
285 // thread's previous updatedAt.
286 expect(events[0].payload.updatedAt).not.toBe(NOW);
287 }
288 }),
289 );
290
291 it.effect("reordering onto the same key preserves updatedAt", () =>
292 Effect.gen(function* () {
293 const event = yield* decideOrchestrationCommand({
294 command: {
295 type: "thread.pin.reorder",
296 commandId: CommandId.make("cmd-reorder-noop"),
297 threadId: ThreadId.make("thread-1"),
298 orderKey: "g",
299 },
300 readModel: makeReadModel({ pinnedAt: PINNED_AT, pinOrderKey: "g" }),
301 });
302 const events = Array.isArray(event) ? event : [event];
303 expect(events[0]?.type).toBe("thread.pin-reordered");
304 if (events[0]?.type === "thread.pin-reordered") {
305 expect(events[0].payload.updatedAt).toBe(NOW);
306 }
307 }),
308 );
309
310 it.effect("rejects reordering an unpinned thread", () =>
311 Effect.gen(function* () {
312 const error = yield* decideOrchestrationCommand({
313 command: {
314 type: "thread.pin.reorder",
315 commandId: CommandId.make("cmd-reorder-unpinned"),
316 threadId: ThreadId.make("thread-1"),
317 orderKey: "m",
318 },
319 readModel: makeReadModel({}),
320 }).pipe(Effect.flip);
321 expect(error._tag).toBe("OrchestrationCommandInvariantError");
322 }),
323 );
226});324});
apps/server/src/orchestration/decider.ts+43
...@@ -676,6 +676,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -676,6 +676,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
676 payload: {676 payload: {
677 threadId: command.threadId,677 threadId: command.threadId,
678 pinnedAt: existingPinnedAt ?? occurredAt,678 pinnedAt: existingPinnedAt ?? occurredAt,
679 // A fresh pin takes the client's slot in the arranged order; on a
680 // re-pin the existing key wins so raced duplicates cannot move a
681 // thread the user already placed.
682 ...(existingPinnedAt === null && command.orderKey !== undefined
683 ? { pinOrderKey: command.orderKey }
684 : {}),
679 updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt,685 updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt,
680 },686 },
681 };687 };
...@@ -745,6 +751,43 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -745,6 +751,43 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
745 };751 };
746 }752 }
747753
754 case "thread.pin.reorder": {
755 const thread = yield* requireThreadNotArchived({
756 readModel,
757 command,
758 threadId: command.threadId,
759 });
760 // Only pinned threads have a slot in the arranged order. Rejecting
761 // (rather than silently pinning) keeps a raced reorder-after-unpin
762 // from resurrecting a pin the user just cleared.
763 if (thread.pinnedAt == null) {
764 return yield* Effect.fail(
765 new OrchestrationCommandInvariantError({
766 commandType: command.type,
767 detail: `thread ${command.threadId} is not pinned and cannot be reordered`,
768 }),
769 );
770 }
771 // Idempotent by re-emission (see thread.settle): a duplicate drop on
772 // the same slot keeps the existing updatedAt so it projects as a no-op.
773 const keyUnchanged = thread.pinOrderKey === command.orderKey;
774 const occurredAt = yield* nowIso;
775 return {
776 ...(yield* withEventBase({
777 aggregateKind: "thread",
778 aggregateId: command.threadId,
779 occurredAt,
780 commandId: command.commandId,
781 })),
782 type: "thread.pin-reordered",
783 payload: {
784 threadId: command.threadId,
785 orderKey: command.orderKey,
786 updatedAt: keyUnchanged ? thread.updatedAt : occurredAt,
787 },
788 };
789 }
790
748 case "thread.meta.update": {791 case "thread.meta.update": {
749 const thread = yield* requireThread({792 const thread = yield* requireThread({
750 readModel,793 readModel,
apps/server/src/orchestration/projector.pinned.test.ts+77
...@@ -75,3 +75,80 @@ it.effect("projects pin lifecycle events", () =>...@@ -75,3 +75,80 @@ it.effect("projects pin lifecycle events", () =>
75 expect(unpinned.threads[0]?.pinnedAt).toBeNull();75 expect(unpinned.threads[0]?.pinnedAt).toBeNull();
76 }),76 }),
77);77);
78
79it.effect("projects pin order key lifecycle", () =>
80 Effect.gen(function* () {
81 const now = "2026-01-01T00:00:00.000Z";
82 const created = yield* projectEvent(
83 createEmptyReadModel(now),
84 makeEvent({
85 sequence: 1,
86 type: "thread.created",
87 payload: {
88 threadId: ThreadId.make("thread-1"),
89 projectId: ProjectId.make("project-1"),
90 title: "Thread",
91 modelSelection: { provider: "codex", model: "gpt-5.4" },
92 runtimeMode: "full-access",
93 interactionMode: "default",
94 branch: null,
95 worktreePath: null,
96 createdAt: now,
97 updatedAt: now,
98 },
99 }),
100 );
101 expect(created.threads[0]?.pinOrderKey ?? null).toBeNull();
102
103 // Fresh pin carries the client's slot in the arranged order.
104 const pinned = yield* projectEvent(
105 created,
106 makeEvent({
107 sequence: 2,
108 type: "thread.pinned",
109 payload: {
110 threadId: ThreadId.make("thread-1"),
111 pinnedAt: now,
112 pinOrderKey: "g",
113 updatedAt: now,
114 },
115 }),
116 );
117 expect(pinned.threads[0]?.pinOrderKey).toBe("g");
118
119 // Re-pins and events from pre-reorder servers omit the field entirely;
120 // the existing key must survive rather than being nulled out.
121 const repinned = yield* projectEvent(
122 pinned,
123 makeEvent({
124 sequence: 3,
125 type: "thread.pinned",
126 payload: { threadId: ThreadId.make("thread-1"), pinnedAt: now, updatedAt: now },
127 }),
128 );
129 expect(repinned.threads[0]?.pinOrderKey).toBe("g");
130
131 // A drag persists the new slot.
132 const reordered = yield* projectEvent(
133 repinned,
134 makeEvent({
135 sequence: 4,
136 type: "thread.pin-reordered",
137 payload: { threadId: ThreadId.make("thread-1"), orderKey: "m", updatedAt: now },
138 }),
139 );
140 expect(reordered.threads[0]?.pinOrderKey).toBe("m");
141
142 // Unpin clears the slot: re-pinning is "pin again", not "restore an
143 // ancient position".
144 const unpinned = yield* projectEvent(
145 reordered,
146 makeEvent({
147 sequence: 5,
148 type: "thread.unpinned",
149 payload: { threadId: ThreadId.make("thread-1"), updatedAt: now },
150 }),
151 );
152 expect(unpinned.threads[0]?.pinOrderKey).toBeNull();
153 }),
154);
apps/server/src/orchestration/projector.ts+16
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24 ThreadRuntimeModeSetPayload,24 ThreadRuntimeModeSetPayload,
25 ThreadSettledPayload,25 ThreadSettledPayload,
26 ThreadPinnedPayload,26 ThreadPinnedPayload,
27 ThreadPinReorderedPayload,
27 ThreadSnoozedPayload,28 ThreadSnoozedPayload,
28 ThreadUnpinnedPayload,29 ThreadUnpinnedPayload,
29 ThreadUnarchivedPayload,30 ThreadUnarchivedPayload,
...@@ -402,6 +403,7 @@ export function projectEvent(...@@ -402,6 +403,7 @@ export function projectEvent(
402 ...nextBase,403 ...nextBase,
403 threads: updateThread(nextBase.threads, payload.threadId, {404 threads: updateThread(nextBase.threads, payload.threadId, {
404 pinnedAt: payload.pinnedAt,405 pinnedAt: payload.pinnedAt,
406 ...(payload.pinOrderKey !== undefined ? { pinOrderKey: payload.pinOrderKey } : {}),
405 updatedAt: payload.updatedAt,407 updatedAt: payload.updatedAt,
406 }),408 }),
407 })),409 })),
...@@ -413,6 +415,20 @@ export function projectEvent(...@@ -413,6 +415,20 @@ export function projectEvent(
413 ...nextBase,415 ...nextBase,
414 threads: updateThread(nextBase.threads, payload.threadId, {416 threads: updateThread(nextBase.threads, payload.threadId, {
415 pinnedAt: null,417 pinnedAt: null,
418 // Unpin clears the slot: re-pinning is "pin again", not "restore
419 // an ancient position".
420 pinOrderKey: null,
421 updatedAt: payload.updatedAt,
422 }),
423 })),
424 );
425
426 case "thread.pin-reordered":
427 return decodeForEvent(ThreadPinReorderedPayload, event.payload, event.type, "payload").pipe(
428 Effect.map((payload) => ({
429 ...nextBase,
430 threads: updateThread(nextBase.threads, payload.threadId, {
431 pinOrderKey: payload.orderKey,
416 updatedAt: payload.updatedAt,432 updatedAt: payload.updatedAt,
417 }),433 }),
418 })),434 })),
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
...@@ -48,6 +48,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -48,6 +48,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
48 snoozed_until,48 snoozed_until,
49 snoozed_at,49 snoozed_at,
50 pinned_at,50 pinned_at,
51 pin_order_key,
51 title_regeneration_request_id,52 title_regeneration_request_id,
52 title_regeneration_started_at,53 title_regeneration_started_at,
53 latest_user_message_at,54 latest_user_message_at,
...@@ -74,6 +75,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -74,6 +75,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
74 ${row.snoozedUntil},75 ${row.snoozedUntil},
75 ${row.snoozedAt},76 ${row.snoozedAt},
76 ${row.pinnedAt},77 ${row.pinnedAt},
78 ${row.pinOrderKey ?? null},
77 ${row.titleRegenerationRequestId ?? null},79 ${row.titleRegenerationRequestId ?? null},
78 ${row.titleRegenerationStartedAt ?? null},80 ${row.titleRegenerationStartedAt ?? null},
79 ${row.latestUserMessageAt},81 ${row.latestUserMessageAt},
...@@ -100,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -100,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
100 snoozed_until = excluded.snoozed_until,102 snoozed_until = excluded.snoozed_until,
101 snoozed_at = excluded.snoozed_at,103 snoozed_at = excluded.snoozed_at,
102 pinned_at = excluded.pinned_at,104 pinned_at = excluded.pinned_at,
105 pin_order_key = excluded.pin_order_key,
103 title_regeneration_request_id = excluded.title_regeneration_request_id,106 title_regeneration_request_id = excluded.title_regeneration_request_id,
104 title_regeneration_started_at = excluded.title_regeneration_started_at,107 title_regeneration_started_at = excluded.title_regeneration_started_at,
105 latest_user_message_at = excluded.latest_user_message_at,108 latest_user_message_at = excluded.latest_user_message_at,
...@@ -133,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -133,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
133 snoozed_until AS "snoozedUntil",136 snoozed_until AS "snoozedUntil",
134 snoozed_at AS "snoozedAt",137 snoozed_at AS "snoozedAt",
135 pinned_at AS "pinnedAt",138 pinned_at AS "pinnedAt",
139 pin_order_key AS "pinOrderKey",
136 title_regeneration_request_id AS "titleRegenerationRequestId",140 title_regeneration_request_id AS "titleRegenerationRequestId",
137 title_regeneration_started_at AS "titleRegenerationStartedAt",141 title_regeneration_started_at AS "titleRegenerationStartedAt",
138 latest_user_message_at AS "latestUserMessageAt",142 latest_user_message_at AS "latestUserMessageAt",
...@@ -168,6 +172,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -168,6 +172,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
168 snoozed_until AS "snoozedUntil",172 snoozed_until AS "snoozedUntil",
169 snoozed_at AS "snoozedAt",173 snoozed_at AS "snoozedAt",
170 pinned_at AS "pinnedAt",174 pinned_at AS "pinnedAt",
175 pin_order_key AS "pinOrderKey",
171 title_regeneration_request_id AS "titleRegenerationRequestId",176 title_regeneration_request_id AS "titleRegenerationRequestId",
172 title_regeneration_started_at AS "titleRegenerationStartedAt",177 title_regeneration_started_at AS "titleRegenerationStartedAt",
173 latest_user_message_at AS "latestUserMessageAt",178 latest_user_message_at AS "latestUserMessageAt",
apps/server/src/persistence/Migrations.ts+2
...@@ -50,6 +50,7 @@ import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";...@@ -50,6 +50,7 @@ import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";
50import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts";50import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts";
51import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts";51import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts";
52import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts";52import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts";
53import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";
5354
54/**55/**
55 * Migration loader with all migrations defined inline.56 * Migration loader with all migrations defined inline.
...@@ -99,6 +100,7 @@ export const migrationEntries = [...@@ -99,6 +100,7 @@ export const migrationEntries = [
99 [35, "ProjectionThreadTitleRegeneration", Migration0035],100 [35, "ProjectionThreadTitleRegeneration", Migration0035],
100 [36, "ProjectionThreadsPinned", Migration0036],101 [36, "ProjectionThreadsPinned", Migration0036],
101 [37, "ProjectionTurnsKeysetIndex", Migration0037],102 [37, "ProjectionTurnsKeysetIndex", Migration0037],
103 [38, "ProjectionThreadsPinOrderKey", Migration0038],
102] as const;104] as const;
103105
104export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);106export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/038_ProjectionThreadsPinOrderKey.ts created+16
...@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6 const columns = yield* sql<{ readonly name: string }>`
7 PRAGMA table_info(projection_threads)
8 `;
9
10 if (!columns.some((column) => column.name === "pin_order_key")) {
11 yield* sql`
12 ALTER TABLE projection_threads
13 ADD COLUMN pin_order_key TEXT
14 `;
15 }
16});
apps/server/src/persistence/Services/ProjectionThreads.ts+1
...@@ -42,6 +42,7 @@ export const ProjectionThread = Schema.Struct({...@@ -42,6 +42,7 @@ export const ProjectionThread = Schema.Struct({
42 snoozedUntil: Schema.NullOr(IsoDateTime),42 snoozedUntil: Schema.NullOr(IsoDateTime),
43 snoozedAt: Schema.NullOr(IsoDateTime),43 snoozedAt: Schema.NullOr(IsoDateTime),
44 pinnedAt: Schema.NullOr(IsoDateTime),44 pinnedAt: Schema.NullOr(IsoDateTime),
45 pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)),
45 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),46 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),
46 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),47 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
47 latestUserMessageAt: Schema.NullOr(IsoDateTime),48 latestUserMessageAt: Schema.NullOr(IsoDateTime),
apps/web/src/components/Sidebar.logic.test.ts+133
...@@ -26,6 +26,9 @@ import {...@@ -26,6 +26,9 @@ import {
26 shouldClearThreadSelectionOnMouseDown,26 shouldClearThreadSelectionOnMouseDown,
27 sortLogicalProjectsForSidebar,27 sortLogicalProjectsForSidebar,
28 sortSettledThreadsForSidebarV2,28 sortSettledThreadsForSidebarV2,
29 pinOrderKeyBetween,
30 planPinnedReorder,
31 sortPinnedThreadsForSidebarV2,
29 sortThreadsForSidebarV2,32 sortThreadsForSidebarV2,
30 sortProjectsForSidebar,33 sortProjectsForSidebar,
31 sortScopedProjectsForSidebar,34 sortScopedProjectsForSidebar,
...@@ -738,6 +741,136 @@ describe("sortThreadsForSidebarV2", () => {...@@ -738,6 +741,136 @@ describe("sortThreadsForSidebarV2", () => {
738 });741 });
739});742});
740743
744describe("pinOrderKeyBetween", () => {
745 it("produces keys that sort between their bounds", () => {
746 const middle = pinOrderKeyBetween(null, null)!;
747 const top = pinOrderKeyBetween(null, middle)!;
748 const bottom = pinOrderKeyBetween(middle, null)!;
749 expect(top < middle).toBe(true);
750 expect(middle < bottom).toBe(true);
751
752 const between = pinOrderKeyBetween(top, middle)!;
753 expect(top < between && between < middle).toBe(true);
754 });
755
756 it("extends into new digits when bounds are adjacent", () => {
757 const key = pinOrderKeyBetween("g", "h")!;
758 expect("g" < key && key < "h").toBe(true);
759 });
760
761 it("stays strictly ordered under repeated top insertion", () => {
762 // Every new pin lands at the head of the arranged run; keys must keep
763 // sorting before the previous head without ever bottoming out.
764 let head: string | null = null;
765 const keys: string[] = [];
766 for (let i = 0; i < 100; i += 1) {
767 const key: string = pinOrderKeyBetween(null, head)!;
768 expect(key).not.toBeNull();
769 if (head !== null) expect(key < head).toBe(true);
770 keys.push(key);
771 head = key;
772 }
773 expect(new Set(keys).size).toBe(100);
774 });
775
776 it("stays strictly ordered under repeated middle insertion", () => {
777 let low = pinOrderKeyBetween(null, null)!;
778 let high = pinOrderKeyBetween(low, null)!;
779 for (let i = 0; i < 100; i += 1) {
780 const key: string = pinOrderKeyBetween(low, high)!;
781 expect(low < key && key < high).toBe(true);
782 if (i % 2 === 0) low = key;
783 else high = key;
784 }
785 });
786
787 it("returns null for corrupt or out-of-order bounds instead of throwing", () => {
788 expect(pinOrderKeyBetween("z", "a")).toBeNull();
789 expect(pinOrderKeyBetween("A!", null)).toBeNull();
790 expect(pinOrderKeyBetween(null, "ma")).toBeNull();
791 expect(pinOrderKeyBetween("m", "m")).toBeNull();
792 });
793});
794
795describe("planPinnedReorder", () => {
796 it("writes only the moved thread when neighbors are keyed", () => {
797 const assignments = planPinnedReorder({
798 orderedIds: ["a", "c", "b"],
799 keysById: new Map([
800 ["a", "f"],
801 ["b", "m"],
802 ["c", "t"],
803 ]),
804 movedId: "c",
805 });
806 expect(assignments).toHaveLength(1);
807 expect(assignments[0]!.id).toBe("c");
808 expect(assignments[0]!.orderKey > "f" && assignments[0]!.orderKey < "m").toBe(true);
809 });
810
811 it("treats list edges as open bounds", () => {
812 const assignments = planPinnedReorder({
813 orderedIds: ["b", "a"],
814 keysById: new Map([
815 ["a", "m"],
816 ["b", null],
817 ]),
818 movedId: "b",
819 });
820 expect(assignments).toHaveLength(1);
821 expect(assignments[0]!.orderKey < "m").toBe(true);
822 });
823
824 it("materializes keys for the whole section when a neighbor is keyless", () => {
825 const assignments = planPinnedReorder({
826 orderedIds: ["b", "a", "c"],
827 keysById: new Map([
828 ["a", null],
829 ["b", "m"],
830 ["c", null],
831 ]),
832 movedId: "b",
833 });
834 expect(assignments.map((entry) => entry.id)).toEqual(["b", "a", "c"]);
835 const keys = assignments.map((entry) => entry.orderKey);
836 expect([...keys].sort()).toEqual(keys);
837 expect(new Set(keys).size).toBe(keys.length);
838 });
839});
840
841describe("sortPinnedThreadsForSidebarV2", () => {
842 const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({
843 id: input.id,
844 createdAt: input.createdAt,
845 pinOrderKey: input.pinOrderKey ?? null,
846 });
847
848 it("sorts keyed threads by key ahead of keyless threads in creation order", () => {
849 const sorted = sortPinnedThreadsForSidebarV2([
850 pinnable({ id: "keyless-old", createdAt: "2026-03-09T08:00:00.000Z" }),
851 pinnable({ id: "second", createdAt: "2026-03-09T09:00:00.000Z", pinOrderKey: "t" }),
852 pinnable({ id: "keyless-new", createdAt: "2026-03-09T12:00:00.000Z" }),
853 pinnable({ id: "first", createdAt: "2026-03-09T07:00:00.000Z", pinOrderKey: "g" }),
854 ]);
855
856 expect(sorted.map((thread) => thread.id)).toEqual([
857 "first",
858 "second",
859 "keyless-new",
860 "keyless-old",
861 ]);
862 });
863
864 it("breaks equal keys by id so raced writes render identically everywhere", () => {
865 const sorted = sortPinnedThreadsForSidebarV2([
866 pinnable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z", pinOrderKey: "m" }),
867 pinnable({ id: "a", createdAt: "2026-03-09T11:00:00.000Z", pinOrderKey: "m" }),
868 ]);
869
870 expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
871 });
872});
873
741describe("sortSettledThreadsForSidebarV2", () => {874describe("sortSettledThreadsForSidebarV2", () => {
742 const settled = (input: {875 const settled = (input: {
743 id: string;876 id: string;
apps/web/src/components/Sidebar.logic.ts+9
...@@ -510,6 +510,15 @@ export function sortThreadsForSidebarV2<...@@ -510,6 +510,15 @@ export function sortThreadsForSidebarV2<
510 );510 );
511}511}
512512
513// Pinned-reorder key math and the keyed sort live in client-runtime
514// (state/thread-sort) so web and mobile compute identical pinned orders.
515export {
516 generateSpreadPinOrderKeys,
517 pinOrderKeyBetween,
518 planPinnedReorder,
519} from "@t3tools/client-runtime/state/thread-sort";
520export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebarV2 } from "@t3tools/client-runtime/state/thread-sort";
521
513/**522/**
514 * Search the already-ordered sidebar thread collection by title only.523 * Search the already-ordered sidebar thread collection by title only.
515 * Keeping the input order means lifecycle ordering (active, snoozed, settled)524 * Keeping the input order means lifecycle ordering (active, snoozed, settled)
apps/web/src/components/SidebarV2.tsx+306-80
...@@ -1,5 +1,21 @@...@@ -1,5 +1,21 @@
1import { autoAnimate } from "@formkit/auto-animate";1import { autoAnimate } from "@formkit/auto-animate";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import {
4 DndContext,
5 PointerSensor,
6 closestCenter,
7 useSensor,
8 useSensors,
9 type DragEndEvent,
10} from "@dnd-kit/core";
11import {
12 SortableContext,
13 arrayMove,
14 useSortable,
15 verticalListSortingStrategy,
16} from "@dnd-kit/sortable";
17import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
18import { CSS } from "@dnd-kit/utilities";
3import {19import {
4 canSnooze,20 canSnooze,
5 effectiveSettled,21 effectiveSettled,
...@@ -116,6 +132,7 @@ import {...@@ -116,6 +132,7 @@ import {
116 hasUnseenCompletion,132 hasUnseenCompletion,
117 isTrailingDoubleClick,133 isTrailingDoubleClick,
118 orderItemsByPreferredIds,134 orderItemsByPreferredIds,
135 planPinnedReorder,
119 resolveAdjacentThreadId,136 resolveAdjacentThreadId,
120 resolveSettledTimestamp,137 resolveSettledTimestamp,
121 resolveSidebarV2Status,138 resolveSidebarV2Status,
...@@ -123,6 +140,7 @@ import {...@@ -123,6 +140,7 @@ import {
123 resolveWorkingStartedAt,140 resolveWorkingStartedAt,
124 shouldNavigateAfterProjectRemoval,141 shouldNavigateAfterProjectRemoval,
125 sortLogicalProjectsForSidebar,142 sortLogicalProjectsForSidebar,
143 sortPinnedThreadsForSidebarV2,
126 sortSettledThreadsForSidebarV2,144 sortSettledThreadsForSidebarV2,
127 sortThreadsForSidebarV2,145 sortThreadsForSidebarV2,
128} from "./Sidebar.logic";146} from "./Sidebar.logic";
...@@ -390,6 +408,26 @@ function SnoozePopoverButton(props: {...@@ -390,6 +408,26 @@ function SnoozePopoverButton(props: {
390 );408 );
391}409}
392410
411// Subset of useSortable applied to a pinned card's root <li>. Listeners go
412// on the whole card (no dedicated handle): the pointer sensor's distance
413// constraint keeps plain clicks working, and we skip dnd-kit's aria
414// attributes since there is no keyboard sensor and the card body already
415// carries its own button semantics.
416type SortablePinnedRowBag = Pick<
417 ReturnType<typeof useSortable>,
418 "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
419>;
420
421function SortablePinnedThreadRow(props: {
422 id: string;
423 children: (bag: SortablePinnedRowBag) => ReactNode;
424}) {
425 const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
426 id: props.id,
427 });
428 return props.children({ listeners, setNodeRef, transform, transition, isDragging });
429}
430
393const SidebarV2Row = memo(function SidebarV2Row(props: {431const SidebarV2Row = memo(function SidebarV2Row(props: {
394 thread: SidebarThreadSummary;432 thread: SidebarThreadSummary;
395 variant: "card" | "slim";433 variant: "card" | "slim";
...@@ -410,6 +448,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -410,6 +448,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
410 // the descriptor is not loaded. Pinning itself lives in the context menu.448 // the descriptor is not loaded. Pinning itself lives in the context menu.
411 pinningSupported: boolean;449 pinningSupported: boolean;
412 isPinned: boolean;450 isPinned: boolean;
451 // Present only on pinned cards whose server supports reordering: dnd-kit
452 // sortable bag applied to the card root so the whole card drags (the
453 // pointer sensor's distance constraint keeps plain clicks working).
454 sortable?: SortablePinnedRowBag | undefined;
413 // Compact wake countdown ("2h") for rows in the snoozed shelf.455 // Compact wake countdown ("2h") for rows in the snoozed shelf.
414 snoozeWakeLabelText: string | null;456 snoozeWakeLabelText: string | null;
415 // When a snooze ended (timer or early wake); drives the Woke pill until457 // When a snooze ended (timer or early wake); drives the Woke pill until
...@@ -974,10 +1016,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -974,10 +1016,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
9741016
975 const diff = latestTurnDiff(thread);1017 const diff = latestTurnDiff(thread);
9761018
1019 const sortable = props.sortable;
977 return (1020 return (
978 <li1021 <li
979 data-thread-item1022 data-thread-item
980 className="list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]"1023 ref={sortable?.setNodeRef}
1024 style={
1025 sortable
1026 ? {
1027 transform: CSS.Translate.toString(sortable.transform),
1028 transition: sortable.transition,
1029 }
1030 : undefined
1031 }
1032 {...(sortable?.listeners ?? {})}
1033 className={cn(
1034 "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]",
1035 sortable?.isDragging && "z-20 opacity-80",
1036 )}
981 >1037 >
982 <Tooltip>1038 <Tooltip>
983 <TooltipTrigger1039 <TooltipTrigger
...@@ -1317,6 +1373,7 @@ export default function SidebarV2() {...@@ -1317,6 +1373,7 @@ export default function SidebarV2() {
1317 unsnoozeThread,1373 unsnoozeThread,
1318 pinThread,1374 pinThread,
1319 unpinThread,1375 unpinThread,
1376 reorderPinnedThread,
1320 deleteThread,1377 deleteThread,
1321 } = useThreadActions();1378 } = useThreadActions();
1322 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {1379 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
...@@ -1702,83 +1759,100 @@ export default function SidebarV2() {...@@ -1702,83 +1759,100 @@ export default function SidebarV2() {
1702 // merging, no optimistic holds. Archived threads remain hidden here —1759 // merging, no optimistic holds. Archived threads remain hidden here —
1703 // archive keeps its original "remove from sidebar" meaning.1760 // archive keeps its original "remove from sidebar" meaning.
1704 const serverConfigs = useAtomValue(environmentServerConfigsAtom);1761 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
1705 const { pinnedThreads, activeThreads, snoozedThreads, settledThreads, snoozeNow } =1762 const {
1706 useMemo(() => {1763 pinnedThreads,
1707 const now = `${nowMinute}:00.000Z`;1764 reorderablePinnedKeys,
1708 // Snooze classification uses a REAL clock, not the quantized minute:1765 activeThreads,
1709 // wake times are second-precise and a woken thread must not linger on1766 snoozedThreads,
1710 // the shelf for the rest of the minute. snoozeWakeTick re-runs this1767 settledThreads,
1711 // memo exactly at the next wake boundary.1768 snoozeNow,
1712 void snoozeWakeTick;1769 } = useMemo(() => {
1713 const preciseNow = new Date().toISOString();1770 const now = `${nowMinute}:00.000Z`;
1714 const visible = threads.filter(1771 // Snooze classification uses a REAL clock, not the quantized minute:
1715 (thread) =>1772 // wake times are second-precise and a woken thread must not linger on
1716 thread.archivedAt === null &&1773 // the shelf for the rest of the minute. snoozeWakeTick re-runs this
1717 (scopedProjectKeys === null ||1774 // memo exactly at the next wake boundary.
1718 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),1775 void snoozeWakeTick;
1719 );1776 const preciseNow = new Date().toISOString();
1720 const pinned: EnvironmentThreadShell[] = [];1777 const visible = threads.filter(
1721 const active: EnvironmentThreadShell[] = [];1778 (thread) =>
1722 const snoozed: EnvironmentThreadShell[] = [];1779 thread.archivedAt === null &&
1723 const settled: EnvironmentThreadShell[] = [];1780 (scopedProjectKeys === null ||
1724 for (const thread of visible) {1781 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
1725 // Threads on servers without the settlement capability (old server,1782 );
1726 // or descriptor not loaded yet) never classify as settled: the user1783 const pinned: EnvironmentThreadShell[] = [];
1727 // could neither un-settle nor pin them, so auto-settling them would1784 const active: EnvironmentThreadShell[] = [];
1728 // strand rows in a tail with no working affordances.1785 const snoozed: EnvironmentThreadShell[] = [];
1729 const supportsSettlement =1786 const settled: EnvironmentThreadShell[] = [];
1730 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement ===1787 for (const thread of visible) {
1731 true;1788 // Threads on servers without the settlement capability (old server,
1732 const supportsSnooze =1789 // or descriptor not loaded yet) never classify as settled: the user
1733 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;1790 // could neither un-settle nor pin them, so auto-settling them would
1734 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));1791 // strand rows in a tail with no working affordances.
1735 const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;1792 const supportsSettlement =
1736 // Snooze outranks everything, including a pin: "hide until Tuesday"1793 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;
1737 // temporarily suspends "keep on top". The pin survives underneath —1794 const supportsSnooze =
1738 // pinned cards are creation-ordered, so on wake the thread reappears1795 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
1739 // at its original spot in the pinned block. (For unpinned threads1796 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1740 // this is also the snooze-beats-auto-settle rule: the wake time is a1797 const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;
1741 // stronger statement about when the thread matters again.)1798 // Snooze outranks everything, including a pin: "hide until Tuesday"
1742 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {1799 // temporarily suspends "keep on top". The pin survives underneath —
1743 snoozed.push(thread);1800 // and so does its pinOrderKey, so on wake the thread reappears at
1744 // A pin otherwise overrides the lifecycle: pinned threads never1801 // its exact slot in the pinned block. (For unpinned threads
1745 // auto-settle out of sight. (The decider clears settled state on1802 // this is also the snooze-beats-auto-settle rule: the wake time is a
1746 // pin and the pin on settle, so pin-vs-settled conflicts only1803 // stronger statement about when the thread matters again.)
1747 // arise from stale or raced writes.)1804 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
1748 } else if (thread.pinnedAt != null) {1805 snoozed.push(thread);
1749 pinned.push(thread);1806 // A pin otherwise overrides the lifecycle: pinned threads never
1750 } else if (1807 // auto-settle out of sight. (The decider clears settled state on
1751 supportsSettlement &&1808 // pin and the pin on settle, so pin-vs-settled conflicts only
1752 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })1809 // arise from stale or raced writes.)
1753 ) {1810 } else if (thread.pinnedAt != null) {
1754 settled.push(thread);1811 pinned.push(thread);
1755 } else {1812 } else if (
1756 active.push(thread);1813 supportsSettlement &&
1757 }1814 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
1815 ) {
1816 settled.push(thread);
1817 } else {
1818 active.push(thread);
1758 }1819 }
1759 return {1820 }
1760 // Same static creation order as the inbox: a pin freezes prominence,1821 // One shared rule on every platform (see sortPinnedThreadsByOrderKey):
1761 // it does not introduce a new ordering scheme.1822 // user-arranged keys first, keyless threads in creation order below.
1762 pinnedThreads: sortThreadsForSidebarV2(pinned),1823 // Server capability only gates DRAGGING — it must not influence the
1763 activeThreads: sortThreadsForSidebarV2(active),1824 // sort, or mixed-version fleets would render different pinned orders on
1764 // Soonest wake first: "what comes back next" is the shelf's question.1825 // web and mobile from the same data.
1765 snoozedThreads: snoozed.toSorted(1826 return {
1766 (left, right) =>1827 pinnedThreads: sortPinnedThreadsForSidebarV2(pinned),
1767 firstValidTimestampMs(left.snoozedUntil ?? null) -1828 reorderablePinnedKeys: new Set(
1768 firstValidTimestampMs(right.snoozedUntil ?? null),1829 pinned
1769 ),1830 .filter(
1770 settledThreads: sortSettledThreadsForSidebarV2(settled),1831 (thread) =>
1771 snoozeNow: preciseNow,1832 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder ===
1772 };1833 true,
1773 }, [1834 )
1774 autoSettleAfterDays,1835 .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1775 changeRequestStateByKey,1836 ),
1776 nowMinute,1837 activeThreads: sortThreadsForSidebarV2(active),
1777 scopedProjectKeys,1838 // Soonest wake first: "what comes back next" is the shelf's question.
1778 serverConfigs,1839 snoozedThreads: snoozed.toSorted(
1779 snoozeWakeTick,1840 (left, right) =>
1780 threads,1841 firstValidTimestampMs(left.snoozedUntil ?? null) -
1781 ]);1842 firstValidTimestampMs(right.snoozedUntil ?? null),
1843 ),
1844 settledThreads: sortSettledThreadsForSidebarV2(settled),
1845 snoozeNow: preciseNow,
1846 };
1847 }, [
1848 autoSettleAfterDays,
1849 changeRequestStateByKey,
1850 nowMinute,
1851 scopedProjectKeys,
1852 serverConfigs,
1853 snoozeWakeTick,
1854 threads,
1855 ]);
17821856
1783 const threadSearchInputRef = useRef<HTMLInputElement>(null);1857 const threadSearchInputRef = useRef<HTMLInputElement>(null);
1784 const [threadSearchQuery, setThreadSearchQuery] = useState("");1858 const [threadSearchQuery, setThreadSearchQuery] = useState("");
...@@ -2193,9 +2267,70 @@ export default function SidebarV2() {...@@ -2193,9 +2267,70 @@ export default function SidebarV2() {
2193 },2267 },
2194 [unsnoozeThread],2268 [unsnoozeThread],
2195 );2269 );
2270 // Drag-to-reorder for the pinned block. A drop computes ONE fractional key
2271 // for the moved thread and sends it to that thread's own server (see
2272 // planPinnedReorder for the keyless-neighbor materialization case). The
2273 // optimistic order keeps the card where it was dropped until the
2274 // confirming event round-trips; canonical order matching it releases the
2275 // override, and a failed write clears it (the card snaps back) with a toast.
2276 // ANY membership change (new pin, unpin, snooze/wake) also releases it:
2277 // the override can't say where members it never saw belong, and holding it
2278 // would misplace them and launder the stale order into later drags.
2279 const pinnedDndSensors = useSensors(
2280 useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
2281 );
2282 const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{
2283 readonly order: readonly string[];
2284 /** pinOrderKey per thread as of the drop, so ANY landed write (ours
2285 confirming, or a concurrent one from another client) releases the
2286 override rather than fighting canonical state. */
2287 readonly keysAtDrop: ReadonlyMap<string, string | null>;
2288 } | null>(null);
2289 const orderedPinnedThreads = useMemo(() => {
2290 if (optimisticPinnedOrder === null) return pinnedThreads;
2291 return orderItemsByPreferredIds({
2292 items: pinnedThreads,
2293 preferredIds: optimisticPinnedOrder.order,
2294 getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2295 });
2296 }, [optimisticPinnedOrder, pinnedThreads]);
2297 useEffect(() => {
2298 if (optimisticPinnedOrder === null) return;
2299 const canonical = pinnedThreads.filter((thread) =>
2300 reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
2301 );
2302 const canonicalKeys = canonical.map((thread) =>
2303 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2304 );
2305 // The override represents one drop against one snapshot of the world.
2306 // Release it as soon as the world moves on in any way: membership
2307 // changed (pin/unpin/snooze/wake — the override can't say where members
2308 // it never saw belong), a key changed (our write confirming, or a
2309 // concurrent client's reorder that must win), or canonical already
2310 // matches. Holding it longer would misplace newcomers and launder the
2311 // stale order into later drags.
2312 const membershipChanged =
2313 canonicalKeys.length !== optimisticPinnedOrder.order.length ||
2314 canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key));
2315 const anyKeyLanded = canonical.some(
2316 (thread, index) =>
2317 optimisticPinnedOrder.keysAtDrop.get(canonicalKeys[index]!) !==
2318 (thread.pinOrderKey ?? null),
2319 );
2320 const orderConfirmed =
2321 !membershipChanged &&
2322 canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]);
2323 if (membershipChanged || anyKeyLanded || orderConfirmed) {
2324 setOptimisticPinnedOrder(null);
2325 }
2326 }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]);
2196 const attemptPin = useCallback(2327 const attemptPin = useCallback(
2197 (threadRef: ScopedThreadRef) => {2328 (threadRef: ScopedThreadRef) => {
2198 void (async () => {2329 void (async () => {
2330 // Fresh pins take the top of the arranged run: pinThread computes a
2331 // key before the smallest key across ALL pinned shells — including
2332 // snoozed pins hidden from this list, whose keys are still part of
2333 // the run — so the new pin can't land beneath a hidden head.
2199 const result = await pinThread(threadRef);2334 const result = await pinThread(threadRef);
2200 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {2335 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2201 const error = squashAtomCommandFailure(result);2336 const error = squashAtomCommandFailure(result);
...@@ -2229,6 +2364,63 @@ export default function SidebarV2() {...@@ -2229,6 +2364,63 @@ export default function SidebarV2() {
2229 },2364 },
2230 [unpinThread],2365 [unpinThread],
2231 );2366 );
2367
2368 const handlePinnedDragEnd = useCallback(
2369 (event: DragEndEvent) => {
2370 const activeKey = String(event.active.id);
2371 const overKey = event.over === null ? null : String(event.over.id);
2372 if (overKey === null || activeKey === overKey) return;
2373 const reorderable = orderedPinnedThreads.filter((thread) =>
2374 reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
2375 );
2376 const keys = reorderable.map((thread) =>
2377 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2378 );
2379 const fromIndex = keys.indexOf(activeKey);
2380 const toIndex = keys.indexOf(overKey);
2381 if (fromIndex === -1 || toIndex === -1) return;
2382 const newOrder = arrayMove([...keys], fromIndex, toIndex);
2383 const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread]));
2384 const keysAtDrop = new Map(
2385 reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]),
2386 );
2387 const assignments = planPinnedReorder({
2388 orderedIds: newOrder,
2389 keysById: keysAtDrop,
2390 movedId: activeKey,
2391 });
2392 if (assignments.length === 0) return;
2393 setOptimisticPinnedOrder({ order: newOrder, keysAtDrop });
2394 void (async () => {
2395 // Sequential, stop on first failure. There is deliberately no
2396 // rollback: every key write is a complete, valid placement on its
2397 // own, so a partial materialization leaves a sensible order (and
2398 // the next drag repairs the rest) — unwinding writes across
2399 // servers would trade that for real inconsistency windows.
2400 for (const assignment of assignments) {
2401 const thread = threadByKey.get(assignment.id);
2402 if (thread === undefined) continue;
2403 const result = await reorderPinnedThread(
2404 scopeThreadRef(thread.environmentId, thread.id),
2405 assignment.orderKey,
2406 );
2407 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2408 setOptimisticPinnedOrder(null);
2409 const error = squashAtomCommandFailure(result);
2410 toastManager.add(
2411 stackedThreadToast({
2412 type: "error",
2413 title: "Failed to reorder pinned threads",
2414 description: error instanceof Error ? error.message : "An error occurred.",
2415 }),
2416 );
2417 return;
2418 }
2419 }
2420 })();
2421 },
2422 [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys],
2423 );
2232 // One snooze per thread at a time — same double-dispatch guard as settle.2424 // One snooze per thread at a time — same double-dispatch guard as settle.
2233 const snoozingThreadKeysRef = useRef(new Set<string>());2425 const snoozingThreadKeysRef = useRef(new Set<string>());
2234 const performSnooze = useCallback(2426 const performSnooze = useCallback(
...@@ -3069,6 +3261,7 @@ export default function SidebarV2() {...@@ -3069,6 +3261,7 @@ export default function SidebarV2() {
3069 const renderThreadRow = (3261 const renderThreadRow = (
3070 thread: EnvironmentThreadShell,3262 thread: EnvironmentThreadShell,
3071 section: "pinned" | "active" | "snoozed" | "settled",3263 section: "pinned" | "active" | "snoozed" | "settled",
3264 sortable?: SortablePinnedRowBag,
3072 ) => {3265 ) => {
3073 const threadKey = scopedThreadKey(3266 const threadKey = scopedThreadKey(
3074 scopeThreadRef(thread.environmentId, thread.id),3267 scopeThreadRef(thread.environmentId, thread.id),
...@@ -3113,6 +3306,7 @@ export default function SidebarV2() {...@@ -3113,6 +3306,7 @@ export default function SidebarV2() {
3113 .threadPinning === true3306 .threadPinning === true
3114 }3307 }
3115 isPinned={section === "pinned"}3308 isPinned={section === "pinned"}
3309 sortable={sortable}
3116 snoozeWakeLabelText={3310 snoozeWakeLabelText={
3117 section === "snoozed" && thread.snoozedUntil != null3311 section === "snoozed" && thread.snoozedUntil != null
3118 ? snoozeWakeLabel(thread.snoozedUntil, {3312 ? snoozeWakeLabel(thread.snoozedUntil, {
...@@ -3161,9 +3355,41 @@ export default function SidebarV2() {...@@ -3161,9 +3355,41 @@ export default function SidebarV2() {
3161 // Pinned block: full cards above the inbox, closed by a3355 // Pinned block: full cards above the inbox, closed by a
3162 // thin divider (the pin glyphs carry the meaning, so no3356 // thin divider (the pin glyphs carry the meaning, so no
3163 // header text). Vanishes entirely at count 0.3357 // header text). Vanishes entirely at count 0.
3164 const items: ReactNode[] = pinnedThreads.map((thread) =>3358 // Rows render in the one shared pinned order; only
3165 renderThreadRow(thread, "pinned"),3359 // reorder-capable rows register as sortable (legacy-server
3166 );3360 // pins render in place as plain rows).
3361 const items: ReactNode[] = [
3362 <DndContext
3363 key="pinned-dnd"
3364 sensors={pinnedDndSensors}
3365 collisionDetection={closestCenter}
3366 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
3367 onDragEnd={handlePinnedDragEnd}
3368 >
3369 <SortableContext
3370 items={orderedPinnedThreads
3371 .map((thread) =>
3372 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3373 )
3374 .filter((threadKey) => reorderablePinnedKeys.has(threadKey))}
3375 strategy={verticalListSortingStrategy}
3376 >
3377 {orderedPinnedThreads.map((thread) => {
3378 const threadKey = scopedThreadKey(
3379 scopeThreadRef(thread.environmentId, thread.id),
3380 );
3381 if (!reorderablePinnedKeys.has(threadKey)) {
3382 return renderThreadRow(thread, "pinned");
3383 }
3384 return (
3385 <SortablePinnedThreadRow key={threadKey} id={threadKey}>
3386 {(bag) => renderThreadRow(thread, "pinned", bag)}
3387 </SortablePinnedThreadRow>
3388 );
3389 })}
3390 </SortableContext>
3391 </DndContext>,
3392 ];
3167 if (pinnedThreads.length > 0) {3393 if (pinnedThreads.length > 0) {
3168 items.push(3394 items.push(
3169 <li3395 <li
apps/web/src/hooks/useThreadActions.ts+69-3
...@@ -13,7 +13,7 @@ import { AsyncResult } from "effect/unstable/reactivity";...@@ -13,7 +13,7 @@ import { AsyncResult } from "effect/unstable/reactivity";
13import { useRouter } from "@tanstack/react-router";13import { useRouter } from "@tanstack/react-router";
14import { useCallback, useMemo, useRef } from "react";14import { useCallback, useMemo, useRef } from "react";
1515
16import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";16import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";
17import { useComposerDraftStore } from "../composerDraftStore";17import { useComposerDraftStore } from "../composerDraftStore";
18import { terminalEnvironment } from "../state/terminal";18import { terminalEnvironment } from "../state/terminal";
19import { threadEnvironment } from "../state/threads";19import { threadEnvironment } from "../state/threads";
...@@ -23,11 +23,13 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat...@@ -23,11 +23,13 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat
23import { readLocalApi } from "../localApi";23import { readLocalApi } from "../localApi";
24import {24import {
25 readEnvironmentSupportsPinning,25 readEnvironmentSupportsPinning,
26 readEnvironmentSupportsPinReorder,
26 readEnvironmentSupportsSettlement,27 readEnvironmentSupportsSettlement,
27 readEnvironmentSupportsSnooze,28 readEnvironmentSupportsSnooze,
28 readEnvironmentThreadRefs,29 readEnvironmentThreadRefs,
29 readProject,30 readProject,
30 readThreadShell,31 readThreadShell,
32 readThreadShells,
31} from "../state/entities";33} from "../state/entities";
32import { useTerminalUiStateStore } from "../terminalUiStateStore";34import { useTerminalUiStateStore } from "../terminalUiStateStore";
33import { useUiStateStore } from "../uiStateStore";35import { useUiStateStore } from "../uiStateStore";
...@@ -97,6 +99,18 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass<ThreadSnoo...@@ -97,6 +99,18 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass<ThreadSnoo
97 }99 }
98}100}
99101
102/** Key that sorts before every arranged pinned thread, so a fresh pin lands
103 at the top of the run. Undefined (keyless, sorts with the legacy block)
104 when key math can't produce one — pinning must never fail on placement. */
105function topOfPinnedRunOrderKey(): string | undefined {
106 let firstKey: string | null = null;
107 for (const shell of readThreadShells()) {
108 if (shell.pinnedAt == null || shell.pinOrderKey == null) continue;
109 if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey;
110 }
111 return pinOrderKeyBetween(null, firstKey) ?? undefined;
112}
113
100export class ThreadPinningUnsupportedError extends Schema.TaggedErrorClass<ThreadPinningUnsupportedError>()(114export class ThreadPinningUnsupportedError extends Schema.TaggedErrorClass<ThreadPinningUnsupportedError>()(
101 "ThreadPinningUnsupportedError",115 "ThreadPinningUnsupportedError",
102 {116 {
...@@ -109,6 +123,18 @@ export class ThreadPinningUnsupportedError extends Schema.TaggedErrorClass<Threa...@@ -109,6 +123,18 @@ export class ThreadPinningUnsupportedError extends Schema.TaggedErrorClass<Threa
109 }123 }
110}124}
111125
126export class ThreadPinReorderUnsupportedError extends Schema.TaggedErrorClass<ThreadPinReorderUnsupportedError>()(
127 "ThreadPinReorderUnsupportedError",
128 {
129 environmentId: EnvironmentId,
130 threadId: ThreadId,
131 },
132) {
133 override get message(): string {
134 return "This environment's server does not support reordering pinned threads yet. Update the server to reorder pins.";
135 }
136}
137
112export function useThreadActions() {138export function useThreadActions() {
113 const closeTerminal = useAtomCommand(terminalEnvironment.close);139 const closeTerminal = useAtomCommand(terminalEnvironment.close);
114 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {140 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
...@@ -132,6 +158,9 @@ export function useThreadActions() {...@@ -132,6 +158,9 @@ export function useThreadActions() {
132 const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, {158 const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, {
133 reportFailure: false,159 reportFailure: false,
134 });160 });
161 const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, {
162 reportFailure: false,
163 });
135 const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, {164 const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, {
136 reportFailure: false,165 reportFailure: false,
137 });166 });
...@@ -506,7 +535,7 @@ export function useThreadActions() {...@@ -506,7 +535,7 @@ export function useThreadActions() {
506 );535 );
507536
508 const pinThread = useCallback(537 const pinThread = useCallback(
509 async (target: ScopedThreadRef) => {538 async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => {
510 // Version skew: never send the command to a server that predates it.539 // Version skew: never send the command to a server that predates it.
511 if (!readEnvironmentSupportsPinning(target.environmentId)) {540 if (!readEnvironmentSupportsPinning(target.environmentId)) {
512 return AsyncResult.failure(541 return AsyncResult.failure(
...@@ -518,9 +547,21 @@ export function useThreadActions() {...@@ -518,9 +547,21 @@ export function useThreadActions() {
518 ),547 ),
519 );548 );
520 }549 }
550 // Every pin path places the thread at the top of the arranged run:
551 // callers with a better anchor (the sidebar, which knows the displayed
552 // order) pass their own key; everyone else (chat header, context menus)
553 // gets the default so the same action never places differently.
554 // orderKey rides only to servers that decode it; pre-reorder servers
555 // get the bare pin they understand and the thread stays keyless.
556 const orderKey = readEnvironmentSupportsPinReorder(target.environmentId)
557 ? (opts.orderKey ?? topOfPinnedRunOrderKey())
558 : undefined;
521 return pinThreadMutation({559 return pinThreadMutation({
522 environmentId: target.environmentId,560 environmentId: target.environmentId,
523 input: { threadId: target.threadId },561 input: {
562 threadId: target.threadId,
563 ...(orderKey !== undefined ? { orderKey } : {}),
564 },
524 });565 });
525 },566 },
526 [pinThreadMutation],567 [pinThreadMutation],
...@@ -546,6 +587,29 @@ export function useThreadActions() {...@@ -546,6 +587,29 @@ export function useThreadActions() {
546 [unpinThreadMutation],587 [unpinThreadMutation],
547 );588 );
548589
590 const reorderPinnedThread = useCallback(
591 async (target: ScopedThreadRef, orderKey: string) => {
592 // Callers (the sidebar drag handler) only enable dragging on
593 // reorder-capable environments; this guard covers races around
594 // capability changes mid-drag.
595 if (!readEnvironmentSupportsPinReorder(target.environmentId)) {
596 return AsyncResult.failure(
597 Cause.fail(
598 new ThreadPinReorderUnsupportedError({
599 environmentId: target.environmentId,
600 threadId: target.threadId,
601 }),
602 ),
603 );
604 }
605 return reorderPinnedThreadMutation({
606 environmentId: target.environmentId,
607 input: { threadId: target.threadId, orderKey },
608 });
609 },
610 [reorderPinnedThreadMutation],
611 );
612
549 const snoozeThread = useCallback(613 const snoozeThread = useCallback(
550 async (target: ScopedThreadRef, snoozedUntil: string) => {614 async (target: ScopedThreadRef, snoozedUntil: string) => {
551 // Version skew: never send the command to a server that predates it.615 // Version skew: never send the command to a server that predates it.
...@@ -641,12 +705,14 @@ export function useThreadActions() {...@@ -641,12 +705,14 @@ export function useThreadActions() {
641 unsnoozeThread,705 unsnoozeThread,
642 pinThread,706 pinThread,
643 unpinThread,707 unpinThread,
708 reorderPinnedThread,
644 }),709 }),
645 [710 [
646 archiveThread,711 archiveThread,
647 confirmAndDeleteThread,712 confirmAndDeleteThread,
648 deleteThread,713 deleteThread,
649 pinThread,714 pinThread,
715 reorderPinnedThread,
650 settleThread,716 settleThread,
651 snoozeThread,717 snoozeThread,
652 unarchiveThread,718 unarchiveThread,
apps/web/src/state/entities.ts+13
...@@ -259,6 +259,15 @@ export function readEnvironmentSupportsTitleRegeneration(environmentId: Environm...@@ -259,6 +259,15 @@ export function readEnvironmentSupportsTitleRegeneration(environmentId: Environm
259 );259 );
260}260}
261261
262/** Whether the environment's server understands thread.pin.reorder (and
263 orderKey on thread.pin). Same version-skew contract as settlement. */
264export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): boolean {
265 return (
266 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
267 .threadPinReorder === true
268 );
269}
270
262export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null {271export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null {
263 return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref));272 return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref));
264}273}
...@@ -273,6 +282,10 @@ export function readThreadRefs(): ReadonlyArray<ScopedThreadRef> {...@@ -273,6 +282,10 @@ export function readThreadRefs(): ReadonlyArray<ScopedThreadRef> {
273 return appAtomRegistry.get(environmentThreadShells.threadRefsAtom);282 return appAtomRegistry.get(environmentThreadShells.threadRefsAtom);
274}283}
275284
285export function readThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
286 return appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
287}
288
276export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null {289export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null {
277 return (290 return (
278 appAtomRegistry291 appAtomRegistry
docs/README.md+1
...@@ -5,6 +5,7 @@...@@ -5,6 +5,7 @@
5- [Install and first run](./user/install.md)5- [Install and first run](./user/install.md)
6- [Permission modes](./user/permission-modes.md)6- [Permission modes](./user/permission-modes.md)
7- [Keyboard shortcuts](./user/keybindings.md)7- [Keyboard shortcuts](./user/keybindings.md)
8- [Organizing threads](./user/thread-sidebar.md)
8- [Remote access](./user/remote-access.md)9- [Remote access](./user/remote-access.md)
9- [Keeping app and server in sync](./user/updating.md)10- [Keeping app and server in sync](./user/updating.md)
10- [Source control integrations](./user/source-control.md)11- [Source control integrations](./user/source-control.md)
docs/user/thread-sidebar.md created+13
...@@ -0,0 +1,13 @@
1# Organizing threads
2
3Pin a thread from its context menu to keep it in the pinned section above your active work.
4Pinned threads are shown independently of their project, including when you connect to more than
5one environment.
6
7On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu
8and choose **Move up** or **Move down**. The order is stored by the server and appears on your
9other connected devices.
10
11If reordering is unavailable for one environment, update the T3 Code server running in that
12environment. Older servers can still pin and unpin threads, but do not understand synced ordering;
13their pinned threads keep the default newest-first order below the ones you have arranged.
packages/client-runtime/src/operations/commands.ts+11
...@@ -41,6 +41,7 @@ export type SnoozeThreadInput = CommandInput<"thread.snooze">;...@@ -41,6 +41,7 @@ export type SnoozeThreadInput = CommandInput<"thread.snooze">;
41export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;41export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
42export type PinThreadInput = CommandInput<"thread.pin">;42export type PinThreadInput = CommandInput<"thread.pin">;
43export type UnpinThreadInput = CommandInput<"thread.unpin">;43export type UnpinThreadInput = CommandInput<"thread.unpin">;
44export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;
44export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;45export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
45export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;46export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
46export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;47export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
...@@ -219,6 +220,16 @@ export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn...@@ -219,6 +220,16 @@ export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn
219 });220 });
220});221});
221222
223export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEffect = Effect.fn(
224 "EnvironmentCommands.reorderPinnedThread",
225)(function* (input) {
226 return yield* dispatch({
227 ...input,
228 type: "thread.pin.reorder",
229 commandId: yield* commandId(input),
230 });
231});
232
222export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(233export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(
223 "EnvironmentCommands.updateThreadMetadata",234 "EnvironmentCommands.updateThreadMetadata",
224)(function* (input) {235)(function* (input) {
packages/client-runtime/src/state/threadCommands.ts+9
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 type SetThreadInteractionModeInput,13 type SetThreadInteractionModeInput,
14 type SetThreadRuntimeModeInput,14 type SetThreadRuntimeModeInput,
15 type PinThreadInput,15 type PinThreadInput,
16 type ReorderPinnedThreadInput,
16 type SettleThreadInput,17 type SettleThreadInput,
17 type SnoozeThreadInput,18 type SnoozeThreadInput,
18 type StartThreadTurnInput,19 type StartThreadTurnInput,
...@@ -32,6 +33,7 @@ import {...@@ -32,6 +33,7 @@ import {
32 setThreadInteractionMode,33 setThreadInteractionMode,
33 setThreadRuntimeMode,34 setThreadRuntimeMode,
34 pinThread,35 pinThread,
36 reorderPinnedThread,
35 settleThread,37 settleThread,
36 snoozeThread,38 snoozeThread,
37 startThreadTurn,39 startThreadTurn,
...@@ -55,6 +57,7 @@ export type {...@@ -55,6 +57,7 @@ export type {
55 SetThreadInteractionModeInput,57 SetThreadInteractionModeInput,
56 SetThreadRuntimeModeInput,58 SetThreadRuntimeModeInput,
57 PinThreadInput,59 PinThreadInput,
60 ReorderPinnedThreadInput,
58 SettleThreadInput,61 SettleThreadInput,
59 SnoozeThreadInput,62 SnoozeThreadInput,
60 StartThreadTurnInput,63 StartThreadTurnInput,
...@@ -136,6 +139,12 @@ export function createThreadEnvironmentAtoms<R, E>(...@@ -136,6 +139,12 @@ export function createThreadEnvironmentAtoms<R, E>(
136 scheduler,139 scheduler,
137 concurrency,140 concurrency,
138 }),141 }),
142 reorderPin: createEnvironmentCommand(runtime, {
143 label: "environment-data:commands:thread:reorder-pin",
144 execute: (input: ReorderPinnedThreadInput) => reorderPinnedThread(input),
145 scheduler,
146 concurrency,
147 }),
139 updateMetadata: createEnvironmentCommand(runtime, {148 updateMetadata: createEnvironmentCommand(runtime, {
140 label: "environment-data:commands:thread:update-metadata",149 label: "environment-data:commands:thread:update-metadata",
141 execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input),150 execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input),
packages/client-runtime/src/state/threadDetail.ts+1
...@@ -62,6 +62,7 @@ export function mergeEnvironmentThread(...@@ -62,6 +62,7 @@ export function mergeEnvironmentThread(
62 snoozedUntil: shell.snoozedUntil,62 snoozedUntil: shell.snoozedUntil,
63 snoozedAt: shell.snoozedAt,63 snoozedAt: shell.snoozedAt,
64 pinnedAt: shell.pinnedAt,64 pinnedAt: shell.pinnedAt,
65 pinOrderKey: shell.pinOrderKey,
65 session: shell.session,66 session: shell.session,
66 };67 };
67}68}
packages/client-runtime/src/state/threadReducer.ts+14
...@@ -173,6 +173,9 @@ export function applyThreadDetailEvent(...@@ -173,6 +173,9 @@ export function applyThreadDetailEvent(
173 thread: {173 thread: {
174 ...thread,174 ...thread,
175 pinnedAt: event.payload.pinnedAt,175 pinnedAt: event.payload.pinnedAt,
176 ...(event.payload.pinOrderKey !== undefined
177 ? { pinOrderKey: event.payload.pinOrderKey }
178 : {}),
176 updatedAt: event.payload.updatedAt,179 updatedAt: event.payload.updatedAt,
177 },180 },
178 };181 };
...@@ -183,6 +186,17 @@ export function applyThreadDetailEvent(...@@ -183,6 +186,17 @@ export function applyThreadDetailEvent(
183 thread: {186 thread: {
184 ...thread,187 ...thread,
185 pinnedAt: null,188 pinnedAt: null,
189 pinOrderKey: null,
190 updatedAt: event.payload.updatedAt,
191 },
192 };
193
194 case "thread.pin-reordered":
195 return {
196 kind: "updated",
197 thread: {
198 ...thread,
199 pinOrderKey: event.payload.orderKey,
186 updatedAt: event.payload.updatedAt,200 updatedAt: event.payload.updatedAt,
187 },201 },
188 };202 };
packages/client-runtime/src/state/threadSort.test.ts+72-1
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { sortThreads, type ThreadSortInput } from "./threadSort.ts";3import {
4 planPinnedMove,
5 sortPinnedThreadsByOrderKey,
6 sortThreads,
7 type ThreadSortInput,
8} from "./threadSort.ts";
49
5type TestThread = { readonly id: string } & ThreadSortInput;10type TestThread = { readonly id: string } & ThreadSortInput;
611
...@@ -69,3 +74,69 @@ describe("sortThreads", () => {...@@ -69,3 +74,69 @@ describe("sortThreads", () => {
69 expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]);74 expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]);
70 });75 });
71});76});
77
78describe("planPinnedMove", () => {
79 it("moves a thread up with a single key write", () => {
80 const assignments = planPinnedMove({
81 orderedIds: ["a", "b", "c"],
82 keysById: new Map([
83 ["a", "f"],
84 ["b", "m"],
85 ["c", "t"],
86 ]),
87 movedId: "c",
88 direction: "up",
89 });
90 expect(assignments).toHaveLength(1);
91 expect(assignments![0]!.id).toBe("c");
92 expect(assignments![0]!.orderKey > "f" && assignments![0]!.orderKey < "m").toBe(true);
93 });
94
95 it("returns null when the move falls off the end of the list", () => {
96 const input = {
97 orderedIds: ["a", "b"],
98 keysById: new Map([
99 ["a", "f"],
100 ["b", "m"],
101 ]),
102 };
103 expect(planPinnedMove({ ...input, movedId: "a", direction: "up" })).toBeNull();
104 expect(planPinnedMove({ ...input, movedId: "b", direction: "down" })).toBeNull();
105 });
106
107 it("materializes keys for the whole section when a neighbor is keyless", () => {
108 const assignments = planPinnedMove({
109 orderedIds: ["a", "b", "c"],
110 keysById: new Map([
111 ["a", null],
112 ["b", "m"],
113 ["c", null],
114 ]),
115 movedId: "b",
116 direction: "up",
117 });
118 expect(assignments).not.toBeNull();
119 const keys = assignments!.map((entry) => entry.orderKey);
120 expect([...keys].sort()).toEqual(keys);
121 });
122});
123
124describe("sortPinnedThreadsByOrderKey", () => {
125 it("breaks equal keys by id THEN environment so merged lists are stable everywhere", () => {
126 const sorted = sortPinnedThreadsByOrderKey([
127 {
128 id: "thread-1",
129 createdAt: "2026-03-09T10:00:00.000Z",
130 pinOrderKey: "m",
131 environmentId: "env-b",
132 },
133 {
134 id: "thread-1",
135 createdAt: "2026-03-09T11:00:00.000Z",
136 pinOrderKey: "m",
137 environmentId: "env-a",
138 },
139 ]);
140 expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]);
141 });
142});
packages/client-runtime/src/state/threadSort.ts+176
...@@ -102,3 +102,179 @@ export function getLatestThreadForProject<...@@ -102,3 +102,179 @@ export function getLatestThreadForProject<
102 )[0] ?? null102 )[0] ?? null
103 );103 );
104}104}
105
106// ── Pinned reorder: fractional index keys ──────────────────────────────
107// Pinned threads carry an optional pinOrderKey (a base-26 string). The
108// pinned block sorts keyed threads by plain string comparison, so a drag
109// (web) or Move up/down (mobile) writes ONE key to ONE thread on that
110// thread's own server — neighbors, possibly living on other servers, are
111// never touched, and every client connected to the same servers converges
112// on the same order.
113const PIN_ORDER_DIGITS = "abcdefghijklmnopqrstuvwxyz";
114
115function isValidPinOrderKey(key: string): boolean {
116 if (key.length === 0) return false;
117 for (const char of key) {
118 if (!PIN_ORDER_DIGITS.includes(char)) return false;
119 }
120 // A trailing minimum digit would leave no room to sort a key immediately
121 // before this one; generators never produce it, so treat it as corrupt.
122 return key.at(-1) !== PIN_ORDER_DIGITS[0];
123}
124
125/** Midpoint of two digit strings interpreted as fractions in (0, 1).
126 "" stands for the open bound on either side. Requires a < b. */
127function pinOrderMidpoint(a: string, b: string): string {
128 if (b !== "" && a >= b) throw new Error("pinOrderMidpoint: bounds out of order");
129 if (b !== "") {
130 // Recurse past the longest common prefix ("a" pads the shorter side).
131 let n = 0;
132 while ((a.charAt(n) || PIN_ORDER_DIGITS[0]) === b.charAt(n)) n += 1;
133 if (n > 0) return b.slice(0, n) + pinOrderMidpoint(a.slice(n), b.slice(n));
134 }
135 const digitA = a === "" ? 0 : PIN_ORDER_DIGITS.indexOf(a.charAt(0));
136 const digitB = b === "" ? PIN_ORDER_DIGITS.length : PIN_ORDER_DIGITS.indexOf(b.charAt(0));
137 if (digitB - digitA > 1) {
138 return PIN_ORDER_DIGITS.charAt(Math.round((digitA + digitB) / 2));
139 }
140 // Consecutive leading digits: either b has spare digits to shorten into,
141 // or we extend a (never producing a trailing minimum digit — the base
142 // case midpoint("", "") is the middle of the alphabet).
143 if (b.length > 1) return b.charAt(0);
144 return PIN_ORDER_DIGITS.charAt(digitA) + pinOrderMidpoint(a.slice(1), "");
145}
146
147/** Key that sorts strictly between two neighbors; null bounds mean "top of
148 the pinned block" / "bottom of the keyed run". Returns null instead of
149 throwing when existing keys are corrupt or out of order — callers fall
150 back to rewriting the section. */
151export function pinOrderKeyBetween(before: string | null, after: string | null): string | null {
152 const a = before ?? "";
153 const b = after ?? "";
154 if (a !== "" && !isValidPinOrderKey(a)) return null;
155 if (b !== "" && !isValidPinOrderKey(b)) return null;
156 if (b !== "" && a >= b) return null;
157 return pinOrderMidpoint(a, b);
158}
159
160/** Evenly spaced keys for rewriting a whole pinned section (used when a
161 drop lands next to keyless threads, so single-key insertion has nothing
162 to anchor on). Two base-26 digits give 675 slots — far beyond any real
163 pinned section — with monotonicity enforced as a belt-and-braces. */
164export function generateSpreadPinOrderKeys(count: number): string[] {
165 const space = PIN_ORDER_DIGITS.length * PIN_ORDER_DIGITS.length;
166 const step = space / (count + 1);
167 const keys: string[] = [];
168 let previous = 0;
169 for (let i = 0; i < count; i += 1) {
170 let value = Math.max(Math.round(step * (i + 1)), previous + 1);
171 // Skip values whose low digit is the minimum (a trailing "a" key).
172 if (value % PIN_ORDER_DIGITS.length === 0) value += 1;
173 value = Math.min(value, space - 1);
174 previous = value;
175 keys.push(
176 PIN_ORDER_DIGITS.charAt(Math.floor(value / PIN_ORDER_DIGITS.length)) +
177 PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length),
178 );
179 }
180 return keys;
181}
182
183/**
184 * Assignments needed to realize a new pinned order. When the moved thread
185 * sits between two keyed (or absent) neighbors, this is a single write to
186 * the moved thread. When a neighbor is keyless (threads pinned before
187 * reordering shipped), the whole section gets fresh spread keys — a
188 * one-time materialization; every move after that is single-write.
189 */
190export function planPinnedReorder(input: {
191 /** Thread ids in the desired visual order (after the move). */
192 readonly orderedIds: readonly string[];
193 readonly keysById: ReadonlyMap<string, string | null | undefined>;
194 readonly movedId: string;
195}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> {
196 const { orderedIds, keysById, movedId } = input;
197 const movedIndex = orderedIds.indexOf(movedId);
198 if (movedIndex === -1) return [];
199 const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null;
200 const afterId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : null;
201 const beforeKey = beforeId != null ? (keysById.get(beforeId) ?? null) : null;
202 const afterKey = afterId != null ? (keysById.get(afterId) ?? null) : null;
203 const beforeUsable = beforeId === null || beforeKey != null;
204 const afterUsable = afterId === null || afterKey != null;
205 if (beforeUsable && afterUsable) {
206 const key = pinOrderKeyBetween(beforeKey, afterKey);
207 if (key !== null) return [{ id: movedId, orderKey: key }];
208 }
209 // Keyless neighbor (or corrupt keys): rewrite the section in the new order.
210 const keys = generateSpreadPinOrderKeys(orderedIds.length);
211 return orderedIds.flatMap((id, index) => {
212 const key = keys[index]!;
213 return keysById.get(id) === key ? [] : [{ id, orderKey: key }];
214 });
215}
216
217/**
218 * Pinned block order: user-arranged keys first (string comparison, id
219 * tiebreak), then keyless threads newest-created first — so threads on
220 * servers that predate reordering keep the static creation order at the
221 * bottom of the block instead of breaking the section.
222 */
223export function sortPinnedThreadsByOrderKey<
224 T extends {
225 readonly id: string;
226 readonly createdAt: string;
227 readonly pinOrderKey?: string | null | undefined;
228 /** Thread ids are only unique within an environment, and the pinned
229 block merges environments — the tiebreak needs both parts or two
230 clients could render equal-key threads in stream-arrival order. */
231 readonly environmentId?: string | undefined;
232 },
233>(threads: readonly T[]): T[] {
234 const keyed: T[] = [];
235 const keyless: T[] = [];
236 for (const thread of threads) {
237 (thread.pinOrderKey != null ? keyed : keyless).push(thread);
238 }
239 const identityTiebreak = (left: T, right: T) =>
240 left.id.localeCompare(right.id) ||
241 (left.environmentId ?? "").localeCompare(right.environmentId ?? "");
242 keyed.sort((left, right) => {
243 const leftKey = left.pinOrderKey!;
244 const rightKey = right.pinOrderKey!;
245 return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : identityTiebreak(left, right);
246 });
247 keyless.sort((left, right) => {
248 const leftMs = Date.parse(left.createdAt);
249 const rightMs = Date.parse(right.createdAt);
250 return (
251 (Number.isNaN(rightMs) ? 0 : rightMs) - (Number.isNaN(leftMs) ? 0 : leftMs) ||
252 identityTiebreak(left, right)
253 );
254 });
255 return [...keyed, ...keyless];
256}
257
258/**
259 * planPinnedReorder specialized for mobile's Move up / Move down menu
260 * actions: swap the moved thread with its displayed neighbor. Null when the
261 * move falls off either end of the list. Same single-write-per-move
262 * semantics as a web drag.
263 */
264export function planPinnedMove(input: {
265 /** Reorder-capable pinned thread ids in displayed order. */
266 readonly orderedIds: readonly string[];
267 readonly keysById: ReadonlyMap<string, string | null | undefined>;
268 readonly movedId: string;
269 readonly direction: "up" | "down";
270}): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> | null {
271 const { orderedIds, keysById, movedId, direction } = input;
272 const from = orderedIds.indexOf(movedId);
273 if (from === -1) return null;
274 const to = direction === "up" ? from - 1 : from + 1;
275 if (to < 0 || to >= orderedIds.length) return null;
276 const newOrder = [...orderedIds];
277 newOrder.splice(from, 1);
278 newOrder.splice(to, 0, movedId);
279 return planPinnedReorder({ orderedIds: newOrder, keysById, movedId });
280}
packages/contracts/src/environment.ts+3
...@@ -50,6 +50,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -50,6 +50,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
50 /** Server understands thread.pin / thread.unpin commands. Same50 /** Server understands thread.pin / thread.unpin commands. Same
51 version-skew contract as threadSettlement. */51 version-skew contract as threadSettlement. */
52 threadPinning: Schema.optionalKey(Schema.Boolean),52 threadPinning: Schema.optionalKey(Schema.Boolean),
53 /** Server understands thread.pin.reorder (and orderKey on thread.pin).
54 Same version-skew contract as threadSettlement. */
55 threadPinReorder: Schema.optionalKey(Schema.Boolean),
53 /** Server understands regenerateTitle on thread.meta.update. Absent on56 /** Server understands regenerateTitle on thread.meta.update. Absent on
54 older servers, so clients hide the action instead of sending it. */57 older servers, so clients hide the action instead of sending it. */
55 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),58 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/orchestration.ts+38
...@@ -379,6 +379,11 @@ export const OrchestrationThread = Schema.Struct({...@@ -379,6 +379,11 @@ export const OrchestrationThread = Schema.Struct({
379 // thread renders in the pinned block and never classifies into a shelf.379 // thread renders in the pinned block and never classifies into a shelf.
380 // Optional so payloads from pre-pinning servers still decode.380 // Optional so payloads from pre-pinning servers still decode.
381 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),381 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
382 // Fractional index for user-arranged pinned order. Keyed threads sort by
383 // string comparison ahead of keyless ones (which keep creation order), so
384 // servers never need each other's threads to agree on the merged list.
385 // Optional so payloads from pre-reorder servers still decode.
386 pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
382 // Pending-only state. Optional so older servers remain compatible.387 // Pending-only state. Optional so older servers remain compatible.
383 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),388 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
384 deletedAt: Schema.NullOr(IsoDateTime),389 deletedAt: Schema.NullOr(IsoDateTime),
...@@ -434,6 +439,7 @@ export const OrchestrationThreadShell = Schema.Struct({...@@ -434,6 +439,7 @@ export const OrchestrationThreadShell = Schema.Struct({
434 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),439 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
435 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),440 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
436 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),441 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
442 pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
437 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),443 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
438 session: Schema.NullOr(OrchestrationSession),444 session: Schema.NullOr(OrchestrationSession),
439 latestUserMessageAt: Schema.NullOr(IsoDateTime),445 latestUserMessageAt: Schema.NullOr(IsoDateTime),
...@@ -702,6 +708,10 @@ const ThreadPinCommand = Schema.Struct({...@@ -702,6 +708,10 @@ const ThreadPinCommand = Schema.Struct({
702 type: Schema.Literal("thread.pin"),708 type: Schema.Literal("thread.pin"),
703 commandId: CommandId,709 commandId: CommandId,
704 threadId: ThreadId,710 threadId: ThreadId,
711 // Initial slot in the user-arranged pinned order (see ThreadPinReorderCommand).
712 // Optional: clients on pre-reorder servers omit it, and the pinned block
713 // falls back to creation order for keyless threads.
714 orderKey: Schema.optional(TrimmedNonEmptyString),
705});715});
706716
707const ThreadUnpinCommand = Schema.Struct({717const ThreadUnpinCommand = Schema.Struct({
...@@ -710,6 +720,17 @@ const ThreadUnpinCommand = Schema.Struct({...@@ -710,6 +720,17 @@ const ThreadUnpinCommand = Schema.Struct({
710 threadId: ThreadId,720 threadId: ThreadId,
711});721});
712722
723const ThreadPinReorderCommand = Schema.Struct({
724 type: Schema.Literal("thread.pin.reorder"),
725 commandId: CommandId,
726 threadId: ThreadId,
727 // Fractional index key: pinned threads sort by plain string comparison of
728 // these keys, so a drag writes one key to one thread — neighbors (possibly
729 // on other servers) are never touched. Clients compute a key that sorts
730 // between the dropped position's neighbors.
731 orderKey: TrimmedNonEmptyString,
732});
733
713const ThreadMetaUpdateCommand = Schema.Struct({734const ThreadMetaUpdateCommand = Schema.Struct({
714 type: Schema.Literal("thread.meta.update"),735 type: Schema.Literal("thread.meta.update"),
715 commandId: CommandId,736 commandId: CommandId,
...@@ -865,6 +886,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([...@@ -865,6 +886,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
865 ThreadUnsnoozeCommand,886 ThreadUnsnoozeCommand,
866 ThreadPinCommand,887 ThreadPinCommand,
867 ThreadUnpinCommand,888 ThreadUnpinCommand,
889 ThreadPinReorderCommand,
868 ThreadMetaUpdateCommand,890 ThreadMetaUpdateCommand,
869 ThreadRuntimeModeSetCommand,891 ThreadRuntimeModeSetCommand,
870 ThreadInteractionModeSetCommand,892 ThreadInteractionModeSetCommand,
...@@ -892,6 +914,7 @@ export const ClientOrchestrationCommand = Schema.Union([...@@ -892,6 +914,7 @@ export const ClientOrchestrationCommand = Schema.Union([
892 ThreadUnsnoozeCommand,914 ThreadUnsnoozeCommand,
893 ThreadPinCommand,915 ThreadPinCommand,
894 ThreadUnpinCommand,916 ThreadUnpinCommand,
917 ThreadPinReorderCommand,
895 ThreadMetaUpdateCommand,918 ThreadMetaUpdateCommand,
896 ThreadRuntimeModeSetCommand,919 ThreadRuntimeModeSetCommand,
897 ThreadInteractionModeSetCommand,920 ThreadInteractionModeSetCommand,
...@@ -1009,6 +1032,7 @@ export const OrchestrationEventType = Schema.Literals([...@@ -1009,6 +1032,7 @@ export const OrchestrationEventType = Schema.Literals([
1009 "thread.unsnoozed",1032 "thread.unsnoozed",
1010 "thread.pinned",1033 "thread.pinned",
1011 "thread.unpinned",1034 "thread.unpinned",
1035 "thread.pin-reordered",
1012 "thread.meta-updated",1036 "thread.meta-updated",
1013 "thread.runtime-mode-set",1037 "thread.runtime-mode-set",
1014 "thread.interaction-mode-set",1038 "thread.interaction-mode-set",
...@@ -1120,6 +1144,9 @@ export const ThreadUnsnoozedPayload = Schema.Struct({...@@ -1120,6 +1144,9 @@ export const ThreadUnsnoozedPayload = Schema.Struct({
1120export const ThreadPinnedPayload = Schema.Struct({1144export const ThreadPinnedPayload = Schema.Struct({
1121 threadId: ThreadId,1145 threadId: ThreadId,
1122 pinnedAt: IsoDateTime,1146 pinnedAt: IsoDateTime,
1147 // Absent on re-pins of an already-pinned thread (the existing key wins)
1148 // and on pins from clients that predate reordering.
1149 pinOrderKey: Schema.optional(TrimmedNonEmptyString),
1123 updatedAt: IsoDateTime,1150 updatedAt: IsoDateTime,
1124});1151});
11251152
...@@ -1128,6 +1155,12 @@ export const ThreadUnpinnedPayload = Schema.Struct({...@@ -1128,6 +1155,12 @@ export const ThreadUnpinnedPayload = Schema.Struct({
1128 updatedAt: IsoDateTime,1155 updatedAt: IsoDateTime,
1129});1156});
11301157
1158export const ThreadPinReorderedPayload = Schema.Struct({
1159 threadId: ThreadId,
1160 orderKey: TrimmedNonEmptyString,
1161 updatedAt: IsoDateTime,
1162});
1163
1131export const ThreadMetaUpdatedPayload = Schema.Struct({1164export const ThreadMetaUpdatedPayload = Schema.Struct({
1132 threadId: ThreadId,1165 threadId: ThreadId,
1133 title: Schema.optional(TrimmedNonEmptyString),1166 title: Schema.optional(TrimmedNonEmptyString),
...@@ -1332,6 +1365,11 @@ export const OrchestrationEvent = Schema.Union([...@@ -1332,6 +1365,11 @@ export const OrchestrationEvent = Schema.Union([
1332 type: Schema.Literal("thread.unpinned"),1365 type: Schema.Literal("thread.unpinned"),
1333 payload: ThreadUnpinnedPayload,1366 payload: ThreadUnpinnedPayload,
1334 }),1367 }),
1368 Schema.Struct({
1369 ...EventBaseFields,
1370 type: Schema.Literal("thread.pin-reordered"),
1371 payload: ThreadPinReorderedPayload,
1372 }),
1335 Schema.Struct({1373 Schema.Struct({
1336 ...EventBaseFields,1374 ...EventBaseFields,
1337 type: Schema.Literal("thread.meta-updated"),1375 type: Schema.Literal("thread.meta-updated"),