| author | |
| committer | |
| log | 2a303535305d7d224a750fdee78c66e2a52ab3ab |
| tree | 25ff5b7cee9e24cbb8c747eb2c500bad53bd2eb8 |
| parent | 08463e2c401ce87858aaaebcb70ed86fb002fb5f |
| signature | Signed by PGP key B5690EEEBB952194 |
12 files changed, 1037 insertions(+), 38 deletions(-)
apps/mobile/src/App.tsx+2| ... | @@ -9,6 +9,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context"; | ... | @@ -9,6 +9,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context"; |
| 9 | import { createStaticNavigation } from "@react-navigation/native"; | 9 | import { createStaticNavigation } from "@react-navigation/native"; |
| 10 | 10 | ||
| 11 | import { RegistryContext } from "@effect/atom-react"; | 11 | import { RegistryContext } from "@effect/atom-react"; |
| 12 | import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet"; | ||
| 12 | import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; | 13 | import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; |
| 13 | import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; | 14 | import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; |
| 14 | import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; | 15 | import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; |
| ... | @@ -94,6 +95,7 @@ function AppContent() { | ... | @@ -94,6 +95,7 @@ function AppContent() { |
| 94 | <Navigation linking={appLinking} theme={navigationTheme} /> | 95 | <Navigation linking={appLinking} theme={navigationTheme} /> |
| 95 | </IncomingShareProvider> | 96 | </IncomingShareProvider> |
| 96 | <ConfirmDialogHost /> | 97 | <ConfirmDialogHost /> |
| 98 | <ThreadArrangementHost /> | ||
| 97 | </BlurTargetView> | 99 | </BlurTargetView> |
| 98 | {/* Anchored-menu overlays render here — in-window, so the | 100 | {/* Anchored-menu overlays render here — in-window, so the |
| 99 | keyboard stays up while a dropdown is open. */} | 101 | keyboard stays up while a dropdown is open. */} |
apps/mobile/src/components/AppSymbol.tsx+2| ... | @@ -59,6 +59,7 @@ import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns"; | ... | @@ -59,6 +59,7 @@ import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns"; |
| 59 | import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar"; | 59 | import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar"; |
| 60 | import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight"; | 60 | import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight"; |
| 61 | import IconLetterSpacing from "@tabler/icons-react-native/IconLetterSpacing"; | 61 | import IconLetterSpacing from "@tabler/icons-react-native/IconLetterSpacing"; |
| 62 | import IconMenu2 from "@tabler/icons-react-native/IconMenu2"; | ||
| 62 | import IconLink from "@tabler/icons-react-native/IconLink"; | 63 | import IconLink from "@tabler/icons-react-native/IconLink"; |
| 63 | import IconMessage from "@tabler/icons-react-native/IconMessage"; | 64 | import IconMessage from "@tabler/icons-react-native/IconMessage"; |
| 64 | import IconMinus from "@tabler/icons-react-native/IconMinus"; | 65 | import IconMinus from "@tabler/icons-react-native/IconMinus"; |
| ... | @@ -141,6 +142,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { | ... | @@ -141,6 +142,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 141 | "info.circle": IconInfoCircle, | 142 | "info.circle": IconInfoCircle, |
| 142 | laptopcomputer: IconDeviceLaptop, | 143 | laptopcomputer: IconDeviceLaptop, |
| 143 | link: IconLink, | 144 | link: IconLink, |
| 145 | "line.3.horizontal": IconMenu2, | ||
| 144 | "line.3.horizontal.decrease": IconFilter, | 146 | "line.3.horizontal.decrease": IconFilter, |
| 145 | "line.3.horizontal.decrease.circle": IconFilter, | 147 | "line.3.horizontal.decrease.circle": IconFilter, |
| 146 | "line.3.horizontal.decrease.circle.fill": IconFilterFilled, | 148 | "line.3.horizontal.decrease.circle.fill": IconFilterFilled, |
apps/mobile/src/features/home/HomeScreen.tsx+3-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { ThreadMoveDestination } from "../threads/threadOrder"; | ||
| 1 | import { createThreadMovePlanner } from "../threads/threadOrder"; | 2 | import { createThreadMovePlanner } from "../threads/threadOrder"; |
| 2 | import { | 3 | import { |
| 3 | LegendList, | 4 | LegendList, |
| ... | @@ -122,7 +123,7 @@ interface HomeScreenProps { | ... | @@ -122,7 +123,7 @@ interface HomeScreenProps { |
| 122 | readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | 123 | readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 123 | readonly onMoveThread: ( | 124 | readonly onMoveThread: ( |
| 124 | thread: EnvironmentThreadShell, | 125 | thread: EnvironmentThreadShell, |
| 125 | direction: "up" | "down", | 126 | direction: ThreadMoveDestination, |
| 126 | ) => Promise<boolean>; | 127 | ) => Promise<boolean>; |
| 127 | readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>; | 128 | readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 128 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; | 129 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; |
| ... | @@ -519,7 +520,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -519,7 +520,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 519 | [props.onPinThread], | 520 | [props.onPinThread], |
| 520 | ); | 521 | ); |
| 521 | const handleMoveThread = useCallback( | 522 | const handleMoveThread = useCallback( |
| 522 | (thread: EnvironmentThreadShell, direction: "up" | "down") => { | 523 | (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { |
| 523 | void props.onMoveThread(thread, direction); | 524 | void props.onMoveThread(thread, direction); |
| 524 | }, | 525 | }, |
| 525 | [props.onMoveThread], | 526 | [props.onMoveThread], |
apps/mobile/src/features/home/useThreadListActions.ts+102-22| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { ThreadMoveDestination } from "../threads/threadOrder"; | ||
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; | 3 | import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; |
| 3 | import * as Cause from "effect/Cause"; | 4 | import * as Cause from "effect/Cause"; |
| 4 | import * as Haptics from "expo-haptics"; | 5 | import * as Haptics from "expo-haptics"; |
| 5 | import { useCallback, useRef } from "react"; | 6 | import { useCallback, useRef } from "react"; |
| ... | @@ -15,8 +16,16 @@ import { environmentServerConfigsAtom } from "../../state/server"; | ... | @@ -15,8 +16,16 @@ import { environmentServerConfigsAtom } from "../../state/server"; |
| 15 | import { environmentThreadShells, threadEnvironment } from "../../state/threads"; | 16 | import { environmentThreadShells, threadEnvironment } from "../../state/threads"; |
| 16 | import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; | 17 | import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; |
| 17 | import { useAtomCommand } from "../../state/use-atom-command"; | 18 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 18 | import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order"; | 19 | import { |
| 19 | import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder"; | 20 | beginPendingThreadOrder, |
| 21 | getPendingThreadOrder, | ||
| 22 | threadDropBusyAtom, | ||
| 23 | } from "../../state/thread-order"; | ||
| 24 | import { | ||
| 25 | createPendingThreadOrder, | ||
| 26 | createThreadMovePlanner, | ||
| 27 | threadDropLifecycle, | ||
| 28 | } from "../threads/threadOrder"; | ||
| 20 | import { getThreadListV2OrderedSection } from "../threads/threadListV2"; | 29 | import { getThreadListV2OrderedSection } from "../threads/threadListV2"; |
| 21 | 30 | ||
| 22 | /** Version skew: never send settle/unsettle to a server that predates them | 31 | /** Version skew: never send settle/unsettle to a server that predates them |
| ... | @@ -229,7 +238,7 @@ export function useThreadListActions(): { | ... | @@ -229,7 +238,7 @@ export function useThreadListActions(): { |
| 229 | readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | 238 | readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 230 | readonly moveThread: ( | 239 | readonly moveThread: ( |
| 231 | thread: EnvironmentThreadShell, | 240 | thread: EnvironmentThreadShell, |
| 232 | direction: "up" | "down", | 241 | direction: ThreadMoveDestination, |
| 233 | ) => Promise<boolean>; | 242 | ) => Promise<boolean>; |
| 234 | readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>; | 243 | readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>; |
| 235 | } { | 244 | } { |
| ... | @@ -474,9 +483,29 @@ export function useThreadListActions(): { | ... | @@ -474,9 +483,29 @@ export function useThreadListActions(): { |
| 474 | reportFailure: false, | 483 | reportFailure: false, |
| 475 | }); | 484 | }); |
| 476 | const moveThread = useCallback( | 485 | const moveThread = useCallback( |
| 477 | async (thread: EnvironmentThreadShell, direction: "up" | "down") => { | 486 | async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { |
| 478 | if (getPendingThreadOrder() !== null) return false; | 487 | if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false; |
| 479 | const section = thread.pinnedAt != null ? "pinned" : "active"; | 488 | const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); |
| 489 | const current = shells.find( | ||
| 490 | (row) => row.id === thread.id && row.environmentId === thread.environmentId, | ||
| 491 | ); | ||
| 492 | if (!current || current.archivedAt !== null) return false; | ||
| 493 | thread = current; | ||
| 494 | const section = | ||
| 495 | typeof direction === "object" && direction.section !== undefined | ||
| 496 | ? direction.section | ||
| 497 | : thread.pinnedAt != null | ||
| 498 | ? "pinned" | ||
| 499 | : "active"; | ||
| 500 | if (section === "settled") { | ||
| 501 | if (!environmentSupportsSettlement(thread.environmentId)) return false; | ||
| 502 | appAtomRegistry.set(threadDropBusyAtom, true); | ||
| 503 | try { | ||
| 504 | return await settleThread(thread); | ||
| 505 | } finally { | ||
| 506 | appAtomRegistry.set(threadDropBusyAtom, false); | ||
| 507 | } | ||
| 508 | } | ||
| 480 | const configs = appAtomRegistry.get(environmentServerConfigsAtom); | 509 | const configs = appAtomRegistry.get(environmentServerConfigsAtom); |
| 481 | const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { | 510 | const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { |
| 482 | const capabilities = configs.get(environmentId)?.environment.capabilities; | 511 | const capabilities = configs.get(environmentId)?.environment.capabilities; |
| ... | @@ -491,7 +520,6 @@ export function useThreadListActions(): { | ... | @@ -491,7 +520,6 @@ export function useThreadListActions(): { |
| 491 | ); | 520 | ); |
| 492 | return false; | 521 | return false; |
| 493 | } | 522 | } |
| 494 | const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); | ||
| 495 | const ordered = getThreadListV2OrderedSection({ | 523 | const ordered = getThreadListV2OrderedSection({ |
| 496 | threads: shells, | 524 | threads: shells, |
| 497 | section, | 525 | section, |
| ... | @@ -515,24 +543,67 @@ export function useThreadListActions(): { | ... | @@ -515,24 +543,67 @@ export function useThreadListActions(): { |
| 515 | reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)), | 543 | reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)), |
| 516 | })(scopedThreadKey(thread.environmentId, thread.id), direction); | 544 | })(scopedThreadKey(thread.environmentId, thread.id), direction); |
| 517 | if (assignments === null) return false; | 545 | if (assignments === null) return false; |
| 546 | const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString()); | ||
| 547 | const crossSection = !ordered.some( | ||
| 548 | (row) => row.id === thread.id && row.environmentId === thread.environmentId, | ||
| 549 | ); | ||
| 550 | if ( | ||
| 551 | crossSection && | ||
| 552 | (((section === "pinned" || thread.pinnedAt != null) && | ||
| 553 | !environmentSupportsPinning(thread.environmentId)) || | ||
| 554 | (thread.settledOverride === "settled" && | ||
| 555 | !environmentSupportsSettlement(thread.environmentId)) || | ||
| 556 | (effectiveSnoozed(thread, { now: new Date().toISOString() }) && | ||
| 557 | !environmentSupportsSnooze(thread.environmentId))) | ||
| 558 | ) | ||
| 559 | return false; | ||
| 518 | const shellByKey = new Map( | 560 | const shellByKey = new Map( |
| 519 | ordered.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), | 561 | shells.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), |
| 520 | ); | 562 | ); |
| 521 | selectionHaptic(); | 563 | selectionHaptic(); |
| 522 | const pending = beginPendingThreadOrder( | 564 | appAtomRegistry.set(threadDropBusyAtom, true); |
| 523 | createPendingThreadOrder({ | 565 | const pending = crossSection |
| 524 | section, | 566 | ? null |
| 525 | ordered, | 567 | : beginPendingThreadOrder( |
| 526 | movedId: scopedThreadKey(thread.environmentId, thread.id), | 568 | createPendingThreadOrder({ |
| 527 | direction, | 569 | section, |
| 528 | assignments, | 570 | ordered, |
| 529 | }), | 571 | movedId: scopedThreadKey(thread.environmentId, thread.id), |
| 530 | ); | 572 | direction, |
| 573 | assignments, | ||
| 574 | }), | ||
| 575 | ); | ||
| 531 | let succeeded = false; | 576 | let succeeded = false; |
| 532 | const reorder = section === "pinned" ? reorderPinnedMutation : reorderActiveMutation; | 577 | const reorder = section === "pinned" ? reorderPinnedMutation : reorderActiveMutation; |
| 533 | try { | 578 | try { |
| 579 | if (crossSection) { | ||
| 580 | if (section === "pinned") { | ||
| 581 | const orderKey = assignments.find( | ||
| 582 | ({ id }) => id === scopedThreadKey(thread.environmentId, thread.id), | ||
| 583 | )?.orderKey; | ||
| 584 | const result = await pinMutation({ | ||
| 585 | environmentId: thread.environmentId, | ||
| 586 | input: { threadId: thread.id, ...(orderKey === undefined ? {} : { orderKey }) }, | ||
| 587 | }); | ||
| 588 | if (result._tag === "Failure") { | ||
| 589 | Alert.alert("Could not pin thread", String(Cause.squash(result.cause))); | ||
| 590 | return false; | ||
| 591 | } | ||
| 592 | } else { | ||
| 593 | if (lifecycle.unpin && !(await unpinThread(thread))) return false; | ||
| 594 | if (lifecycle.unsettle && !(await unsettleThread(thread))) return false; | ||
| 595 | if (lifecycle.unsnooze && !(await unsnoozeThread(thread))) return false; | ||
| 596 | } | ||
| 597 | } | ||
| 534 | for (const assignment of assignments) { | 598 | for (const assignment of assignments) { |
| 535 | if (!pending.isPending()) return false; | 599 | if ( |
| 600 | crossSection && | ||
| 601 | section === "pinned" && | ||
| 602 | thread.pinnedAt == null && | ||
| 603 | assignment.id === scopedThreadKey(thread.environmentId, thread.id) | ||
| 604 | ) | ||
| 605 | continue; | ||
| 606 | if (pending !== null && !pending.isPending()) return false; | ||
| 536 | const target = shellByKey.get(assignment.id); | 607 | const target = shellByKey.get(assignment.id); |
| 537 | if (target === undefined) continue; | 608 | if (target === undefined) continue; |
| 538 | const result = await reorder({ | 609 | const result = await reorder({ |
| ... | @@ -552,13 +623,22 @@ export function useThreadListActions(): { | ... | @@ -552,13 +623,22 @@ export function useThreadListActions(): { |
| 552 | } | 623 | } |
| 553 | } | 624 | } |
| 554 | succeeded = true; | 625 | succeeded = true; |
| 555 | pending.complete(); | 626 | pending?.complete(); |
| 556 | return true; | 627 | return true; |
| 557 | } finally { | 628 | } finally { |
| 558 | if (!succeeded) pending.cancel(); | 629 | if (!succeeded) pending?.cancel(); |
| 630 | appAtomRegistry.set(threadDropBusyAtom, false); | ||
| 559 | } | 631 | } |
| 560 | }, | 632 | }, |
| 561 | [reorderActiveMutation, reorderPinnedMutation], | 633 | [ |
| 634 | settleThread, | ||
| 635 | reorderActiveMutation, | ||
| 636 | reorderPinnedMutation, | ||
| 637 | pinMutation, | ||
| 638 | unpinThread, | ||
| 639 | unsettleThread, | ||
| 640 | unsnoozeThread, | ||
| 641 | ], | ||
| 562 | ); | 642 | ); |
| 563 | 643 | ||
| 564 | const confirmDeleteThread = useConfirmDeleteThread(executeAction); | 644 | const confirmDeleteThread = useConfirmDeleteThread(executeAction); |
apps/mobile/src/features/threads/ThreadArrangementSheet.tsx created+564| ... | @@ -0,0 +1,564 @@ | ||
| 1 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 2 | import { useAtomValue } from "@effect/atom-react"; | ||
| 3 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 4 | import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; | ||
| 5 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | ||
| 6 | import { Animated, FlatList, Modal, Pressable, View } from "react-native"; | ||
| 7 | import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; | ||
| 8 | import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; | ||
| 9 | import { threadDragGapOffset } from "./threadDragGap"; | ||
| 10 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 11 | |||
| 12 | import { AppText as Text } from "../../components/AppText"; | ||
| 13 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 14 | import { scopedThreadKey } from "../../lib/scopedEntities"; | ||
| 15 | import { environmentServerConfigsAtom } from "../../state/server"; | ||
| 16 | import { environmentThreadShells } from "../../state/threads"; | ||
| 17 | import { | ||
| 18 | pendingThreadOrderAtom, | ||
| 19 | threadDropBusyAtom, | ||
| 20 | threadArrangementOpenAtom, | ||
| 21 | } from "../../state/thread-order"; | ||
| 22 | import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; | ||
| 23 | import { useThreadListActions } from "../home/useThreadListActions"; | ||
| 24 | import { | ||
| 25 | createThreadMovePlanner, | ||
| 26 | threadDragAction, | ||
| 27 | type ThreadMoveDestination, | ||
| 28 | } from "./threadOrder"; | ||
| 29 | import { getThreadListV2OrderedSection } from "./threadListV2"; | ||
| 30 | |||
| 31 | const ROW_HEIGHT = 56; | ||
| 32 | const HEADER_HEIGHT = 48; | ||
| 33 | const keyOf = (thread: EnvironmentThreadShell) => scopedThreadKey(thread.environmentId, thread.id); | ||
| 34 | type Section = "pinned" | "active" | "snoozed" | "settled"; | ||
| 35 | type Destination = Exclude<ThreadMoveDestination, string>; | ||
| 36 | type Row = { | ||
| 37 | key: string; | ||
| 38 | section: Section; | ||
| 39 | thread?: EnvironmentThreadShell; | ||
| 40 | offset: number; | ||
| 41 | height: number; | ||
| 42 | }; | ||
| 43 | type Drag = { | ||
| 44 | orderVersion: string; | ||
| 45 | sourceSection: Section; | ||
| 46 | thread: EnvironmentThreadShell; | ||
| 47 | startY: number; | ||
| 48 | translation: number; | ||
| 49 | destination: Destination | null; | ||
| 50 | }; | ||
| 51 | |||
| 52 | function ArrangementRow(props: { | ||
| 53 | height: number; | ||
| 54 | offset: number; | ||
| 55 | lifted: boolean; | ||
| 56 | dragging: boolean; | ||
| 57 | children: ReactNode; | ||
| 58 | }) { | ||
| 59 | const { dragging, offset, lifted } = props; | ||
| 60 | const style = useAnimatedStyle(() => ({ | ||
| 61 | transform: [ | ||
| 62 | { | ||
| 63 | translateY: dragging | ||
| 64 | ? withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System }) | ||
| 65 | : offset, | ||
| 66 | }, | ||
| 67 | ], | ||
| 68 | opacity: lifted ? 0 : 1, | ||
| 69 | })); | ||
| 70 | return ( | ||
| 71 | <Reanimated.View | ||
| 72 | style={[{ height: props.height }, style]} | ||
| 73 | className="flex-row items-center border-b border-border-subtle px-5" | ||
| 74 | > | ||
| 75 | {props.children} | ||
| 76 | </Reanimated.View> | ||
| 77 | ); | ||
| 78 | } | ||
| 79 | |||
| 80 | /** Native pan recognition wins over list scrolling only inside the handle. */ | ||
| 81 | function DragHandle(props: { | ||
| 82 | title: string; | ||
| 83 | disabled: boolean; | ||
| 84 | onStart: () => void; | ||
| 85 | onMove: (translation: number) => void; | ||
| 86 | onEnd: (cancelled: boolean) => void; | ||
| 87 | onStep: (direction: "up" | "down") => void; | ||
| 88 | sectionActions: readonly { name: "pinned" | "active" | "settled"; label: string }[]; | ||
| 89 | onSectionMove: (section: "pinned" | "active" | "settled") => void; | ||
| 90 | canMoveUp: boolean; | ||
| 91 | canMoveDown: boolean; | ||
| 92 | }) { | ||
| 93 | const latest = useRef(props); | ||
| 94 | latest.current = props; | ||
| 95 | const gesture = useMemo( | ||
| 96 | () => | ||
| 97 | Gesture.Pan() | ||
| 98 | .enabled(!props.disabled) | ||
| 99 | .minDistance(0) | ||
| 100 | .shouldCancelWhenOutside(false) | ||
| 101 | .runOnJS(true) | ||
| 102 | .onStart(() => latest.current.onStart()) | ||
| 103 | .onUpdate((event) => latest.current.onMove(event.translationY)) | ||
| 104 | .onEnd((event) => latest.current.onMove(event.translationY)) | ||
| 105 | .onFinalize((_, success) => latest.current.onEnd(!success)), | ||
| 106 | [props.disabled], | ||
| 107 | ); | ||
| 108 | return ( | ||
| 109 | <GestureDetector gesture={gesture}> | ||
| 110 | <View | ||
| 111 | collapsable={false} | ||
| 112 | accessible | ||
| 113 | accessibilityRole="adjustable" | ||
| 114 | accessibilityLabel={`Reorder ${props.title}`} | ||
| 115 | accessibilityHint="Move up and Move down reorder within this section. Other actions move between sections." | ||
| 116 | accessibilityState={{ disabled: props.disabled }} | ||
| 117 | accessibilityActions={[ | ||
| 118 | ...props.sectionActions, | ||
| 119 | ...(props.canMoveUp ? [{ name: "decrement", label: "Move up" }] : []), | ||
| 120 | ...(props.canMoveDown ? [{ name: "increment", label: "Move down" }] : []), | ||
| 121 | ]} | ||
| 122 | onAccessibilityAction={({ nativeEvent }) => { | ||
| 123 | if (props.disabled) return; | ||
| 124 | const sectionAction = props.sectionActions.find( | ||
| 125 | (action) => action.name === nativeEvent.actionName, | ||
| 126 | ); | ||
| 127 | if (sectionAction) props.onSectionMove(sectionAction.name); | ||
| 128 | if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up"); | ||
| 129 | if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down"); | ||
| 130 | }} | ||
| 131 | style={{ | ||
| 132 | width: 48, | ||
| 133 | height: 48, | ||
| 134 | alignItems: "center", | ||
| 135 | justifyContent: "center", | ||
| 136 | opacity: props.disabled ? 0.3 : 1, | ||
| 137 | }} | ||
| 138 | > | ||
| 139 | <SymbolView | ||
| 140 | name="line.3.horizontal" | ||
| 141 | size={22} | ||
| 142 | tintColorClassName="accent-foreground-muted" | ||
| 143 | /> | ||
| 144 | </View> | ||
| 145 | </GestureDetector> | ||
| 146 | ); | ||
| 147 | } | ||
| 148 | |||
| 149 | export function ThreadArrangementSheet(props: { onClose: () => void }) { | ||
| 150 | const insets = useSafeAreaInsets(); | ||
| 151 | const threads = useAtomValue(environmentThreadShells.threadShellsAtom); | ||
| 152 | const configs = useAtomValue(environmentServerConfigsAtom); | ||
| 153 | const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom); | ||
| 154 | const pendingOrder = useAtomValue(pendingThreadOrderAtom); | ||
| 155 | const dropBusy = useAtomValue(threadDropBusyAtom); | ||
| 156 | const { moveThread } = useThreadListActions(); | ||
| 157 | const [now, setNow] = useState(() => new Date().toISOString()); | ||
| 158 | const [expanded, setExpanded] = useState({ snoozed: false, settled: false }); | ||
| 159 | useEffect(() => { | ||
| 160 | const wakeAt = Math.min( | ||
| 161 | ...threads.flatMap((thread) => { | ||
| 162 | const at = Date.parse(thread.snoozedUntil ?? ""); | ||
| 163 | return at > Date.parse(now) ? [at] : []; | ||
| 164 | }), | ||
| 165 | ); | ||
| 166 | if (!Number.isFinite(wakeAt)) return; | ||
| 167 | const timer = setTimeout( | ||
| 168 | () => setNow(new Date().toISOString()), | ||
| 169 | Math.min(Math.max(0, wakeAt - Date.now()) + 1, 2_147_483_647), | ||
| 170 | ); | ||
| 171 | return () => clearTimeout(timer); | ||
| 172 | }, [threads, now]); | ||
| 173 | const sections = useMemo(() => { | ||
| 174 | const shared = { | ||
| 175 | threads, | ||
| 176 | now, | ||
| 177 | queuedThreadKeys, | ||
| 178 | pendingOrder, | ||
| 179 | settlementEnvironmentIds: new Set( | ||
| 180 | [...configs].flatMap(([id, config]) => | ||
| 181 | config.environment.capabilities.threadSettlement ? [id] : [], | ||
| 182 | ), | ||
| 183 | ), | ||
| 184 | snoozeEnvironmentIds: new Set( | ||
| 185 | [...configs].flatMap(([id, config]) => | ||
| 186 | config.environment.capabilities.threadSnooze ? [id] : [], | ||
| 187 | ), | ||
| 188 | ), | ||
| 189 | }; | ||
| 190 | const pinned = getThreadListV2OrderedSection({ ...shared, section: "pinned" }); | ||
| 191 | const active = getThreadListV2OrderedSection({ ...shared, section: "active" }); | ||
| 192 | const visible = new Set([...pinned, ...active].map(keyOf)); | ||
| 193 | const parked = threads.filter( | ||
| 194 | (thread) => thread.archivedAt === null && !visible.has(keyOf(thread)), | ||
| 195 | ); | ||
| 196 | return { | ||
| 197 | pinned, | ||
| 198 | active, | ||
| 199 | snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })), | ||
| 200 | settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })), | ||
| 201 | }; | ||
| 202 | }, [threads, configs, now, queuedThreadKeys, pendingOrder]); | ||
| 203 | const planners = useMemo(() => { | ||
| 204 | const planner = (section: "pinned" | "active") => | ||
| 205 | createThreadMovePlanner({ | ||
| 206 | ordered: sections[section], | ||
| 207 | allThreads: threads, | ||
| 208 | section, | ||
| 209 | reorderableEnvironmentIds: new Set( | ||
| 210 | [...configs].flatMap(([id, config]) => | ||
| 211 | ( | ||
| 212 | section === "pinned" | ||
| 213 | ? config.environment.capabilities.threadPinReorder | ||
| 214 | : config.environment.capabilities.threadActiveReorder | ||
| 215 | ) | ||
| 216 | ? [id] | ||
| 217 | : [], | ||
| 218 | ), | ||
| 219 | ), | ||
| 220 | }); | ||
| 221 | return { pinned: planner("pinned"), active: planner("active") }; | ||
| 222 | }, [sections, threads, configs]); | ||
| 223 | const rows = useMemo(() => { | ||
| 224 | const result: Row[] = []; | ||
| 225 | let offset = 0; | ||
| 226 | for (const section of ["pinned", "active", "snoozed", "settled"] as const) { | ||
| 227 | if (section === "snoozed" && sections[section].length === 0) continue; | ||
| 228 | result.push({ key: section, section, offset, height: HEADER_HEIGHT }); | ||
| 229 | offset += HEADER_HEIGHT; | ||
| 230 | if ((section === "snoozed" || section === "settled") && !expanded[section]) continue; | ||
| 231 | for (const thread of sections[section]) { | ||
| 232 | result.push({ key: keyOf(thread), section, thread, offset, height: ROW_HEIGHT }); | ||
| 233 | offset += ROW_HEIGHT; | ||
| 234 | } | ||
| 235 | } | ||
| 236 | return result; | ||
| 237 | }, [sections, expanded]); | ||
| 238 | const list = useRef<FlatList<Row>>(null); | ||
| 239 | const geometry = useRef({ height: 0, offset: 0 }); | ||
| 240 | const drag = useRef<Drag | null>(null); | ||
| 241 | const frame = useRef<number | null>(null); | ||
| 242 | const [preview, setPreview] = useState<Drag | null>(null); | ||
| 243 | const translateY = useRef(new Animated.Value(0)).current; | ||
| 244 | const latest = useRef({ rows, planners, moveThread }); | ||
| 245 | latest.current = { rows, planners, moveThread }; | ||
| 246 | |||
| 247 | function stop() { | ||
| 248 | if (frame.current !== null) cancelAnimationFrame(frame.current); | ||
| 249 | frame.current = null; | ||
| 250 | drag.current = null; | ||
| 251 | setPreview(null); | ||
| 252 | } | ||
| 253 | const orderVersion = rows | ||
| 254 | .map((row) => `${row.key}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`) | ||
| 255 | .join("|"); | ||
| 256 | useEffect(() => { | ||
| 257 | stop(); | ||
| 258 | }, [orderVersion]); | ||
| 259 | useEffect( | ||
| 260 | () => () => { | ||
| 261 | if (frame.current !== null) cancelAnimationFrame(frame.current); | ||
| 262 | }, | ||
| 263 | [], | ||
| 264 | ); | ||
| 265 | |||
| 266 | function update(translation: number) { | ||
| 267 | const current = drag.current; | ||
| 268 | if (current === null) return; | ||
| 269 | current.translation = translation; | ||
| 270 | const { height, offset } = geometry.current; | ||
| 271 | const y = current.startY + translation; | ||
| 272 | translateY.setValue(Math.max(0, Math.min(height - ROW_HEIGHT, y - ROW_HEIGHT / 2))); | ||
| 273 | const contentY = Math.max(0, y + offset); | ||
| 274 | const target = | ||
| 275 | latest.current.rows.find((row) => contentY < row.offset + row.height) ?? | ||
| 276 | latest.current.rows.at(-1); | ||
| 277 | let destination: Destination | null = null; | ||
| 278 | if ( | ||
| 279 | target && | ||
| 280 | y >= 0 && | ||
| 281 | y <= height && | ||
| 282 | (target.section === "pinned" || target.section === "active" || target.section === "settled") | ||
| 283 | ) { | ||
| 284 | const candidate: Destination = { | ||
| 285 | section: target.section, | ||
| 286 | targetId: target.thread ? target.key : null, | ||
| 287 | placement: | ||
| 288 | !target.thread || contentY < target.offset + target.height / 2 ? "before" : "after", | ||
| 289 | }; | ||
| 290 | if (target.section === "settled") { | ||
| 291 | if ( | ||
| 292 | current.sourceSection !== "settled" && | ||
| 293 | configs.get(current.thread.environmentId)?.environment.capabilities.threadSettlement | ||
| 294 | ) | ||
| 295 | destination = { section: "settled", targetId: null, placement: "before" }; | ||
| 296 | } else if (latest.current.planners[target.section](keyOf(current.thread), candidate) !== null) | ||
| 297 | destination = candidate; | ||
| 298 | } | ||
| 299 | if ( | ||
| 300 | current.destination?.targetId !== destination?.targetId || | ||
| 301 | current.destination?.section !== destination?.section || | ||
| 302 | current.destination?.placement !== destination?.placement | ||
| 303 | ) { | ||
| 304 | current.destination = destination; | ||
| 305 | setPreview({ ...current }); | ||
| 306 | } | ||
| 307 | } | ||
| 308 | function start(row: Row) { | ||
| 309 | if (!row.thread || preview !== null) return; | ||
| 310 | drag.current = { | ||
| 311 | orderVersion, | ||
| 312 | sourceSection: row.section, | ||
| 313 | thread: row.thread, | ||
| 314 | startY: row.offset + ROW_HEIGHT / 2 - geometry.current.offset, | ||
| 315 | translation: 0, | ||
| 316 | destination: null, | ||
| 317 | }; | ||
| 318 | setPreview({ ...drag.current }); | ||
| 319 | update(0); | ||
| 320 | let last = performance.now(); | ||
| 321 | const tick = () => { | ||
| 322 | const current = drag.current; | ||
| 323 | if (!current) return; | ||
| 324 | const timestamp = performance.now(); | ||
| 325 | const dt = Math.min(timestamp - last, 32); | ||
| 326 | last = timestamp; | ||
| 327 | const bounds = geometry.current; | ||
| 328 | const y = current.startY + current.translation; | ||
| 329 | const speed = | ||
| 330 | y < 48 | ||
| 331 | ? -Math.min(1, (48 - y) / 48) | ||
| 332 | : y > bounds.height - 48 | ||
| 333 | ? Math.min(1, (y - bounds.height + 48) / 48) | ||
| 334 | : 0; | ||
| 335 | const tail = latest.current.rows.at(-1); | ||
| 336 | const maximum = Math.max(0, (tail ? tail.offset + tail.height : 0) - bounds.height); | ||
| 337 | const offset = Math.max(0, Math.min(maximum, bounds.offset + speed * dt * 0.5)); | ||
| 338 | if (offset !== bounds.offset) { | ||
| 339 | bounds.offset = offset; | ||
| 340 | list.current?.scrollToOffset({ offset, animated: false }); | ||
| 341 | update(current.translation); | ||
| 342 | } | ||
| 343 | frame.current = requestAnimationFrame(tick); | ||
| 344 | }; | ||
| 345 | frame.current = requestAnimationFrame(tick); | ||
| 346 | } | ||
| 347 | const visiblePreview = preview?.orderVersion === orderVersion ? preview : null; | ||
| 348 | const sourceRow = visiblePreview | ||
| 349 | ? rows.find((row) => row.key === keyOf(visiblePreview.thread)) | ||
| 350 | : undefined; | ||
| 351 | const destination = visiblePreview?.destination; | ||
| 352 | const targetRow = destination | ||
| 353 | ? rows.find( | ||
| 354 | (row) => | ||
| 355 | row.section === destination.section && | ||
| 356 | row.key === (destination.targetId ?? destination.section), | ||
| 357 | ) | ||
| 358 | : undefined; | ||
| 359 | const insertionOffset = targetRow | ||
| 360 | ? targetRow.offset + | ||
| 361 | (!targetRow.thread || destination?.placement === "after" ? targetRow.height : 0) | ||
| 362 | : sourceRow?.offset; | ||
| 363 | return ( | ||
| 364 | <Modal | ||
| 365 | visible | ||
| 366 | animationType="slide" | ||
| 367 | presentationStyle="fullScreen" | ||
| 368 | onRequestClose={props.onClose} | ||
| 369 | > | ||
| 370 | <GestureHandlerRootView style={{ flex: 1 }}> | ||
| 371 | <View | ||
| 372 | className="flex-1 bg-screen" | ||
| 373 | style={{ paddingTop: insets.top, paddingBottom: insets.bottom }} | ||
| 374 | > | ||
| 375 | <View className="flex-row items-center justify-between gap-3 px-5 py-3"> | ||
| 376 | <Text className="flex-1 text-xl font-t3-semibold">Arrange threads</Text> | ||
| 377 | <Pressable | ||
| 378 | accessibilityRole="button" | ||
| 379 | onPress={props.onClose} | ||
| 380 | className="min-h-11 justify-center px-3" | ||
| 381 | > | ||
| 382 | <Text className="text-base text-primary">Done</Text> | ||
| 383 | </Pressable> | ||
| 384 | </View> | ||
| 385 | <Text className="px-5 pb-3 text-sm text-foreground-muted"> | ||
| 386 | Drag to reorder, pin, or settle. Changes save when you drop. | ||
| 387 | </Text> | ||
| 388 | <View | ||
| 389 | onLayout={(event) => { | ||
| 390 | geometry.current.height = event.nativeEvent.layout.height; | ||
| 391 | }} | ||
| 392 | className="flex-1" | ||
| 393 | style={{ overflow: "hidden" }} | ||
| 394 | > | ||
| 395 | <FlatList | ||
| 396 | ref={list} | ||
| 397 | data={rows} | ||
| 398 | keyExtractor={(row) => row.key} | ||
| 399 | scrollEnabled={visiblePreview === null} | ||
| 400 | removeClippedSubviews={false} | ||
| 401 | onScroll={(event) => { | ||
| 402 | geometry.current.offset = event.nativeEvent.contentOffset.y; | ||
| 403 | }} | ||
| 404 | scrollEventThrottle={16} | ||
| 405 | getItemLayout={(_, index) => ({ | ||
| 406 | length: rows[index]!.height, | ||
| 407 | offset: rows[index]!.offset, | ||
| 408 | index, | ||
| 409 | })} | ||
| 410 | renderItem={({ item }) => { | ||
| 411 | const thread = item.thread; | ||
| 412 | const planner = | ||
| 413 | item.section === "pinned" || item.section === "active" | ||
| 414 | ? planners[item.section] | ||
| 415 | : null; | ||
| 416 | const capabilities = | ||
| 417 | thread && configs.get(thread.environmentId)?.environment.capabilities; | ||
| 418 | const sectionActions = thread | ||
| 419 | ? (["pinned", "active", "settled"] as const).flatMap<{ | ||
| 420 | name: "pinned" | "active" | "settled"; | ||
| 421 | label: string; | ||
| 422 | }>((section) => { | ||
| 423 | if (section === item.section) return []; | ||
| 424 | const label = threadDragAction(item.section, section); | ||
| 425 | if (!label) return []; | ||
| 426 | if (section === "settled") | ||
| 427 | return capabilities?.threadSettlement ? [{ name: section, label }] : []; | ||
| 428 | if ( | ||
| 429 | ((section === "pinned" || thread.pinnedAt != null) && | ||
| 430 | !capabilities?.threadPinning) || | ||
| 431 | (section === "active" && | ||
| 432 | item.section === "settled" && | ||
| 433 | !capabilities?.threadSettlement) || | ||
| 434 | (section === "active" && | ||
| 435 | item.section === "snoozed" && | ||
| 436 | !capabilities?.threadSnooze) | ||
| 437 | ) | ||
| 438 | return []; | ||
| 439 | return planners[section](item.key, { | ||
| 440 | section, | ||
| 441 | targetId: null, | ||
| 442 | placement: "before", | ||
| 443 | }) | ||
| 444 | ? [{ name: section, label }] | ||
| 445 | : []; | ||
| 446 | }) | ||
| 447 | : []; | ||
| 448 | return ( | ||
| 449 | <ArrangementRow | ||
| 450 | height={item.height} | ||
| 451 | dragging={visiblePreview !== null} | ||
| 452 | lifted={item.key === sourceRow?.key} | ||
| 453 | offset={ | ||
| 454 | sourceRow && insertionOffset !== undefined | ||
| 455 | ? threadDragGapOffset( | ||
| 456 | item.offset, | ||
| 457 | sourceRow.offset, | ||
| 458 | sourceRow.height, | ||
| 459 | insertionOffset, | ||
| 460 | ) | ||
| 461 | : 0 | ||
| 462 | } | ||
| 463 | > | ||
| 464 | {thread ? ( | ||
| 465 | <> | ||
| 466 | <Text numberOfLines={2} className="flex-1 text-base"> | ||
| 467 | {thread.title} | ||
| 468 | </Text> | ||
| 469 | <DragHandle | ||
| 470 | title={thread.title} | ||
| 471 | disabled={ | ||
| 472 | dropBusy || | ||
| 473 | pendingOrder !== null || | ||
| 474 | !( | ||
| 475 | configs.get(thread.environmentId)?.environment.capabilities | ||
| 476 | .threadPinReorder || | ||
| 477 | configs.get(thread.environmentId)?.environment.capabilities | ||
| 478 | .threadActiveReorder | ||
| 479 | ) | ||
| 480 | } | ||
| 481 | sectionActions={sectionActions} | ||
| 482 | onSectionMove={(section) => { | ||
| 483 | void moveThread(thread, { | ||
| 484 | section, | ||
| 485 | targetId: null, | ||
| 486 | placement: "before", | ||
| 487 | }); | ||
| 488 | }} | ||
| 489 | canMoveUp={planner?.(item.key, "up") != null} | ||
| 490 | canMoveDown={planner?.(item.key, "down") != null} | ||
| 491 | onStep={(direction) => { | ||
| 492 | void moveThread(thread, direction); | ||
| 493 | }} | ||
| 494 | onStart={() => start(item)} | ||
| 495 | onMove={update} | ||
| 496 | onEnd={(cancelled) => { | ||
| 497 | const current = drag.current; | ||
| 498 | if (cancelled || !current?.destination) { | ||
| 499 | stop(); | ||
| 500 | return; | ||
| 501 | } | ||
| 502 | if (frame.current !== null) cancelAnimationFrame(frame.current); | ||
| 503 | frame.current = null; | ||
| 504 | drag.current = null; | ||
| 505 | // Retain the gap until the saved order arrives, avoiding a flash back. | ||
| 506 | void moveThread(current.thread, current.destination).finally(stop); | ||
| 507 | }} | ||
| 508 | /> | ||
| 509 | </> | ||
| 510 | ) : ( | ||
| 511 | <Pressable | ||
| 512 | disabled={item.section === "pinned" || item.section === "active"} | ||
| 513 | className="flex-1 justify-center self-stretch" | ||
| 514 | onPress={() => { | ||
| 515 | const section = item.section; | ||
| 516 | if (section === "snoozed" || section === "settled") | ||
| 517 | setExpanded((value) => ({ ...value, [section]: !value[section] })); | ||
| 518 | }} | ||
| 519 | > | ||
| 520 | <Text className="text-sm font-t3-semibold text-foreground-muted"> | ||
| 521 | {item.section[0]!.toUpperCase() + item.section.slice(1)} ( | ||
| 522 | {sections[item.section].length}) | ||
| 523 | </Text> | ||
| 524 | </Pressable> | ||
| 525 | )} | ||
| 526 | </ArrangementRow> | ||
| 527 | ); | ||
| 528 | }} | ||
| 529 | /> | ||
| 530 | {visiblePreview ? ( | ||
| 531 | <Animated.View | ||
| 532 | pointerEvents="none" | ||
| 533 | className="absolute left-5 right-5 justify-center rounded-xl border border-border bg-screen px-4" | ||
| 534 | style={{ top: 0, height: ROW_HEIGHT, transform: [{ translateY }] }} | ||
| 535 | > | ||
| 536 | <Text | ||
| 537 | numberOfLines={visiblePreview.destination?.section ? 1 : 2} | ||
| 538 | className="text-base font-t3-medium" | ||
| 539 | > | ||
| 540 | {visiblePreview.thread.title} | ||
| 541 | </Text> | ||
| 542 | {visiblePreview.destination?.section ? ( | ||
| 543 | <Text className="text-xs text-foreground-muted"> | ||
| 544 | {threadDragAction( | ||
| 545 | visiblePreview.sourceSection, | ||
| 546 | visiblePreview.destination.section, | ||
| 547 | )} | ||
| 548 | </Text> | ||
| 549 | ) : null} | ||
| 550 | </Animated.View> | ||
| 551 | ) : null} | ||
| 552 | </View> | ||
| 553 | </View> | ||
| 554 | </GestureHandlerRootView> | ||
| 555 | </Modal> | ||
| 556 | ); | ||
| 557 | } | ||
| 558 | |||
| 559 | export function ThreadArrangementHost() { | ||
| 560 | const open = useAtomValue(threadArrangementOpenAtom); | ||
| 561 | return open ? ( | ||
| 562 | <ThreadArrangementSheet onClose={() => appAtomRegistry.set(threadArrangementOpenAtom, false)} /> | ||
| 563 | ) : null; | ||
| 564 | } | ||
apps/mobile/src/features/threads/thread-list-v2-items.tsx+14-4| ... | @@ -1,4 +1,7 @@ | ... | @@ -1,4 +1,7 @@ |
| 1 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 1 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 2 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 3 | import { threadArrangementOpenAtom } from "../../state/thread-order"; | ||
| 4 | import type { ThreadMoveDestination } from "./threadOrder"; | ||
| 2 | import type { | 5 | import type { |
| 3 | EnvironmentProject, | 6 | EnvironmentProject, |
| 4 | EnvironmentThreadShell, | 7 | EnvironmentThreadShell, |
| ... | @@ -395,7 +398,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -395,7 +398,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 395 | readonly titleRegenerationSupported: boolean; | 398 | readonly titleRegenerationSupported: boolean; |
| 396 | /** Server supports reordering this card's section. */ | 399 | /** Server supports reordering this card's section. */ |
| 397 | readonly reorderSupported?: boolean; | 400 | readonly reorderSupported?: boolean; |
| 398 | readonly onMoveThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void; | 401 | readonly onMoveThread?: ( |
| 402 | thread: EnvironmentThreadShell, | ||
| 403 | direction: ThreadMoveDestination, | ||
| 404 | ) => void; | ||
| 399 | /** Position flags for the card's section so the menu disables the move that | 405 | /** Position flags for the card's section so the menu disables the move that |
| 400 | would fall off the end of the list. */ | 406 | would fall off the end of the list. */ |
| 401 | readonly canMoveUp?: boolean; | 407 | readonly canMoveUp?: boolean; |
| ... | @@ -515,8 +521,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -515,8 +521,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 515 | // hides the card until wake with the pin intact.) | 521 | // hides the card until wake with the pin intact.) |
| 516 | const arrangementMenuItems = useMemo<MenuAction[]>( | 522 | const arrangementMenuItems = useMemo<MenuAction[]>( |
| 517 | () => [ | 523 | () => [ |
| 518 | ...(variant === "card" && props.reorderSupported === true | 524 | ...(props.reorderSupported === true |
| 519 | ? [ | 525 | ? [ |
| 526 | { id: "arrange", title: "Arrange threads…", image: "line.3.horizontal" }, | ||
| 520 | { | 527 | { |
| 521 | id: "move-up", | 528 | id: "move-up", |
| 522 | title: "Move up", | 529 | title: "Move up", |
| ... | @@ -583,11 +590,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -583,11 +590,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 583 | const slimMenuActions = useMemo<MenuAction[]>( | 590 | const slimMenuActions = useMemo<MenuAction[]>( |
| 584 | () => [ | 591 | () => [ |
| 585 | SLIM_MENU_ACTIONS[0]!, | 592 | SLIM_MENU_ACTIONS[0]!, |
| 586 | ...(thread.pinnedAt != null ? arrangementMenuItems : []), | 593 | ...arrangementMenuItems.filter( |
| 594 | (action) => action.id !== "move-up" && action.id !== "move-down", | ||
| 595 | ), | ||
| 587 | ...titleRegenerationMenuItems, | 596 | ...titleRegenerationMenuItems, |
| 588 | SLIM_MENU_ACTIONS[1]!, | 597 | SLIM_MENU_ACTIONS[1]!, |
| 589 | ], | 598 | ], |
| 590 | [arrangementMenuItems, thread.pinnedAt, titleRegenerationMenuItems], | 599 | [arrangementMenuItems, titleRegenerationMenuItems], |
| 591 | ); | 600 | ); |
| 592 | const snoozedMenuActions = useMemo<MenuAction[]>( | 601 | const snoozedMenuActions = useMemo<MenuAction[]>( |
| 593 | () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], | 602 | () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], |
| ... | @@ -610,6 +619,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -610,6 +619,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 610 | if (nativeEvent.event === "unsnooze") handleUnsnooze(); | 619 | if (nativeEvent.event === "unsnooze") handleUnsnooze(); |
| 611 | if (nativeEvent.event === "pin") handlePin(); | 620 | if (nativeEvent.event === "pin") handlePin(); |
| 612 | if (nativeEvent.event === "unpin") handleUnpin(); | 621 | if (nativeEvent.event === "unpin") handleUnpin(); |
| 622 | if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true); | ||
| 613 | if (nativeEvent.event === "move-up") handleMoveUp(); | 623 | if (nativeEvent.event === "move-up") handleMoveUp(); |
| 614 | if (nativeEvent.event === "move-down") handleMoveDown(); | 624 | if (nativeEvent.event === "move-down") handleMoveDown(); |
| 615 | if (nativeEvent.event === "archive") handleArchive(); | 625 | if (nativeEvent.event === "archive") handleArchive(); |
apps/mobile/src/features/threads/threadDragGap.test.ts created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { threadDragAction, threadOrderAfterMove } from "./threadOrder"; | ||
| 3 | import { threadDragGapOffset } from "./threadDragGap"; | ||
| 4 | |||
| 5 | describe("live thread insertion gap", () => { | ||
| 6 | // Header, pinned row, Active header, two active rows. Geometry stays fixed for hit testing. | ||
| 7 | const offsets = [0, 48, 120, 168, 240]; | ||
| 8 | const shifts = (source: number, insertion: number) => | ||
| 9 | offsets.map((offset) => threadDragGapOffset(offset, source, 72, insertion)); | ||
| 10 | |||
| 11 | it("moves the Active header and intervening rows up when unpinning", () => { | ||
| 12 | expect(shifts(48, 312)).toEqual([0, 0, -72, -72, -72]); | ||
| 13 | }); | ||
| 14 | it("opens a full gap below the Pinned header when pinning", () => { | ||
| 15 | expect(shifts(240, 48)).toEqual([0, 72, 72, 72, 0]); | ||
| 16 | }); | ||
| 17 | it("leaves the source gap in place for cancellation or its current destination", () => { | ||
| 18 | expect(shifts(168, 168)).toEqual([0, 0, 0, 0, 0]); | ||
| 19 | expect(shifts(168, 240)).toEqual([0, 0, 0, 0, 0]); | ||
| 20 | }); | ||
| 21 | it("moves only crossed rows for an adjacent reorder", () => { | ||
| 22 | expect(shifts(168, 312)).toEqual([0, 0, 0, 0, -72]); | ||
| 23 | expect(shifts(240, 168)).toEqual([0, 0, 0, 72, 0]); | ||
| 24 | }); | ||
| 25 | }); | ||
| 26 | |||
| 27 | describe("drag action labels", () => { | ||
| 28 | it("names the action for each destination instead of its section", () => { | ||
| 29 | expect(threadDragAction("active", "pinned")).toBe("Pin"); | ||
| 30 | expect(threadDragAction("pinned", "active")).toBe("Unpin"); | ||
| 31 | expect(threadDragAction("settled", "active")).toBe("Unsettle"); | ||
| 32 | expect(threadDragAction("snoozed", "active")).toBe("Unsnooze"); | ||
| 33 | expect(threadDragAction("active", "settled")).toBe("Settle"); | ||
| 34 | expect(threadDragAction("pinned", "settled")).toBe("Settle"); | ||
| 35 | expect(threadDragAction("active", "active")).toBe("Reorder"); | ||
| 36 | }); | ||
| 37 | it("does not offer a parked-section reorder or snooze without a wake time", () => { | ||
| 38 | expect(threadDragAction("settled", "settled")).toBeNull(); | ||
| 39 | expect(threadDragAction("active", "snoozed")).toBeNull(); | ||
| 40 | expect( | ||
| 41 | threadOrderAfterMove(["a", "b"], "a", { | ||
| 42 | section: "settled", | ||
| 43 | targetId: null, | ||
| 44 | placement: "before", | ||
| 45 | }), | ||
| 46 | ).toBeNull(); | ||
| 47 | }); | ||
| 48 | }); | ||
apps/mobile/src/features/threads/threadDragGap.ts created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | /** Keep hit testing in the original layout while rows make room for the lifted item. */ | ||
| 2 | export function threadDragGapOffset( | ||
| 3 | rowOffset: number, | ||
| 4 | sourceOffset: number, | ||
| 5 | sourceHeight: number, | ||
| 6 | insertionOffset: number, | ||
| 7 | ): number { | ||
| 8 | if (rowOffset === sourceOffset) return 0; | ||
| 9 | if (insertionOffset <= sourceOffset) { | ||
| 10 | return rowOffset >= insertionOffset && rowOffset < sourceOffset ? sourceHeight : 0; | ||
| 11 | } | ||
| 12 | return rowOffset > sourceOffset && rowOffset < insertionOffset ? -sourceHeight : 0; | ||
| 13 | } | ||
apps/mobile/src/features/threads/threadListV2.test.ts+201| ... | @@ -2,6 +2,8 @@ import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; | ... | @@ -2,6 +2,8 @@ import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; |
| 2 | import { | 2 | import { |
| 3 | createPendingThreadOrder, | 3 | createPendingThreadOrder, |
| 4 | createThreadMovePlanner, | 4 | createThreadMovePlanner, |
| 5 | threadOrderAfterMove, | ||
| 6 | threadDropLifecycle, | ||
| 5 | reconcilePendingThreadOrder, | 7 | reconcilePendingThreadOrder, |
| 6 | type PendingThreadOrder, | 8 | type PendingThreadOrder, |
| 7 | } from "./threadOrder"; | 9 | } from "./threadOrder"; |
| ... | @@ -1276,3 +1278,202 @@ describe("mobile move availability", () => { | ... | @@ -1276,3 +1278,202 @@ describe("mobile move availability", () => { |
| 1276 | expect(assignments![0]!.orderKey < "dd").toBe(true); | 1278 | expect(assignments![0]!.orderKey < "dd").toBe(true); |
| 1277 | }); | 1279 | }); |
| 1278 | }); | 1280 | }); |
| 1281 | |||
| 1282 | describe("thread drag destinations", () => { | ||
| 1283 | it("moves across multiple rows while keeping hidden anchors in place", () => { | ||
| 1284 | expect( | ||
| 1285 | threadOrderAfterMove(["a", "hidden", "b", "c"], "c", { | ||
| 1286 | targetId: "a", | ||
| 1287 | placement: "before", | ||
| 1288 | }), | ||
| 1289 | ).toEqual(["c", "a", "hidden", "b"]); | ||
| 1290 | expect( | ||
| 1291 | threadOrderAfterMove(["a", "hidden", "b", "c"], "a", { | ||
| 1292 | targetId: "b", | ||
| 1293 | placement: "after", | ||
| 1294 | }), | ||
| 1295 | ).toEqual(["hidden", "b", "a", "c"]); | ||
| 1296 | }); | ||
| 1297 | |||
| 1298 | it("rejects missing, self, and unchanged destinations", () => { | ||
| 1299 | for (const targetId of ["missing", "a", "b"]) { | ||
| 1300 | expect( | ||
| 1301 | threadOrderAfterMove(["a", "b", "c"], "a", { | ||
| 1302 | targetId, | ||
| 1303 | placement: "before", | ||
| 1304 | }), | ||
| 1305 | ).toBeNull(); | ||
| 1306 | } | ||
| 1307 | expect(threadOrderAfterMove(["a", "b"], "missing", "down")).toBeNull(); | ||
| 1308 | }); | ||
| 1309 | |||
| 1310 | it.each(["active", "pinned"] as const)( | ||
| 1311 | "persists a dropped %s row and holds its order until confirmed", | ||
| 1312 | (section) => { | ||
| 1313 | const ordered = ["a", "b", "c", "d"].map((id) => | ||
| 1314 | makeThread({ | ||
| 1315 | id: ThreadId.make(id), | ||
| 1316 | title: id, | ||
| 1317 | pinnedAt: section === "pinned" ? NOW : null, | ||
| 1318 | }), | ||
| 1319 | ); | ||
| 1320 | const ids = ordered.map((row) => `${row.environmentId}:${row.id}`); | ||
| 1321 | const direction = { targetId: ids[0]!, placement: "before" as const }; | ||
| 1322 | const assignments = createThreadMovePlanner({ | ||
| 1323 | ordered, | ||
| 1324 | section, | ||
| 1325 | reorderableEnvironmentIds: new Set([environmentId]), | ||
| 1326 | })(ids[3]!, direction)!; | ||
| 1327 | const pending = createPendingThreadOrder({ | ||
| 1328 | section, | ||
| 1329 | ordered, | ||
| 1330 | movedId: ids[3]!, | ||
| 1331 | direction, | ||
| 1332 | assignments, | ||
| 1333 | }); | ||
| 1334 | expect(pending.orderedIds).toEqual([ids[3], ids[0], ids[1], ids[2]]); | ||
| 1335 | const confirmed = ordered.map((row) => ({ | ||
| 1336 | ...row, | ||
| 1337 | [section === "pinned" ? "pinOrderKey" : "activeOrderKey"]: assignments.find( | ||
| 1338 | (a) => a.id === `${row.environmentId}:${row.id}`, | ||
| 1339 | )!.orderKey, | ||
| 1340 | })); | ||
| 1341 | expect( | ||
| 1342 | getThreadListV2OrderedSection({ threads: confirmed, section, now: NOW }).map( | ||
| 1343 | (row) => `${row.environmentId}:${row.id}`, | ||
| 1344 | ), | ||
| 1345 | ).toEqual(pending.orderedIds); | ||
| 1346 | expect( | ||
| 1347 | reconcilePendingThreadOrder({ ...pending, commandsComplete: true }, confirmed), | ||
| 1348 | ).toBeNull(); | ||
| 1349 | }, | ||
| 1350 | ); | ||
| 1351 | |||
| 1352 | it("refuses a drop that would need to rewrite an old server's keyless row", () => { | ||
| 1353 | const old = EnvironmentId.make("old-server"); | ||
| 1354 | const ordered = [environmentId, old, environmentId].map((env, index) => | ||
| 1355 | makeThread({ | ||
| 1356 | id: ThreadId.make(String(index)), | ||
| 1357 | title: String(index), | ||
| 1358 | environmentId: env, | ||
| 1359 | }), | ||
| 1360 | ); | ||
| 1361 | expect( | ||
| 1362 | createThreadMovePlanner({ | ||
| 1363 | ordered, | ||
| 1364 | section: "active", | ||
| 1365 | reorderableEnvironmentIds: new Set([environmentId]), | ||
| 1366 | })(`${environmentId}:2`, { targetId: `${environmentId}:0`, placement: "before" }), | ||
| 1367 | ).toBeNull(); | ||
| 1368 | }); | ||
| 1369 | }); | ||
| 1370 | |||
| 1371 | it("allows a long drop past an old server even when both adjacent moves fail", () => { | ||
| 1372 | const old = EnvironmentId.make("old-server"); | ||
| 1373 | const ordered = [ | ||
| 1374 | makeThread({ id: ThreadId.make("a"), title: "a" }), | ||
| 1375 | makeThread({ id: ThreadId.make("b"), title: "b", environmentId: old }), | ||
| 1376 | makeThread({ id: ThreadId.make("c"), title: "c", activeOrderKey: "h" }), | ||
| 1377 | makeThread({ id: ThreadId.make("d"), title: "d", activeOrderKey: "p" }), | ||
| 1378 | ]; | ||
| 1379 | const planner = createThreadMovePlanner({ | ||
| 1380 | ordered, | ||
| 1381 | section: "active", | ||
| 1382 | reorderableEnvironmentIds: new Set([environmentId]), | ||
| 1383 | }); | ||
| 1384 | const movedId = `${environmentId}:a`; | ||
| 1385 | expect(planner(movedId, "up")).toBeNull(); | ||
| 1386 | expect(planner(movedId, "down")).toBeNull(); | ||
| 1387 | const assignments = planner(movedId, { targetId: `${environmentId}:d`, placement: "after" }); | ||
| 1388 | expect(assignments).toHaveLength(1); | ||
| 1389 | expect(assignments![0]!.id).toBe(movedId); | ||
| 1390 | expect(assignments![0]!.orderKey > "p").toBe(true); | ||
| 1391 | }); | ||
| 1392 | |||
| 1393 | describe("cross-section thread drops", () => { | ||
| 1394 | it.each(["pinned", "active"] as const)("inserts into an empty %s section", (section) => { | ||
| 1395 | const thread = makeThread({ id: ThreadId.make("source"), title: "source" }); | ||
| 1396 | const id = `${thread.environmentId}:${thread.id}`; | ||
| 1397 | const destination = { section, targetId: null, placement: "before" as const }; | ||
| 1398 | expect(threadOrderAfterMove([], id, destination)).toEqual([id]); | ||
| 1399 | const plan = createThreadMovePlanner({ | ||
| 1400 | ordered: [], | ||
| 1401 | allThreads: [thread], | ||
| 1402 | section, | ||
| 1403 | reorderableEnvironmentIds: new Set([environmentId]), | ||
| 1404 | })(id, destination); | ||
| 1405 | expect(plan).toHaveLength(1); | ||
| 1406 | expect(plan![0]!.id).toBe(id); | ||
| 1407 | }); | ||
| 1408 | it("places an incoming row between existing anchors without rewriting them", () => { | ||
| 1409 | const a = makeThread({ id: ThreadId.make("a"), title: "a", pinOrderKey: "h" }); | ||
| 1410 | const b = makeThread({ id: ThreadId.make("b"), title: "b", pinOrderKey: "z" }); | ||
| 1411 | const source = makeThread({ id: ThreadId.make("source"), title: "source" }); | ||
| 1412 | const id = `${environmentId}:source`; | ||
| 1413 | const destination = { | ||
| 1414 | section: "pinned" as const, | ||
| 1415 | targetId: `${environmentId}:b`, | ||
| 1416 | placement: "before" as const, | ||
| 1417 | }; | ||
| 1418 | const plan = createThreadMovePlanner({ | ||
| 1419 | ordered: [a, b], | ||
| 1420 | allThreads: [a, b, source], | ||
| 1421 | section: "pinned", | ||
| 1422 | reorderableEnvironmentIds: new Set([environmentId]), | ||
| 1423 | })(id, destination); | ||
| 1424 | expect(plan).toHaveLength(1); | ||
| 1425 | expect(plan![0]!.orderKey > "h" && plan![0]!.orderKey < "z").toBe(true); | ||
| 1426 | expect( | ||
| 1427 | threadOrderAfterMove([`${environmentId}:a`, `${environmentId}:b`], id, destination), | ||
| 1428 | ).toEqual([`${environmentId}:a`, id, `${environmentId}:b`]); | ||
| 1429 | }); | ||
| 1430 | it("rejects removed targets and unsupported incoming sources", () => { | ||
| 1431 | expect( | ||
| 1432 | threadOrderAfterMove(["a"], "source", { | ||
| 1433 | section: "active", | ||
| 1434 | targetId: "gone", | ||
| 1435 | placement: "before", | ||
| 1436 | }), | ||
| 1437 | ).toBeNull(); | ||
| 1438 | const source = makeThread({ id: ThreadId.make("source"), title: "source" }); | ||
| 1439 | expect( | ||
| 1440 | createThreadMovePlanner({ | ||
| 1441 | ordered: [], | ||
| 1442 | allThreads: [source], | ||
| 1443 | section: "active", | ||
| 1444 | reorderableEnvironmentIds: new Set(), | ||
| 1445 | })(`${environmentId}:source`, { section: "active", targetId: null, placement: "before" }), | ||
| 1446 | ).toBeNull(); | ||
| 1447 | }); | ||
| 1448 | it("clears pinning, settlement and snooze when returning a parked thread to Active", () => { | ||
| 1449 | const thread = makeThread({ | ||
| 1450 | id: ThreadId.make("parked"), | ||
| 1451 | title: "parked", | ||
| 1452 | pinnedAt: NOW, | ||
| 1453 | settledOverride: "settled", | ||
| 1454 | snoozedAt: NOW, | ||
| 1455 | snoozedUntil: "2099-01-01T00:00:00.000Z", | ||
| 1456 | }); | ||
| 1457 | expect(threadDropLifecycle(thread, "active", NOW)).toEqual({ | ||
| 1458 | pin: false, | ||
| 1459 | unpin: true, | ||
| 1460 | unsettle: true, | ||
| 1461 | unsnooze: true, | ||
| 1462 | }); | ||
| 1463 | expect(threadDropLifecycle(thread, "pinned", NOW)).toEqual({ | ||
| 1464 | pin: true, | ||
| 1465 | unpin: false, | ||
| 1466 | unsettle: false, | ||
| 1467 | unsnooze: false, | ||
| 1468 | }); | ||
| 1469 | }); | ||
| 1470 | it("does not send lifecycle commands for an ordinary Active reorder", () => { | ||
| 1471 | expect( | ||
| 1472 | threadDropLifecycle( | ||
| 1473 | makeThread({ id: ThreadId.make("active"), title: "active" }), | ||
| 1474 | "active", | ||
| 1475 | NOW, | ||
| 1476 | ), | ||
| 1477 | ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false }); | ||
| 1478 | }); | ||
| 1479 | }); |
apps/mobile/src/features/threads/threadOrder.ts+78-9| ... | @@ -1,7 +1,47 @@ | ... | @@ -1,7 +1,47 @@ |
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; | 2 | import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; |
| 3 | import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; | ||
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 4 | 5 | ||
| 6 | export type ThreadMoveDestination = | ||
| 7 | | "up" | ||
| 8 | | "down" | ||
| 9 | | { | ||
| 10 | readonly targetId: string | null; | ||
| 11 | readonly section?: "pinned" | "active" | "settled"; | ||
| 12 | readonly placement: "before" | "after"; | ||
| 13 | }; | ||
| 14 | |||
| 15 | /** Resolve against stable row identities, including rows hidden by a filter. */ | ||
| 16 | export function threadOrderAfterMove( | ||
| 17 | orderedIds: readonly string[], | ||
| 18 | movedId: string, | ||
| 19 | destination: ThreadMoveDestination, | ||
| 20 | ): string[] | null { | ||
| 21 | if (typeof destination === "object" && destination.section === "settled") return null; | ||
| 22 | const from = orderedIds.indexOf(movedId); | ||
| 23 | if (from < 0 && (typeof destination === "string" || destination.section === undefined)) | ||
| 24 | return null; | ||
| 25 | const result = orderedIds.filter((id) => id !== movedId); | ||
| 26 | let to: number; | ||
| 27 | if (typeof destination === "string") { | ||
| 28 | to = from + (destination === "up" ? -1 : 1); | ||
| 29 | if (to < 0 || to >= orderedIds.length) return null; | ||
| 30 | } else { | ||
| 31 | if (destination.targetId === null) { | ||
| 32 | if (destination.section === undefined) return null; | ||
| 33 | to = destination.placement === "before" ? 0 : result.length; | ||
| 34 | } else { | ||
| 35 | const target = result.indexOf(destination.targetId); | ||
| 36 | if (target < 0) return null; | ||
| 37 | to = target + (destination.placement === "after" ? 1 : 0); | ||
| 38 | } | ||
| 39 | } | ||
| 40 | if (to === from) return null; | ||
| 41 | result.splice(to, 0, movedId); | ||
| 42 | return result; | ||
| 43 | } | ||
| 44 | |||
| 5 | type OrderRow = Pick< | 45 | type OrderRow = Pick< |
| 6 | EnvironmentThreadShell, | 46 | EnvironmentThreadShell, |
| 7 | | "id" | 47 | | "id" |
| ... | @@ -49,13 +89,15 @@ export function createThreadMovePlanner(input: { | ... | @@ -49,13 +89,15 @@ export function createThreadMovePlanner(input: { |
| 49 | ]), | 89 | ]), |
| 50 | ); | 90 | ); |
| 51 | const writableIds = new Set( | 91 | const writableIds = new Set( |
| 52 | input.ordered | 92 | (input.allThreads ?? input.ordered) |
| 53 | .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId)) | 93 | .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId)) |
| 54 | .map(rowId), | 94 | .map(rowId), |
| 55 | ); | 95 | ); |
| 56 | return (movedId: string, direction: "up" | "down") => { | 96 | return (movedId: string, direction: ThreadMoveDestination) => { |
| 57 | if (!writableIds.has(movedId)) return null; | 97 | if (!writableIds.has(movedId)) return null; |
| 58 | const assignments = planPinnedMove({ orderedIds, keysById, movedId, direction }); | 98 | const nextIds = threadOrderAfterMove(orderedIds, movedId, direction); |
| 99 | if (nextIds === null) return null; | ||
| 100 | const assignments = planPinnedReorder({ orderedIds: nextIds, keysById, movedId }); | ||
| 59 | return assignments === null || | 101 | return assignments === null || |
| 60 | assignments.length === 0 || | 102 | assignments.length === 0 || |
| 61 | assignments.some((assignment) => !writableIds.has(assignment.id)) | 103 | assignments.some((assignment) => !writableIds.has(assignment.id)) |
| ... | @@ -68,13 +110,11 @@ export function createPendingThreadOrder(input: { | ... | @@ -68,13 +110,11 @@ export function createPendingThreadOrder(input: { |
| 68 | readonly section: PendingThreadOrder["section"]; | 110 | readonly section: PendingThreadOrder["section"]; |
| 69 | readonly ordered: readonly OrderRow[]; | 111 | readonly ordered: readonly OrderRow[]; |
| 70 | readonly movedId: string; | 112 | readonly movedId: string; |
| 71 | readonly direction: "up" | "down"; | 113 | readonly direction: ThreadMoveDestination; |
| 72 | readonly assignments: readonly { readonly id: string; readonly orderKey: string }[]; | 114 | readonly assignments: readonly { readonly id: string; readonly orderKey: string }[]; |
| 73 | }): PendingThreadOrder { | 115 | }): PendingThreadOrder { |
| 74 | const orderedIds = input.ordered.map(rowId); | 116 | const orderedIds = threadOrderAfterMove(input.ordered.map(rowId), input.movedId, input.direction); |
| 75 | const from = orderedIds.indexOf(input.movedId); | 117 | if (orderedIds === null) throw new Error("Cannot begin an invalid thread move"); |
| 76 | orderedIds.splice(from, 1); | ||
| 77 | orderedIds.splice(from + (input.direction === "up" ? -1 : 1), 0, input.movedId); | ||
| 78 | return { | 118 | return { |
| 79 | section: input.section, | 119 | section: input.section, |
| 80 | orderedIds, | 120 | orderedIds, |
| ... | @@ -118,3 +158,32 @@ export function applyPendingThreadOrder<T extends OrderRow>( | ... | @@ -118,3 +158,32 @@ export function applyPendingThreadOrder<T extends OrderRow>( |
| 118 | (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity), | 158 | (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity), |
| 119 | ); | 159 | ); |
| 120 | } | 160 | } |
| 161 | |||
| 162 | /** Match desktop re-entry: a pin wakes the thread on the server; Active clears | ||
| 163 | * each underlying parked state before assigning its destination order key. */ | ||
| 164 | export function threadDropLifecycle( | ||
| 165 | thread: EnvironmentThreadShell, | ||
| 166 | section: "pinned" | "active", | ||
| 167 | now: string, | ||
| 168 | ) { | ||
| 169 | if (section === "pinned") return { pin: true, unpin: false, unsettle: false, unsnooze: false }; | ||
| 170 | return { | ||
| 171 | pin: false, | ||
| 172 | unpin: thread.pinnedAt != null, | ||
| 173 | unsettle: thread.settledOverride === "settled", | ||
| 174 | unsnooze: effectiveSnoozed(thread, { now }), | ||
| 175 | }; | ||
| 176 | } | ||
| 177 | |||
| 178 | export type ThreadDragSection = "pinned" | "active" | "snoozed" | "settled"; | ||
| 179 | |||
| 180 | /** The action shown during hover describes the lifecycle change made on drop. */ | ||
| 181 | export function threadDragAction(source: ThreadDragSection, destination: ThreadDragSection) { | ||
| 182 | if (destination === "snoozed") return null; | ||
| 183 | if (destination === "settled") return source === "settled" ? null : "Settle"; | ||
| 184 | if (source === destination) return "Reorder"; | ||
| 185 | if (destination === "pinned") return "Pin"; | ||
| 186 | if (source === "pinned") return "Unpin"; | ||
| 187 | if (source === "settled") return "Unsettle"; | ||
| 188 | return "Unsnooze"; | ||
| 189 | } |
apps/mobile/src/state/thread-order.ts+5| ... | @@ -12,6 +12,11 @@ import { environmentServerConfigsAtom } from "./server"; | ... | @@ -12,6 +12,11 @@ import { environmentServerConfigsAtom } from "./server"; |
| 12 | import { environmentThreadShells } from "./threads"; | 12 | import { environmentThreadShells } from "./threads"; |
| 13 | import { queuedThreadKeysAtom } from "./use-thread-outbox"; | 13 | import { queuedThreadKeysAtom } from "./use-thread-outbox"; |
| 14 | 14 | ||
| 15 | // Covers lifecycle commands before a cross-section move can acquire an order hold. | ||
| 16 | export const threadArrangementOpenAtom = Atom.make(false).pipe(Atom.keepAlive); | ||
| 17 | |||
| 18 | export const threadDropBusyAtom = Atom.make(false).pipe(Atom.keepAlive); | ||
| 19 | |||
| 15 | export const pendingThreadOrderAtom = Atom.make<PendingThreadOrder | null>(null).pipe( | 20 | export const pendingThreadOrderAtom = Atom.make<PendingThreadOrder | null>(null).pipe( |
| 16 | Atom.keepAlive, | 21 | Atom.keepAlive, |
| 17 | ); | 22 | ); |
docs/user/thread-sidebar.md+5-1| ... | @@ -49,7 +49,11 @@ identify empty sections and a collapsed settled shelf. | ... | @@ -49,7 +49,11 @@ identify empty sections and a collapsed settled shelf. |
| 49 | 49 | ||
| 50 | Drag within the pinned or active section to change its order. Other rows slide aside to show the | 50 | Drag within the pinned or active section to change its order. Other rows slide aside to show the |
| 51 | spot where the thread will land. Drops into either section keep the position you choose. On | 51 | spot where the thread will land. Drops into either section keep the position you choose. On |
| 52 | mobile, open a pinned or active thread's menu and choose **Move up** or **Move down**. The server | 52 | mobile, open a thread's menu and choose **Arrange threads**. Drag a handle within or between |
| 53 | **Pinned** and **Active** to reorder, pin, or unpin. Drop onto the **Settled** divider to | ||
| 54 | settle a thread. The dragged card shows the action before you release it. Expand **Snoozed** | ||
| 55 | or **Settled** to drag a parked thread back into either live section. Each drop saves; **Done** returns to the thread list. | ||
| 56 | **Move up** and **Move down** are also available in the thread menu. The server | ||
| 53 | saves the order, so it survives a refresh and appears on your other connected devices. | 57 | saves the order, so it survives a refresh and appears on your other connected devices. |
| 54 | 58 | ||
| 55 | On web and desktop, the list also animates section changes made with thread actions such as | 59 | On web and desktop, the list also animates section changes made with thread actions such as |