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() {
4444 unsnoozeThread,
4545 pinThread,
4646 unpinThread,
47 movePinnedThread,
4748 unsettleThread,
4849 } = useThreadListActions();
4950 const pendingTasks = usePendingNewTasks();
......@@ -170,6 +171,7 @@ export function HomeRouteScreen() {
170171 onUnsettleThread={unsettleThread}
171172 onPinThread={pinThread}
172173 onUnpinThread={unpinThread}
174 onMovePinnedThread={movePinnedThread}
173175 onEnvironmentChange={setSelectedEnvironmentId}
174176 onProjectChange={setSelectedProjectKey}
175177 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
apps/mobile/src/features/home/HomeScreen.tsx+44
......@@ -11,6 +11,7 @@ import {
1111 threadSearchMatchKey,
1212 type EnvironmentThreadSearchMatch,
1313} from "@t3tools/client-runtime/state/thread-search";
14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
1415import type {
1516 EnvironmentId,
1617 SidebarProjectGroupingMode,
......@@ -110,6 +111,10 @@ interface HomeScreenProps {
110111 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
111112 readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
112113 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
114 readonly onMovePinnedThread: (
115 thread: EnvironmentThreadShell,
116 direction: "up" | "down",
117 ) => Promise<boolean>;
113118 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
114119 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
115120 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
......@@ -521,6 +526,12 @@ export function HomeScreen(props: HomeScreenProps) {
521526 },
522527 [props.onPinThread],
523528 );
529 const handleMovePinnedThread = useCallback(
530 (thread: EnvironmentThreadShell, direction: "up" | "down") => {
531 void props.onMovePinnedThread(thread, direction);
532 },
533 [props.onMovePinnedThread],
534 );
524535 const handleUnpinThread = useCallback(
525536 (thread: EnvironmentThreadShell) => {
526537 void props.onUnpinThread(thread);
......@@ -595,6 +606,29 @@ export function HomeScreen(props: HomeScreenProps) {
595606 }
596607 return supported;
597608 }, [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]);
598632 const threadListV2Layout = useMemo(() => {
599633 if (!threadListV2Enabled)
600634 return {
......@@ -781,11 +815,18 @@ export function HomeScreen(props: HomeScreenProps) {
781815 onSettleThread={handleSettleThread}
782816 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
783817 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 })()}
784824 onSnoozeThread={handleSnoozeThread}
785825 onUnsnoozeThread={handleUnsnoozeThread}
786826 onUnsettleThread={handleUnsettleThread}
787827 onPinThread={handlePinThread}
788828 onUnpinThread={handleUnpinThread}
829 onMovePinnedThread={handleMovePinnedThread}
789830 onChangeRequestState={handleChangeRequestState}
790831 projectCwd={
791832 projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
......@@ -798,6 +839,8 @@ export function HomeScreen(props: HomeScreenProps) {
798839 [
799840 handleChangeRequestState,
800841 handleDeleteThread,
842 arrangedPinnedKeys,
843 handleMovePinnedThread,
801844 handlePinThread,
802845 handleSettleThread,
803846 handleSnoozeThread,
......@@ -807,6 +850,7 @@ export function HomeScreen(props: HomeScreenProps) {
807850 handleSwipeableWillOpen,
808851 handleUnsettleThread,
809852 pinningEnvironmentIds,
853 pinReorderEnvironmentIds,
810854 projectByKey,
811855 projectCwdByKey,
812856 props.onArchiveThread,
apps/mobile/src/features/home/useThreadListActions.ts+110-2
......@@ -8,9 +8,14 @@ import { Alert } from "react-native";
88import { showConfirmDialog } from "../../components/ConfirmDialogHost";
99import { scopedThreadKey } from "../../lib/scopedEntities";
1010import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
11import {
12 pinOrderKeyBetween,
13 planPinnedMove,
14 sortPinnedThreadsByOrderKey,
15} from "@t3tools/client-runtime/state/thread-sort";
1116import { appAtomRegistry } from "../../state/atom-registry";
1217import { environmentServerConfigsAtom } from "../../state/server";
13import { threadEnvironment } from "../../state/threads";
18import { environmentThreadShells, threadEnvironment } from "../../state/threads";
1419import { useAtomCommand } from "../../state/use-atom-command";
1520
1621/** Version skew: never send settle/unsettle to a server that predates them
......@@ -36,6 +41,13 @@ function environmentSupportsPinning(environmentId: EnvironmentThreadShell["envir
3641 );
3742}
3843
44function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["environmentId"]) {
45 return (
46 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
47 .threadPinReorder === true
48 );
49}
50
3951type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";
4052
4153const ACTION_VERBS: Record<ThreadListAction, string> = {
......@@ -211,6 +223,10 @@ export function useThreadListActions(): {
211223 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
212224 readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
213225 readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
226 readonly movePinnedThread: (
227 thread: EnvironmentThreadShell,
228 direction: "up" | "down",
229 ) => Promise<boolean>;
214230} {
215231 const executeAction = useThreadActionExecutor();
216232 const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false });
......@@ -331,9 +347,21 @@ export function useThreadListActions(): {
331347 return false;
332348 }
333349 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 }
334362 const result = await pinMutation({
335363 environmentId: thread.environmentId,
336 input: { threadId: thread.id },
364 input: { threadId: thread.id, ...(orderKey !== undefined ? { orderKey } : {}) },
337365 });
338366 if (result._tag === "Failure") {
339367 const error = Cause.squash(result.cause);
......@@ -378,6 +406,85 @@ export function useThreadListActions(): {
378406 [unpinMutation],
379407 );
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
381488 const confirmDeleteThread = useConfirmDeleteThread(executeAction);
382489
383490 return {
......@@ -389,6 +496,7 @@ export function useThreadListActions(): {
389496 unsettleThread,
390497 pinThread,
391498 unpinThread,
499 movePinnedThread,
392500 };
393501}
394502
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+36
......@@ -11,6 +11,7 @@ import { LegendList } from "@legendapp/list/react-native";
1111import type { MenuAction } from "@react-native-menu/menu";
1212import { useAtomValue } from "@effect/atom-react";
1313import type { EnvironmentId } from "@t3tools/contracts";
14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
1415import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
1516import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
1617import { Platform, Pressable, StyleSheet, TextInput, View, useColorScheme } from "react-native";
......@@ -209,6 +210,7 @@ function ThreadNavigationSidebarPane(
209210 unsettleThread,
210211 pinThread,
211212 unpinThread,
213 movePinnedThread,
212214 } = useThreadListActions();
213215 const threadListV2Enabled = useThreadListV2Enabled();
214216 const pendingTasks = usePendingNewTasks();
......@@ -494,6 +496,28 @@ function ThreadNavigationSidebarPane(
494496 }
495497 return supported;
496498 }, [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]);
497521 const threadListV2Layout = useMemo(() => {
498522 if (!threadListV2Enabled)
499523 return {
......@@ -930,11 +954,20 @@ function ThreadNavigationSidebarPane(
930954 onSettleThread={settleThread}
931955 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
932956 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 })()}
933965 onSnoozeThread={snoozeThread}
934966 onUnsnoozeThread={unsnoozeThread}
935967 onUnsettleThread={unsettleThread}
936968 onPinThread={pinThread}
937969 onUnpinThread={unpinThread}
970 onMovePinnedThread={movePinnedThread}
938971 onChangeRequestState={handleChangeRequestState}
939972 projectCwd={projectCwdByKey.get(scopeKey) ?? null}
940973 onSwipeableClose={handleSwipeableClose}
......@@ -1055,13 +1088,16 @@ function ThreadNavigationSidebarPane(
10551088 },
10561089 [
10571090 archiveThread,
1091 arrangedPinnedKeys,
10581092 confirmDeletePendingTask,
10591093 confirmDeleteThread,
10601094 handleChangeRequestState,
10611095 handleSelectThread,
10621096 handleSwipeableClose,
10631097 handleSwipeableWillOpen,
1098 movePinnedThread,
10641099 openPendingTask,
1100 pinReorderEnvironmentIds,
10651101 pinThread,
10661102 pinningEnvironmentIds,
10671103 projectByKey,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+44-1
......@@ -354,6 +354,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
354354 readonly snoozeSupported: boolean;
355355 /** False on servers that predate thread.pin/unpin. */
356356 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;
357365 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
358366 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
359367 /** 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: {
382390 onArchiveThread,
383391 onPinThread,
384392 onUnpinThread,
393 onMovePinnedThread,
385394 onChangeRequestState,
386395 } = props;
387396 const snoozedRow = props.snoozed === true;
......@@ -416,6 +425,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
416425 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
417426 const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);
418427 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 );
419436 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
420437
421438 // Swipe: the v2 primary action is the lifecycle transition. Every settled
......@@ -459,12 +476,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
459476 () =>
460477 props.pinningSupported
461478 ? [
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 : []),
462495 pinnedRow
463496 ? { id: "unpin", title: "Unpin", image: "pin.slash" }
464497 : { id: "pin", title: "Pin", image: "pin" },
465498 ]
466499 : [],
467 [pinnedRow, props.pinningSupported],
500 [
501 pinnedRow,
502 props.canMovePinnedDown,
503 props.canMovePinnedUp,
504 props.pinReorderSupported,
505 props.pinningSupported,
506 ],
468507 );
469508 const snoozableCardMenuActions = useMemo<MenuAction[]>(
470509 () => [
......@@ -491,6 +530,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
491530 if (nativeEvent.event === "unsnooze") handleUnsnooze();
492531 if (nativeEvent.event === "pin") handlePin();
493532 if (nativeEvent.event === "unpin") handleUnpin();
533 if (nativeEvent.event === "move-pin-up") handleMovePinnedUp();
534 if (nativeEvent.event === "move-pin-down") handleMovePinnedDown();
494535 if (nativeEvent.event === "archive") handleArchive();
495536 if (nativeEvent.event === "delete") handleDelete();
496537 const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({
......@@ -507,6 +548,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
507548 [
508549 handleArchive,
509550 handleDelete,
551 handleMovePinnedDown,
552 handleMovePinnedUp,
510553 handlePin,
511554 handleSettle,
512555 handleSnooze,
apps/mobile/src/features/threads/threadListV2.ts+4-3
......@@ -9,6 +9,7 @@ import {
99import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
1010import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
1111import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
12import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
1213import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
1314
1415import type { PendingNewTask } from "../../state/use-pending-new-tasks";
......@@ -383,8 +384,8 @@ export function buildThreadListV2Items(input: {
383384 input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
384385 // Visibility parity with web: snooze outranks everything, including a
385386 // pin — a snoozed thread leaves the list until it wakes (or raises its
386 // hand). The pin survives underneath, so a woken thread reappears at
387 // its original spot in the creation-ordered pinned block.
387 // hand). The pin (and its pinOrderKey) survives underneath, so a woken
388 // thread reappears at its exact spot in the pinned block.
388389 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
389390 snoozed.push(thread);
390391 if (
......@@ -444,7 +445,7 @@ export function buildThreadListV2Items(input: {
444445 );
445446
446447 const items: ThreadListV2Item[] = [];
447 for (const thread of sortThreadsForListV2(pinned)) {
448 for (const thread of sortPinnedThreadsByOrderKey(pinned)) {
448449 items.push({
449450 thread,
450451 variant: "card",
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -146,6 +146,7 @@ export const make = Effect.gen(function* () {
146146 threadSettlement: true,
147147 threadSnooze: true,
148148 threadPinning: true,
149 threadPinReorder: true,
149150 threadTitleRegeneration: true,
150151 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
151152 ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+20
......@@ -612,6 +612,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
612612 snoozedUntil: null,
613613 snoozedAt: null,
614614 pinnedAt: null,
615 pinOrderKey: null,
615616 titleRegenerationRequestId: null,
616617 titleRegenerationStartedAt: null,
617618 latestUserMessageAt: null,
......@@ -728,6 +729,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
728729 yield* projectionThreadRepository.upsert({
729730 ...existingRow.value,
730731 pinnedAt: event.payload.pinnedAt,
732 ...(event.payload.pinOrderKey !== undefined
733 ? { pinOrderKey: event.payload.pinOrderKey }
734 : {}),
731735 updatedAt: event.payload.updatedAt,
732736 });
733737 return;
......@@ -743,6 +747,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
743747 yield* projectionThreadRepository.upsert({
744748 ...existingRow.value,
745749 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,
746766 updatedAt: event.payload.updatedAt,
747767 });
748768 return;
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+8-2
......@@ -87,6 +87,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
8787 pending_approval_count,
8888 pending_user_input_count,
8989 has_actionable_proposed_plan,
90 pinned_at,
91 pin_order_key,
9092 created_at,
9193 updated_at,
9294 deleted_at
......@@ -105,6 +107,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
105107 1,
106108 0,
107109 0,
110 '2026-02-24T00:00:01.000Z',
111 'gm',
108112 '2026-02-24T00:00:02.000Z',
109113 '2026-02-24T00:00:03.000Z',
110114 NULL
......@@ -317,7 +321,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
317321 settledAt: null,
318322 snoozedUntil: null,
319323 snoozedAt: null,
320 pinnedAt: null,
324 pinnedAt: "2026-02-24T00:00:01.000Z",
325 pinOrderKey: "gm",
321326 titleRegeneration: null,
322327 deletedAt: null,
323328 messages: [
......@@ -433,7 +438,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
433438 settledAt: null,
434439 snoozedUntil: null,
435440 snoozedAt: null,
436 pinnedAt: null,
441 pinnedAt: "2026-02-24T00:00:01.000Z",
442 pinOrderKey: "gm",
437443 titleRegeneration: null,
438444 session: {
439445 threadId: ThreadId.make("thread-1"),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+10
......@@ -423,6 +423,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
423423 snoozed_until AS "snoozedUntil",
424424 snoozed_at AS "snoozedAt",
425425 pinned_at AS "pinnedAt",
426 pin_order_key AS "pinOrderKey",
426427 title_regeneration_request_id AS "titleRegenerationRequestId",
427428 title_regeneration_started_at AS "titleRegenerationStartedAt",
428429 latest_user_message_at AS "latestUserMessageAt",
......@@ -458,6 +459,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
458459 snoozed_until AS "snoozedUntil",
459460 snoozed_at AS "snoozedAt",
460461 pinned_at AS "pinnedAt",
462 pin_order_key AS "pinOrderKey",
461463 title_regeneration_request_id AS "titleRegenerationRequestId",
462464 title_regeneration_started_at AS "titleRegenerationStartedAt",
463465 latest_user_message_at AS "latestUserMessageAt",
......@@ -495,6 +497,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
495497 snoozed_until AS "snoozedUntil",
496498 snoozed_at AS "snoozedAt",
497499 pinned_at AS "pinnedAt",
500 pin_order_key AS "pinOrderKey",
498501 title_regeneration_request_id AS "titleRegenerationRequestId",
499502 title_regeneration_started_at AS "titleRegenerationStartedAt",
500503 latest_user_message_at AS "latestUserMessageAt",
......@@ -932,6 +935,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
932935 snoozed_until AS "snoozedUntil",
933936 snoozed_at AS "snoozedAt",
934937 pinned_at AS "pinnedAt",
938 pin_order_key AS "pinOrderKey",
935939 title_regeneration_request_id AS "titleRegenerationRequestId",
936940 title_regeneration_started_at AS "titleRegenerationStartedAt",
937941 latest_user_message_at AS "latestUserMessageAt",
......@@ -1562,6 +1566,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
15621566 snoozedUntil: row.snoozedUntil,
15631567 snoozedAt: row.snoozedAt,
15641568 pinnedAt: row.pinnedAt,
1569 pinOrderKey: row.pinOrderKey ?? null,
15651570 titleRegeneration: mapTitleRegeneration(row),
15661571 deletedAt: row.deletedAt,
15671572 messages: messagesByThread.get(row.threadId) ?? [],
......@@ -1766,6 +1771,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
17661771 snoozedUntil: row.snoozedUntil,
17671772 snoozedAt: row.snoozedAt,
17681773 pinnedAt: row.pinnedAt,
1774 pinOrderKey: row.pinOrderKey ?? null,
17691775 titleRegeneration: mapTitleRegeneration(row),
17701776 deletedAt: row.deletedAt,
17711777 messages: [],
......@@ -1901,6 +1907,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
19011907 snoozedUntil: row.snoozedUntil,
19021908 snoozedAt: row.snoozedAt,
19031909 pinnedAt: row.pinnedAt,
1910 pinOrderKey: row.pinOrderKey ?? null,
19041911 titleRegeneration: mapTitleRegeneration(row),
19051912 session: sessionByThread.get(row.threadId) ?? null,
19061913 latestUserMessageAt: row.latestUserMessageAt,
......@@ -2045,6 +2052,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20452052 snoozedUntil: row.snoozedUntil,
20462053 snoozedAt: row.snoozedAt,
20472054 pinnedAt: row.pinnedAt,
2055 pinOrderKey: row.pinOrderKey ?? null,
20482056 titleRegeneration: mapTitleRegeneration(row),
20492057 session: sessionByThread.get(row.threadId) ?? null,
20502058 latestUserMessageAt: row.latestUserMessageAt,
......@@ -2321,6 +2329,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
23212329 snoozedUntil: threadRow.value.snoozedUntil,
23222330 snoozedAt: threadRow.value.snoozedAt,
23232331 pinnedAt: threadRow.value.pinnedAt,
2332 pinOrderKey: threadRow.value.pinOrderKey ?? null,
23242333 titleRegeneration: mapTitleRegeneration(threadRow.value),
23252334 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
23262335 latestUserMessageAt: threadRow.value.latestUserMessageAt,
......@@ -2441,6 +2450,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
24412450 snoozedUntil: threadRow.value.snoozedUntil,
24422451 snoozedAt: threadRow.value.snoozedAt,
24432452 pinnedAt: threadRow.value.pinnedAt,
2453 pinOrderKey: threadRow.value.pinOrderKey ?? null,
24442454 titleRegeneration: mapTitleRegeneration(threadRow.value),
24452455 deletedAt: null,
24462456 messages: messageRows.map((row) => {
apps/server/src/orchestration/Schemas.ts+2
......@@ -15,6 +15,7 @@ import {
1515 ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema,
1616 ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema,
1717 ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema,
18 ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema,
1819 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
1920 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
2021 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
......@@ -46,6 +47,7 @@ export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema;
4647export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema;
4748export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema;
4849export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema;
50export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema;
4951
5052export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
5153export 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";
1616
1717function makeReadModel(input: {
1818 readonly pinnedAt?: string | null;
19 readonly pinOrderKey?: string | null;
1920 readonly archivedAt?: string | null;
2021 readonly settledOverride?: "settled" | "active" | null;
2122 readonly settledAt?: string | null;
......@@ -44,6 +45,7 @@ function makeReadModel(input: {
4445 snoozedUntil: input.snoozedUntil ?? null,
4546 snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? PINNED_AT : null),
4647 pinnedAt: input.pinnedAt ?? null,
48 pinOrderKey: input.pinOrderKey ?? null,
4749 deletedAt: null,
4850 messages: [],
4951 proposedPlans: [],
......@@ -223,4 +225,100 @@ it.layer(NodeServices.layer)("pinned thread decider", (it) => {
223225 expect(error._tag).toBe("OrchestrationCommandInvariantError");
224226 }),
225227 );
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 );
226324});
apps/server/src/orchestration/decider.ts+43
......@@ -676,6 +676,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
676676 payload: {
677677 threadId: command.threadId,
678678 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 : {}),
679685 updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt,
680686 },
681687 };
......@@ -745,6 +751,43 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
745751 };
746752 }
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
748791 case "thread.meta.update": {
749792 const thread = yield* requireThread({
750793 readModel,
apps/server/src/orchestration/projector.pinned.test.ts+77
......@@ -75,3 +75,80 @@ it.effect("projects pin lifecycle events", () =>
7575 expect(unpinned.threads[0]?.pinnedAt).toBeNull();
7676 }),
7777);
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 {
2424 ThreadRuntimeModeSetPayload,
2525 ThreadSettledPayload,
2626 ThreadPinnedPayload,
27 ThreadPinReorderedPayload,
2728 ThreadSnoozedPayload,
2829 ThreadUnpinnedPayload,
2930 ThreadUnarchivedPayload,
......@@ -402,6 +403,7 @@ export function projectEvent(
402403 ...nextBase,
403404 threads: updateThread(nextBase.threads, payload.threadId, {
404405 pinnedAt: payload.pinnedAt,
406 ...(payload.pinOrderKey !== undefined ? { pinOrderKey: payload.pinOrderKey } : {}),
405407 updatedAt: payload.updatedAt,
406408 }),
407409 })),
......@@ -413,6 +415,20 @@ export function projectEvent(
413415 ...nextBase,
414416 threads: updateThread(nextBase.threads, payload.threadId, {
415417 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,
416432 updatedAt: payload.updatedAt,
417433 }),
418434 })),
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
......@@ -48,6 +48,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
4848 snoozed_until,
4949 snoozed_at,
5050 pinned_at,
51 pin_order_key,
5152 title_regeneration_request_id,
5253 title_regeneration_started_at,
5354 latest_user_message_at,
......@@ -74,6 +75,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
7475 ${row.snoozedUntil},
7576 ${row.snoozedAt},
7677 ${row.pinnedAt},
78 ${row.pinOrderKey ?? null},
7779 ${row.titleRegenerationRequestId ?? null},
7880 ${row.titleRegenerationStartedAt ?? null},
7981 ${row.latestUserMessageAt},
......@@ -100,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
100102 snoozed_until = excluded.snoozed_until,
101103 snoozed_at = excluded.snoozed_at,
102104 pinned_at = excluded.pinned_at,
105 pin_order_key = excluded.pin_order_key,
103106 title_regeneration_request_id = excluded.title_regeneration_request_id,
104107 title_regeneration_started_at = excluded.title_regeneration_started_at,
105108 latest_user_message_at = excluded.latest_user_message_at,
......@@ -133,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
133136 snoozed_until AS "snoozedUntil",
134137 snoozed_at AS "snoozedAt",
135138 pinned_at AS "pinnedAt",
139 pin_order_key AS "pinOrderKey",
136140 title_regeneration_request_id AS "titleRegenerationRequestId",
137141 title_regeneration_started_at AS "titleRegenerationStartedAt",
138142 latest_user_message_at AS "latestUserMessageAt",
......@@ -168,6 +172,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
168172 snoozed_until AS "snoozedUntil",
169173 snoozed_at AS "snoozedAt",
170174 pinned_at AS "pinnedAt",
175 pin_order_key AS "pinOrderKey",
171176 title_regeneration_request_id AS "titleRegenerationRequestId",
172177 title_regeneration_started_at AS "titleRegenerationStartedAt",
173178 latest_user_message_at AS "latestUserMessageAt",
apps/server/src/persistence/Migrations.ts+2
......@@ -50,6 +50,7 @@ import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";
5050import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts";
5151import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts";
5252import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts";
53import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";
5354
5455/**
5556 * Migration loader with all migrations defined inline.
......@@ -99,6 +100,7 @@ export const migrationEntries = [
99100 [35, "ProjectionThreadTitleRegeneration", Migration0035],
100101 [36, "ProjectionThreadsPinned", Migration0036],
101102 [37, "ProjectionTurnsKeysetIndex", Migration0037],
103 [38, "ProjectionThreadsPinOrderKey", Migration0038],
102104] as const;
103105
104106export 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({
4242 snoozedUntil: Schema.NullOr(IsoDateTime),
4343 snoozedAt: Schema.NullOr(IsoDateTime),
4444 pinnedAt: Schema.NullOr(IsoDateTime),
45 pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)),
4546 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),
4647 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
4748 latestUserMessageAt: Schema.NullOr(IsoDateTime),
apps/web/src/components/Sidebar.logic.test.ts+133
......@@ -26,6 +26,9 @@ import {
2626 shouldClearThreadSelectionOnMouseDown,
2727 sortLogicalProjectsForSidebar,
2828 sortSettledThreadsForSidebarV2,
29 pinOrderKeyBetween,
30 planPinnedReorder,
31 sortPinnedThreadsForSidebarV2,
2932 sortThreadsForSidebarV2,
3033 sortProjectsForSidebar,
3134 sortScopedProjectsForSidebar,
......@@ -738,6 +741,136 @@ describe("sortThreadsForSidebarV2", () => {
738741 });
739742});
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
741874describe("sortSettledThreadsForSidebarV2", () => {
742875 const settled = (input: {
743876 id: string;
apps/web/src/components/Sidebar.logic.ts+9
......@@ -510,6 +510,15 @@ export function sortThreadsForSidebarV2<
510510 );
511511}
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
513522/**
514523 * Search the already-ordered sidebar thread collection by title only.
515524 * Keeping the input order means lifecycle ordering (active, snoozed, settled)
apps/web/src/components/SidebarV2.tsx+306-80
......@@ -1,5 +1,21 @@
11import { autoAnimate } from "@formkit/auto-animate";
22import { 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";
319import {
420 canSnooze,
521 effectiveSettled,
......@@ -116,6 +132,7 @@ import {
116132 hasUnseenCompletion,
117133 isTrailingDoubleClick,
118134 orderItemsByPreferredIds,
135 planPinnedReorder,
119136 resolveAdjacentThreadId,
120137 resolveSettledTimestamp,
121138 resolveSidebarV2Status,
......@@ -123,6 +140,7 @@ import {
123140 resolveWorkingStartedAt,
124141 shouldNavigateAfterProjectRemoval,
125142 sortLogicalProjectsForSidebar,
143 sortPinnedThreadsForSidebarV2,
126144 sortSettledThreadsForSidebarV2,
127145 sortThreadsForSidebarV2,
128146} from "./Sidebar.logic";
......@@ -390,6 +408,26 @@ function SnoozePopoverButton(props: {
390408 );
391409}
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
393431const SidebarV2Row = memo(function SidebarV2Row(props: {
394432 thread: SidebarThreadSummary;
395433 variant: "card" | "slim";
......@@ -410,6 +448,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
410448 // the descriptor is not loaded. Pinning itself lives in the context menu.
411449 pinningSupported: boolean;
412450 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;
413455 // Compact wake countdown ("2h") for rows in the snoozed shelf.
414456 snoozeWakeLabelText: string | null;
415457 // When a snooze ended (timer or early wake); drives the Woke pill until
......@@ -974,10 +1016,24 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
9741016
9751017 const diff = latestTurnDiff(thread);
9761018
1019 const sortable = props.sortable;
9771020 return (
9781021 <li
9791022 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 )}
9811037 >
9821038 <Tooltip>
9831039 <TooltipTrigger
......@@ -1317,6 +1373,7 @@ export default function SidebarV2() {
13171373 unsnoozeThread,
13181374 pinThread,
13191375 unpinThread,
1376 reorderPinnedThread,
13201377 deleteThread,
13211378 } = useThreadActions();
13221379 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
......@@ -1702,83 +1759,100 @@ export default function SidebarV2() {
17021759 // merging, no optimistic holds. Archived threads remain hidden here —
17031760 // archive keeps its original "remove from sidebar" meaning.
17041761 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
1705 const { pinnedThreads, activeThreads, snoozedThreads, settledThreads, snoozeNow } =
1706 useMemo(() => {
1707 const now = `${nowMinute}:00.000Z`;
1708 // Snooze classification uses a REAL clock, not the quantized minute:
1709 // wake times are second-precise and a woken thread must not linger on
1710 // the shelf for the rest of the minute. snoozeWakeTick re-runs this
1711 // memo exactly at the next wake boundary.
1712 void snoozeWakeTick;
1713 const preciseNow = new Date().toISOString();
1714 const visible = threads.filter(
1715 (thread) =>
1716 thread.archivedAt === null &&
1717 (scopedProjectKeys === null ||
1718 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
1719 );
1720 const pinned: EnvironmentThreadShell[] = [];
1721 const active: EnvironmentThreadShell[] = [];
1722 const snoozed: EnvironmentThreadShell[] = [];
1723 const settled: EnvironmentThreadShell[] = [];
1724 for (const thread of visible) {
1725 // Threads on servers without the settlement capability (old server,
1726 // or descriptor not loaded yet) never classify as settled: the user
1727 // could neither un-settle nor pin them, so auto-settling them would
1728 // strand rows in a tail with no working affordances.
1729 const supportsSettlement =
1730 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement ===
1731 true;
1732 const supportsSnooze =
1733 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
1734 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1735 const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;
1736 // Snooze outranks everything, including a pin: "hide until Tuesday"
1737 // temporarily suspends "keep on top". The pin survives underneath —
1738 // pinned cards are creation-ordered, so on wake the thread reappears
1739 // at its original spot in the pinned block. (For unpinned threads
1740 // this is also the snooze-beats-auto-settle rule: the wake time is a
1741 // stronger statement about when the thread matters again.)
1742 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
1743 snoozed.push(thread);
1744 // A pin otherwise overrides the lifecycle: pinned threads never
1745 // auto-settle out of sight. (The decider clears settled state on
1746 // pin and the pin on settle, so pin-vs-settled conflicts only
1747 // arise from stale or raced writes.)
1748 } else if (thread.pinnedAt != null) {
1749 pinned.push(thread);
1750 } else if (
1751 supportsSettlement &&
1752 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
1753 ) {
1754 settled.push(thread);
1755 } else {
1756 active.push(thread);
1757 }
1762 const {
1763 pinnedThreads,
1764 reorderablePinnedKeys,
1765 activeThreads,
1766 snoozedThreads,
1767 settledThreads,
1768 snoozeNow,
1769 } = useMemo(() => {
1770 const now = `${nowMinute}:00.000Z`;
1771 // Snooze classification uses a REAL clock, not the quantized minute:
1772 // wake times are second-precise and a woken thread must not linger on
1773 // the shelf for the rest of the minute. snoozeWakeTick re-runs this
1774 // memo exactly at the next wake boundary.
1775 void snoozeWakeTick;
1776 const preciseNow = new Date().toISOString();
1777 const visible = threads.filter(
1778 (thread) =>
1779 thread.archivedAt === null &&
1780 (scopedProjectKeys === null ||
1781 scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)),
1782 );
1783 const pinned: EnvironmentThreadShell[] = [];
1784 const active: EnvironmentThreadShell[] = [];
1785 const snoozed: EnvironmentThreadShell[] = [];
1786 const settled: EnvironmentThreadShell[] = [];
1787 for (const thread of visible) {
1788 // Threads on servers without the settlement capability (old server,
1789 // or descriptor not loaded yet) never classify as settled: the user
1790 // could neither un-settle nor pin them, so auto-settling them would
1791 // strand rows in a tail with no working affordances.
1792 const supportsSettlement =
1793 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;
1794 const supportsSnooze =
1795 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
1796 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1797 const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;
1798 // Snooze outranks everything, including a pin: "hide until Tuesday"
1799 // temporarily suspends "keep on top". The pin survives underneath —
1800 // and so does its pinOrderKey, so on wake the thread reappears at
1801 // its exact slot in the pinned block. (For unpinned threads
1802 // this is also the snooze-beats-auto-settle rule: the wake time is a
1803 // stronger statement about when the thread matters again.)
1804 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
1805 snoozed.push(thread);
1806 // A pin otherwise overrides the lifecycle: pinned threads never
1807 // auto-settle out of sight. (The decider clears settled state on
1808 // pin and the pin on settle, so pin-vs-settled conflicts only
1809 // arise from stale or raced writes.)
1810 } else if (thread.pinnedAt != null) {
1811 pinned.push(thread);
1812 } else if (
1813 supportsSettlement &&
1814 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
1815 ) {
1816 settled.push(thread);
1817 } else {
1818 active.push(thread);
17581819 }
1759 return {
1760 // Same static creation order as the inbox: a pin freezes prominence,
1761 // it does not introduce a new ordering scheme.
1762 pinnedThreads: sortThreadsForSidebarV2(pinned),
1763 activeThreads: sortThreadsForSidebarV2(active),
1764 // Soonest wake first: "what comes back next" is the shelf's question.
1765 snoozedThreads: snoozed.toSorted(
1766 (left, right) =>
1767 firstValidTimestampMs(left.snoozedUntil ?? null) -
1768 firstValidTimestampMs(right.snoozedUntil ?? null),
1769 ),
1770 settledThreads: sortSettledThreadsForSidebarV2(settled),
1771 snoozeNow: preciseNow,
1772 };
1773 }, [
1774 autoSettleAfterDays,
1775 changeRequestStateByKey,
1776 nowMinute,
1777 scopedProjectKeys,
1778 serverConfigs,
1779 snoozeWakeTick,
1780 threads,
1781 ]);
1820 }
1821 // One shared rule on every platform (see sortPinnedThreadsByOrderKey):
1822 // user-arranged keys first, keyless threads in creation order below.
1823 // Server capability only gates DRAGGING — it must not influence the
1824 // sort, or mixed-version fleets would render different pinned orders on
1825 // web and mobile from the same data.
1826 return {
1827 pinnedThreads: sortPinnedThreadsForSidebarV2(pinned),
1828 reorderablePinnedKeys: new Set(
1829 pinned
1830 .filter(
1831 (thread) =>
1832 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder ===
1833 true,
1834 )
1835 .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
1836 ),
1837 activeThreads: sortThreadsForSidebarV2(active),
1838 // Soonest wake first: "what comes back next" is the shelf's question.
1839 snoozedThreads: snoozed.toSorted(
1840 (left, right) =>
1841 firstValidTimestampMs(left.snoozedUntil ?? null) -
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
17831857 const threadSearchInputRef = useRef<HTMLInputElement>(null);
17841858 const [threadSearchQuery, setThreadSearchQuery] = useState("");
......@@ -2193,9 +2267,70 @@ export default function SidebarV2() {
21932267 },
21942268 [unsnoozeThread],
21952269 );
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]);
21962327 const attemptPin = useCallback(
21972328 (threadRef: ScopedThreadRef) => {
21982329 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.
21992334 const result = await pinThread(threadRef);
22002335 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
22012336 const error = squashAtomCommandFailure(result);
......@@ -2229,6 +2364,63 @@ export default function SidebarV2() {
22292364 },
22302365 [unpinThread],
22312366 );
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 );
22322424 // One snooze per thread at a time — same double-dispatch guard as settle.
22332425 const snoozingThreadKeysRef = useRef(new Set<string>());
22342426 const performSnooze = useCallback(
......@@ -3069,6 +3261,7 @@ export default function SidebarV2() {
30693261 const renderThreadRow = (
30703262 thread: EnvironmentThreadShell,
30713263 section: "pinned" | "active" | "snoozed" | "settled",
3264 sortable?: SortablePinnedRowBag,
30723265 ) => {
30733266 const threadKey = scopedThreadKey(
30743267 scopeThreadRef(thread.environmentId, thread.id),
......@@ -3113,6 +3306,7 @@ export default function SidebarV2() {
31133306 .threadPinning === true
31143307 }
31153308 isPinned={section === "pinned"}
3309 sortable={sortable}
31163310 snoozeWakeLabelText={
31173311 section === "snoozed" && thread.snoozedUntil != null
31183312 ? snoozeWakeLabel(thread.snoozedUntil, {
......@@ -3161,9 +3355,41 @@ export default function SidebarV2() {
31613355 // Pinned block: full cards above the inbox, closed by a
31623356 // thin divider (the pin glyphs carry the meaning, so no
31633357 // header text). Vanishes entirely at count 0.
3164 const items: ReactNode[] = pinnedThreads.map((thread) =>
3165 renderThreadRow(thread, "pinned"),
3166 );
3358 // Rows render in the one shared pinned order; only
3359 // reorder-capable rows register as sortable (legacy-server
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 ];
31673393 if (pinnedThreads.length > 0) {
31683394 items.push(
31693395 <li
apps/web/src/hooks/useThreadActions.ts+69-3
......@@ -13,7 +13,7 @@ import { AsyncResult } from "effect/unstable/reactivity";
1313import { useRouter } from "@tanstack/react-router";
1414import { useCallback, useMemo, useRef } from "react";
1515
16import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";
16import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";
1717import { useComposerDraftStore } from "../composerDraftStore";
1818import { terminalEnvironment } from "../state/terminal";
1919import { threadEnvironment } from "../state/threads";
......@@ -23,11 +23,13 @@ import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsStat
2323import { readLocalApi } from "../localApi";
2424import {
2525 readEnvironmentSupportsPinning,
26 readEnvironmentSupportsPinReorder,
2627 readEnvironmentSupportsSettlement,
2728 readEnvironmentSupportsSnooze,
2829 readEnvironmentThreadRefs,
2930 readProject,
3031 readThreadShell,
32 readThreadShells,
3133} from "../state/entities";
3234import { useTerminalUiStateStore } from "../terminalUiStateStore";
3335import { useUiStateStore } from "../uiStateStore";
......@@ -97,6 +99,18 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedErrorClass<ThreadSnoo
9799 }
98100}
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
100114export class ThreadPinningUnsupportedError extends Schema.TaggedErrorClass<ThreadPinningUnsupportedError>()(
101115 "ThreadPinningUnsupportedError",
102116 {
......@@ -109,6 +123,18 @@ export class ThreadPinningUnsupportedError extends Schema.TaggedErrorClass<Threa
109123 }
110124}
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
112138export function useThreadActions() {
113139 const closeTerminal = useAtomCommand(terminalEnvironment.close);
114140 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
......@@ -132,6 +158,9 @@ export function useThreadActions() {
132158 const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, {
133159 reportFailure: false,
134160 });
161 const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, {
162 reportFailure: false,
163 });
135164 const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, {
136165 reportFailure: false,
137166 });
......@@ -506,7 +535,7 @@ export function useThreadActions() {
506535 );
507536
508537 const pinThread = useCallback(
509 async (target: ScopedThreadRef) => {
538 async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => {
510539 // Version skew: never send the command to a server that predates it.
511540 if (!readEnvironmentSupportsPinning(target.environmentId)) {
512541 return AsyncResult.failure(
......@@ -518,9 +547,21 @@ export function useThreadActions() {
518547 ),
519548 );
520549 }
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;
521559 return pinThreadMutation({
522560 environmentId: target.environmentId,
523 input: { threadId: target.threadId },
561 input: {
562 threadId: target.threadId,
563 ...(orderKey !== undefined ? { orderKey } : {}),
564 },
524565 });
525566 },
526567 [pinThreadMutation],
......@@ -546,6 +587,29 @@ export function useThreadActions() {
546587 [unpinThreadMutation],
547588 );
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
549613 const snoozeThread = useCallback(
550614 async (target: ScopedThreadRef, snoozedUntil: string) => {
551615 // Version skew: never send the command to a server that predates it.
......@@ -641,12 +705,14 @@ export function useThreadActions() {
641705 unsnoozeThread,
642706 pinThread,
643707 unpinThread,
708 reorderPinnedThread,
644709 }),
645710 [
646711 archiveThread,
647712 confirmAndDeleteThread,
648713 deleteThread,
649714 pinThread,
715 reorderPinnedThread,
650716 settleThread,
651717 snoozeThread,
652718 unarchiveThread,
apps/web/src/state/entities.ts+13
......@@ -259,6 +259,15 @@ export function readEnvironmentSupportsTitleRegeneration(environmentId: Environm
259259 );
260260}
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
262271export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null {
263272 return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref));
264273}
......@@ -273,6 +282,10 @@ export function readThreadRefs(): ReadonlyArray<ScopedThreadRef> {
273282 return appAtomRegistry.get(environmentThreadShells.threadRefsAtom);
274283}
275284
285export function readThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
286 return appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
287}
288
276289export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null {
277290 return (
278291 appAtomRegistry
docs/README.md+1
......@@ -5,6 +5,7 @@
55- [Install and first run](./user/install.md)
66- [Permission modes](./user/permission-modes.md)
77- [Keyboard shortcuts](./user/keybindings.md)
8- [Organizing threads](./user/thread-sidebar.md)
89- [Remote access](./user/remote-access.md)
910- [Keeping app and server in sync](./user/updating.md)
1011- [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">;
4141export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
4242export type PinThreadInput = CommandInput<"thread.pin">;
4343export type UnpinThreadInput = CommandInput<"thread.unpin">;
44export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;
4445export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
4546export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
4647export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
......@@ -219,6 +220,16 @@ export const unpinThread: (input: UnpinThreadInput) => CommandEffect = Effect.fn
219220 });
220221});
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
222233export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(
223234 "EnvironmentCommands.updateThreadMetadata",
224235)(function* (input) {
packages/client-runtime/src/state/threadCommands.ts+9
......@@ -13,6 +13,7 @@ import {
1313 type SetThreadInteractionModeInput,
1414 type SetThreadRuntimeModeInput,
1515 type PinThreadInput,
16 type ReorderPinnedThreadInput,
1617 type SettleThreadInput,
1718 type SnoozeThreadInput,
1819 type StartThreadTurnInput,
......@@ -32,6 +33,7 @@ import {
3233 setThreadInteractionMode,
3334 setThreadRuntimeMode,
3435 pinThread,
36 reorderPinnedThread,
3537 settleThread,
3638 snoozeThread,
3739 startThreadTurn,
......@@ -55,6 +57,7 @@ export type {
5557 SetThreadInteractionModeInput,
5658 SetThreadRuntimeModeInput,
5759 PinThreadInput,
60 ReorderPinnedThreadInput,
5861 SettleThreadInput,
5962 SnoozeThreadInput,
6063 StartThreadTurnInput,
......@@ -136,6 +139,12 @@ export function createThreadEnvironmentAtoms<R, E>(
136139 scheduler,
137140 concurrency,
138141 }),
142 reorderPin: createEnvironmentCommand(runtime, {
143 label: "environment-data:commands:thread:reorder-pin",
144 execute: (input: ReorderPinnedThreadInput) => reorderPinnedThread(input),
145 scheduler,
146 concurrency,
147 }),
139148 updateMetadata: createEnvironmentCommand(runtime, {
140149 label: "environment-data:commands:thread:update-metadata",
141150 execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input),
packages/client-runtime/src/state/threadDetail.ts+1
......@@ -62,6 +62,7 @@ export function mergeEnvironmentThread(
6262 snoozedUntil: shell.snoozedUntil,
6363 snoozedAt: shell.snoozedAt,
6464 pinnedAt: shell.pinnedAt,
65 pinOrderKey: shell.pinOrderKey,
6566 session: shell.session,
6667 };
6768}
packages/client-runtime/src/state/threadReducer.ts+14
......@@ -173,6 +173,9 @@ export function applyThreadDetailEvent(
173173 thread: {
174174 ...thread,
175175 pinnedAt: event.payload.pinnedAt,
176 ...(event.payload.pinOrderKey !== undefined
177 ? { pinOrderKey: event.payload.pinOrderKey }
178 : {}),
176179 updatedAt: event.payload.updatedAt,
177180 },
178181 };
......@@ -183,6 +186,17 @@ export function applyThreadDetailEvent(
183186 thread: {
184187 ...thread,
185188 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,
186200 updatedAt: event.payload.updatedAt,
187201 },
188202 };
packages/client-runtime/src/state/threadSort.test.ts+72-1
......@@ -1,6 +1,11 @@
11import { 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
510type TestThread = { readonly id: string } & ThreadSortInput;
611
......@@ -69,3 +74,69 @@ describe("sortThreads", () => {
6974 expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]);
7075 });
7176});
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<
102102 )[0] ?? null
103103 );
104104}
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({
5050 /** Server understands thread.pin / thread.unpin commands. Same
5151 version-skew contract as threadSettlement. */
5252 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),
5356 /** Server understands regenerateTitle on thread.meta.update. Absent on
5457 older servers, so clients hide the action instead of sending it. */
5558 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/orchestration.ts+38
......@@ -379,6 +379,11 @@ export const OrchestrationThread = Schema.Struct({
379379 // thread renders in the pinned block and never classifies into a shelf.
380380 // Optional so payloads from pre-pinning servers still decode.
381381 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)),
382387 // Pending-only state. Optional so older servers remain compatible.
383388 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
384389 deletedAt: Schema.NullOr(IsoDateTime),
......@@ -434,6 +439,7 @@ export const OrchestrationThreadShell = Schema.Struct({
434439 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
435440 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
436441 pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
442 pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
437443 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
438444 session: Schema.NullOr(OrchestrationSession),
439445 latestUserMessageAt: Schema.NullOr(IsoDateTime),
......@@ -702,6 +708,10 @@ const ThreadPinCommand = Schema.Struct({
702708 type: Schema.Literal("thread.pin"),
703709 commandId: CommandId,
704710 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),
705715});
706716
707717const ThreadUnpinCommand = Schema.Struct({
......@@ -710,6 +720,17 @@ const ThreadUnpinCommand = Schema.Struct({
710720 threadId: ThreadId,
711721});
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
713734const ThreadMetaUpdateCommand = Schema.Struct({
714735 type: Schema.Literal("thread.meta.update"),
715736 commandId: CommandId,
......@@ -865,6 +886,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
865886 ThreadUnsnoozeCommand,
866887 ThreadPinCommand,
867888 ThreadUnpinCommand,
889 ThreadPinReorderCommand,
868890 ThreadMetaUpdateCommand,
869891 ThreadRuntimeModeSetCommand,
870892 ThreadInteractionModeSetCommand,
......@@ -892,6 +914,7 @@ export const ClientOrchestrationCommand = Schema.Union([
892914 ThreadUnsnoozeCommand,
893915 ThreadPinCommand,
894916 ThreadUnpinCommand,
917 ThreadPinReorderCommand,
895918 ThreadMetaUpdateCommand,
896919 ThreadRuntimeModeSetCommand,
897920 ThreadInteractionModeSetCommand,
......@@ -1009,6 +1032,7 @@ export const OrchestrationEventType = Schema.Literals([
10091032 "thread.unsnoozed",
10101033 "thread.pinned",
10111034 "thread.unpinned",
1035 "thread.pin-reordered",
10121036 "thread.meta-updated",
10131037 "thread.runtime-mode-set",
10141038 "thread.interaction-mode-set",
......@@ -1120,6 +1144,9 @@ export const ThreadUnsnoozedPayload = Schema.Struct({
11201144export const ThreadPinnedPayload = Schema.Struct({
11211145 threadId: ThreadId,
11221146 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),
11231150 updatedAt: IsoDateTime,
11241151});
11251152
......@@ -1128,6 +1155,12 @@ export const ThreadUnpinnedPayload = Schema.Struct({
11281155 updatedAt: IsoDateTime,
11291156});
11301157
1158export const ThreadPinReorderedPayload = Schema.Struct({
1159 threadId: ThreadId,
1160 orderKey: TrimmedNonEmptyString,
1161 updatedAt: IsoDateTime,
1162});
1163
11311164export const ThreadMetaUpdatedPayload = Schema.Struct({
11321165 threadId: ThreadId,
11331166 title: Schema.optional(TrimmedNonEmptyString),
......@@ -1332,6 +1365,11 @@ export const OrchestrationEvent = Schema.Union([
13321365 type: Schema.Literal("thread.unpinned"),
13331366 payload: ThreadUnpinnedPayload,
13341367 }),
1368 Schema.Struct({
1369 ...EventBaseFields,
1370 type: Schema.Literal("thread.pin-reordered"),
1371 payload: ThreadPinReorderedPayload,
1372 }),
13351373 Schema.Struct({
13361374 ...EventBaseFields,
13371375 type: Schema.Literal("thread.meta-updated"),