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