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