| author | |
| committer | |
| log | d6aa179ad2afe0424e1410cbafbb119bbd5f7809 |
| tree | 9feb70250c2c48b120dae226ae1edb369c6a33a2 |
| parent | 66a24d6c1a008430eebd855cd5640607d5aef38c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>14 files changed, 193 insertions(+), 8 deletions(-)
apps/mobile/src/components/AppSymbol.tsx+2| ... | @@ -84,6 +84,7 @@ import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease"; | ... | @@ -84,6 +84,7 @@ import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease"; |
| 84 | import IconTool from "@tabler/icons-react-native/IconTool"; | 84 | import IconTool from "@tabler/icons-react-native/IconTool"; |
| 85 | import IconTrash from "@tabler/icons-react-native/IconTrash"; | 85 | import IconTrash from "@tabler/icons-react-native/IconTrash"; |
| 86 | import IconTypography from "@tabler/icons-react-native/IconTypography"; | 86 | import IconTypography from "@tabler/icons-react-native/IconTypography"; |
| 87 | import IconUpload from "@tabler/icons-react-native/IconUpload"; | ||
| 87 | import IconUserCircle from "@tabler/icons-react-native/IconUserCircle"; | 88 | import IconUserCircle from "@tabler/icons-react-native/IconUserCircle"; |
| 88 | import IconWifiOff from "@tabler/icons-react-native/IconWifiOff"; | 89 | import IconWifiOff from "@tabler/icons-react-native/IconWifiOff"; |
| 89 | import IconWorld from "@tabler/icons-react-native/IconWorld"; | 90 | import IconWorld from "@tabler/icons-react-native/IconWorld"; |
| ... | @@ -173,6 +174,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { | ... | @@ -173,6 +174,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 173 | "textformat.size": IconTypography, | 174 | "textformat.size": IconTypography, |
| 174 | "textformat.size.larger": IconTextIncrease, | 175 | "textformat.size.larger": IconTextIncrease, |
| 175 | "textformat.size.smaller": IconTextDecrease, | 176 | "textformat.size.smaller": IconTextDecrease, |
| 177 | "tray.and.arrow.up": IconUpload, | ||
| 176 | trash: IconTrash, | 178 | trash: IconTrash, |
| 177 | "wifi.slash": IconWifiOff, | 179 | "wifi.slash": IconWifiOff, |
| 178 | xmark: IconX, | 180 | xmark: IconX, |
apps/mobile/src/features/home/HomeScreen.tsx+12| ... | @@ -38,6 +38,7 @@ import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | ... | @@ -38,6 +38,7 @@ import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 38 | import { usePendingThreadOrder } from "../../state/thread-order"; | 38 | import { usePendingThreadOrder } from "../../state/thread-order"; |
| 39 | import { environmentServerConfigsAtom } from "../../state/server"; | 39 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 40 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 40 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 41 | import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; | ||
| 41 | import { | 42 | import { |
| 42 | PendingTaskListRow, | 43 | PendingTaskListRow, |
| 43 | ThreadListGroupHeader, | 44 | ThreadListGroupHeader, |
| ... | @@ -212,6 +213,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -212,6 +213,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 212 | >(() => new Map()); | 213 | >(() => new Map()); |
| 213 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | 214 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 214 | const threadListV2Enabled = useThreadListV2Enabled(); | 215 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 216 | const queuedThreadKeys = useQueuedThreadKeys(); | ||
| 215 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | 217 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 216 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); | 218 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 217 | const listRef = useRef<LegendListRef | null>(null); | 219 | const listRef = useRef<LegendListRef | null>(null); |
| ... | @@ -384,6 +386,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -384,6 +386,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 384 | projects: scopedProjects, | 386 | projects: scopedProjects, |
| 385 | threads: scopedThreads, | 387 | threads: scopedThreads, |
| 386 | pendingTasks: scopedPendingTasks, | 388 | pendingTasks: scopedPendingTasks, |
| 389 | queuedThreadKeys, | ||
| 387 | environmentId: props.selectedEnvironmentId, | 390 | environmentId: props.selectedEnvironmentId, |
| 388 | searchQuery: props.searchQuery, | 391 | searchQuery: props.searchQuery, |
| 389 | matchedThreadKeys, | 392 | matchedThreadKeys, |
| ... | @@ -393,6 +396,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -393,6 +396,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 393 | }), | 396 | }), |
| 394 | [ | 397 | [ |
| 395 | threadListV2Enabled, | 398 | threadListV2Enabled, |
| 399 | queuedThreadKeys, | ||
| 396 | props.projectGroupingMode, | 400 | props.projectGroupingMode, |
| 397 | props.projectSortOrder, | 401 | props.projectSortOrder, |
| 398 | props.searchQuery, | 402 | props.searchQuery, |
| ... | @@ -655,6 +659,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -655,6 +659,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 655 | now: new Date().toISOString(), | 659 | now: new Date().toISOString(), |
| 656 | settlementEnvironmentIds, | 660 | settlementEnvironmentIds, |
| 657 | snoozeEnvironmentIds, | 661 | snoozeEnvironmentIds, |
| 662 | queuedThreadKeys, | ||
| 658 | }), | 663 | }), |
| 659 | }); | 664 | }); |
| 660 | return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; | 665 | return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; |
| ... | @@ -662,6 +667,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -662,6 +667,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 662 | serverConfigs, | 667 | serverConfigs, |
| 663 | props.threads, | 668 | props.threads, |
| 664 | pendingOrder, | 669 | pendingOrder, |
| 670 | queuedThreadKeys, | ||
| 665 | settlementEnvironmentIds, | 671 | settlementEnvironmentIds, |
| 666 | snoozeEnvironmentIds, | 672 | snoozeEnvironmentIds, |
| 667 | nowMinute, | 673 | nowMinute, |
| ... | @@ -689,6 +695,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -689,6 +695,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 689 | matchedThreadKeys, | 695 | matchedThreadKeys, |
| 690 | settlementEnvironmentIds, | 696 | settlementEnvironmentIds, |
| 691 | snoozeEnvironmentIds, | 697 | snoozeEnvironmentIds, |
| 698 | queuedThreadKeys, | ||
| 692 | settledLimit: settledVisibleCount, | 699 | settledLimit: settledVisibleCount, |
| 693 | now: new Date().toISOString(), | 700 | now: new Date().toISOString(), |
| 694 | snoozedShelfExpanded, | 701 | snoozedShelfExpanded, |
| ... | @@ -697,6 +704,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -697,6 +704,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 697 | }); | 704 | }); |
| 698 | }, [ | 705 | }, [ |
| 699 | pendingOrder, | 706 | pendingOrder, |
| 707 | queuedThreadKeys, | ||
| 700 | nowMinute, | 708 | nowMinute, |
| 701 | snoozeWakeTick, | 709 | snoozeWakeTick, |
| 702 | snoozedShelfExpanded, | 710 | snoozedShelfExpanded, |
| ... | @@ -818,6 +826,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -818,6 +826,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 818 | <ThreadListV2Row | 826 | <ThreadListV2Row |
| 819 | thread={thread} | 827 | thread={thread} |
| 820 | variant={item.item.variant} | 828 | variant={item.item.variant} |
| 829 | hasQueuedMessages={queuedThreadKeys.has(movedId)} | ||
| 821 | snoozed={item.item.snoozed} | 830 | snoozed={item.item.snoozed} |
| 822 | pinned={item.item.pinned} | 831 | pinned={item.item.pinned} |
| 823 | snoozePresetMinute={nowMinute} | 832 | snoozePresetMinute={nowMinute} |
| ... | @@ -883,6 +892,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -883,6 +892,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 883 | activeReorderEnvironmentIds, | 892 | activeReorderEnvironmentIds, |
| 884 | threadMovePlanners, | 893 | threadMovePlanners, |
| 885 | pendingOrder, | 894 | pendingOrder, |
| 895 | queuedThreadKeys, | ||
| 886 | handleMoveThread, | 896 | handleMoveThread, |
| 887 | handlePinThread, | 897 | handlePinThread, |
| 888 | handleRegenerateThreadTitle, | 898 | handleRegenerateThreadTitle, |
| ... | @@ -993,6 +1003,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -993,6 +1003,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 993 | <ThreadListRow | 1003 | <ThreadListRow |
| 994 | variant="compact" | 1004 | variant="compact" |
| 995 | thread={thread} | 1005 | thread={thread} |
| 1006 | hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)} | ||
| 996 | environmentLabel={ | 1007 | environmentLabel={ |
| 997 | props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null | 1008 | props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null |
| 998 | } | 1009 | } |
| ... | @@ -1032,6 +1043,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -1032,6 +1043,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1032 | handleSwipeableWillOpen, | 1043 | handleSwipeableWillOpen, |
| 1033 | handleRegenerateThreadTitle, | 1044 | handleRegenerateThreadTitle, |
| 1034 | machineByEnvironmentId, | 1045 | machineByEnvironmentId, |
| 1046 | queuedThreadKeys, | ||
| 1035 | props.onArchiveThread, | 1047 | props.onArchiveThread, |
| 1036 | props.onDeletePendingTask, | 1048 | props.onDeletePendingTask, |
| 1037 | props.onDeleteThread, | 1049 | props.onDeleteThread, |
apps/mobile/src/features/home/homeThreadList.test.ts+37| ... | @@ -615,6 +615,43 @@ describe("buildHomeThreadGroups", () => { | ... | @@ -615,6 +615,43 @@ describe("buildHomeThreadGroups", () => { |
| 615 | expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]); | 615 | expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]); |
| 616 | }); | 616 | }); |
| 617 | 617 | ||
| 618 | it("keeps an old thread in the default view while a message waits in its outbox", () => { | ||
| 619 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 620 | const project = makeProject({ | ||
| 621 | environmentId, | ||
| 622 | id: ProjectId.make("project-1"), | ||
| 623 | title: "T3 Code", | ||
| 624 | }); | ||
| 625 | const threads = [ | ||
| 626 | makeThread({ | ||
| 627 | environmentId, | ||
| 628 | id: ThreadId.make("recent"), | ||
| 629 | projectId: project.id, | ||
| 630 | title: "Today", | ||
| 631 | updatedAt: "2026-06-28T00:00:00.000Z", | ||
| 632 | }), | ||
| 633 | makeThread({ | ||
| 634 | environmentId, | ||
| 635 | id: ThreadId.make("old-queued"), | ||
| 636 | projectId: project.id, | ||
| 637 | title: "Two weeks ago, follow-up queued offline", | ||
| 638 | updatedAt: "2026-06-14T00:00:00.000Z", | ||
| 639 | }), | ||
| 640 | makeThread({ | ||
| 641 | environmentId, | ||
| 642 | id: ThreadId.make("old"), | ||
| 643 | projectId: project.id, | ||
| 644 | title: "Two weeks ago", | ||
| 645 | updatedAt: "2026-06-13T00:00:00.000Z", | ||
| 646 | }), | ||
| 647 | ]; | ||
| 648 | |||
| 649 | const group = buildGroups([project], threads, { | ||
| 650 | queuedThreadKeys: new Set([`${environmentId}:old-queued`]), | ||
| 651 | })[0]; | ||
| 652 | expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent", "old-queued"]); | ||
| 653 | }); | ||
| 654 | |||
| 618 | it("falls back to the most recent 3 threads when none are within 5 days", () => { | 655 | it("falls back to the most recent 3 threads when none are within 5 days", () => { |
| 619 | const environmentId = EnvironmentId.make("environment-1"); | 656 | const environmentId = EnvironmentId.make("environment-1"); |
| 620 | const project = makeProject({ | 657 | const project = makeProject({ |
apps/mobile/src/features/home/homeThreadList.ts+10-3| ... | @@ -24,7 +24,7 @@ import * as Arr from "effect/Array"; | ... | @@ -24,7 +24,7 @@ import * as Arr from "effect/Array"; |
| 24 | import * as Option from "effect/Option"; | 24 | import * as Option from "effect/Option"; |
| 25 | import * as Order from "effect/Order"; | 25 | import * as Order from "effect/Order"; |
| 26 | 26 | ||
| 27 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 27 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; |
| 28 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 28 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 29 | 29 | ||
| 30 | export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">; | 30 | export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">; |
| ... | @@ -190,10 +190,15 @@ function selectRecentThreads( | ... | @@ -190,10 +190,15 @@ function selectRecentThreads( |
| 190 | sortedThreads: ReadonlyArray<EnvironmentThreadShell>, | 190 | sortedThreads: ReadonlyArray<EnvironmentThreadShell>, |
| 191 | threadSortOrder: SidebarThreadSortOrder, | 191 | threadSortOrder: SidebarThreadSortOrder, |
| 192 | now: number, | 192 | now: number, |
| 193 | queuedThreadKeys: ReadonlySet<string> | undefined, | ||
| 193 | ): ReadonlyArray<EnvironmentThreadShell> { | 194 | ): ReadonlyArray<EnvironmentThreadShell> { |
| 194 | const cutoff = now - RECENT_THREAD_WINDOW_MS; | 195 | const cutoff = now - RECENT_THREAD_WINDOW_MS; |
| 196 | // A thread with a message waiting in the outbox has work the user is | ||
| 197 | // waiting on, however old its last activity; it never trims away. | ||
| 195 | const recent = sortedThreads.filter( | 198 | const recent = sortedThreads.filter( |
| 196 | (thread) => getThreadSortTimestamp(thread, threadSortOrder) >= cutoff, | 199 | (thread) => |
| 200 | getThreadSortTimestamp(thread, threadSortOrder) >= cutoff || | ||
| 201 | queuedThreadKeys?.has(scopedThreadKey(thread.environmentId, thread.id)) === true, | ||
| 197 | ); | 202 | ); |
| 198 | return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT); | 203 | return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT); |
| 199 | } | 204 | } |
| ... | @@ -202,6 +207,8 @@ export function buildHomeThreadGroups(input: { | ... | @@ -202,6 +207,8 @@ export function buildHomeThreadGroups(input: { |
| 202 | readonly projects: ReadonlyArray<EnvironmentProject>; | 207 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 203 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | 208 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 204 | readonly pendingTasks?: ReadonlyArray<PendingNewTask>; | 209 | readonly pendingTasks?: ReadonlyArray<PendingNewTask>; |
| 210 | /** Thread keys with a message waiting in the outbox; kept in the default view. */ | ||
| 211 | readonly queuedThreadKeys?: ReadonlySet<string>; | ||
| 205 | readonly environmentId: EnvironmentId | null; | 212 | readonly environmentId: EnvironmentId | null; |
| 206 | readonly searchQuery: string; | 213 | readonly searchQuery: string; |
| 207 | readonly matchedThreadKeys?: ReadonlySet<string>; | 214 | readonly matchedThreadKeys?: ReadonlySet<string>; |
| ... | @@ -326,7 +333,7 @@ export function buildHomeThreadGroups(input: { | ... | @@ -326,7 +333,7 @@ export function buildHomeThreadGroups(input: { |
| 326 | // only trims the default (no-query) view. | 333 | // only trims the default (no-query) view. |
| 327 | const recentThreads = | 334 | const recentThreads = |
| 328 | query.length === 0 | 335 | query.length === 0 |
| 329 | ? selectRecentThreads(sortedThreads, input.threadSortOrder, now) | 336 | ? selectRecentThreads(sortedThreads, input.threadSortOrder, now, input.queuedThreadKeys) |
| 330 | : sortedThreads; | 337 | : sortedThreads; |
| 331 | 338 | ||
| 332 | // A stale project id still resolves to the canonical member with the same | 339 | // A stale project id still resolves to the canonical member with the same |
apps/mobile/src/features/home/useThreadListActions.ts+2| ... | @@ -12,6 +12,7 @@ import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; | ... | @@ -12,6 +12,7 @@ import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; |
| 12 | import { appAtomRegistry } from "../../state/atom-registry"; | 12 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 13 | import { environmentServerConfigsAtom } from "../../state/server"; | 13 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 14 | import { environmentThreadShells, threadEnvironment } from "../../state/threads"; | 14 | import { environmentThreadShells, threadEnvironment } from "../../state/threads"; |
| 15 | import { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; | ||
| 15 | import { useAtomCommand } from "../../state/use-atom-command"; | 16 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 16 | import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order"; | 17 | import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order"; |
| 17 | import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder"; | 18 | import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder"; |
| ... | @@ -480,6 +481,7 @@ export function useThreadListActions(): { | ... | @@ -480,6 +481,7 @@ export function useThreadListActions(): { |
| 480 | threads: shells, | 481 | threads: shells, |
| 481 | section, | 482 | section, |
| 482 | now: new Date().toISOString(), | 483 | now: new Date().toISOString(), |
| 484 | queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), | ||
| 483 | settlementEnvironmentIds: new Set( | 485 | settlementEnvironmentIds: new Set( |
| 484 | [...configs].flatMap(([id, config]) => | 486 | [...configs].flatMap(([id, config]) => |
| 485 | config.environment.capabilities.threadSettlement === true ? [id] : [], | 487 | config.environment.capabilities.threadSettlement === true ? [id] : [], |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+11| ... | @@ -33,6 +33,7 @@ import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-pref | ... | @@ -33,6 +33,7 @@ import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-pref |
| 33 | import { usePendingThreadOrder } from "../../state/thread-order"; | 33 | import { usePendingThreadOrder } from "../../state/thread-order"; |
| 34 | import { environmentServerConfigsAtom } from "../../state/server"; | 34 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 35 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; | 35 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; |
| 36 | import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; | ||
| 36 | import { useWorkspaceState } from "../../state/workspace"; | 37 | import { useWorkspaceState } from "../../state/workspace"; |
| 37 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 38 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 38 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | 39 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| ... | @@ -165,6 +166,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -165,6 +166,7 @@ function ThreadNavigationSidebarPane( |
| 165 | } = useThreadListActions(); | 166 | } = useThreadListActions(); |
| 166 | const threadListV2Enabled = useThreadListV2Enabled(); | 167 | const threadListV2Enabled = useThreadListV2Enabled(); |
| 167 | const pendingTasks = usePendingNewTasks(); | 168 | const pendingTasks = usePendingNewTasks(); |
| 169 | const queuedThreadKeys = useQueuedThreadKeys(); | ||
| 168 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); | 170 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); |
| 169 | const environments = useMemo( | 171 | const environments = useMemo( |
| 170 | () => | 172 | () => |
| ... | @@ -313,6 +315,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -313,6 +315,7 @@ function ThreadNavigationSidebarPane( |
| 313 | projects: scopedProjects, | 315 | projects: scopedProjects, |
| 314 | threads: scopedThreads, | 316 | threads: scopedThreads, |
| 315 | pendingTasks: scopedPendingTasks, | 317 | pendingTasks: scopedPendingTasks, |
| 318 | queuedThreadKeys, | ||
| 316 | environmentId: options.selectedEnvironmentId, | 319 | environmentId: options.selectedEnvironmentId, |
| 317 | searchQuery: props.searchQuery, | 320 | searchQuery: props.searchQuery, |
| 318 | matchedThreadKeys, | 321 | matchedThreadKeys, |
| ... | @@ -322,6 +325,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -322,6 +325,7 @@ function ThreadNavigationSidebarPane( |
| 322 | }), | 325 | }), |
| 323 | [ | 326 | [ |
| 324 | threadListV2Enabled, | 327 | threadListV2Enabled, |
| 328 | queuedThreadKeys, | ||
| 325 | matchedThreadKeys, | 329 | matchedThreadKeys, |
| 326 | options, | 330 | options, |
| 327 | props.searchQuery, | 331 | props.searchQuery, |
| ... | @@ -489,6 +493,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -489,6 +493,7 @@ function ThreadNavigationSidebarPane( |
| 489 | now: new Date().toISOString(), | 493 | now: new Date().toISOString(), |
| 490 | settlementEnvironmentIds, | 494 | settlementEnvironmentIds, |
| 491 | snoozeEnvironmentIds, | 495 | snoozeEnvironmentIds, |
| 496 | queuedThreadKeys, | ||
| 492 | }), | 497 | }), |
| 493 | }); | 498 | }); |
| 494 | return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; | 499 | return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; |
| ... | @@ -496,6 +501,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -496,6 +501,7 @@ function ThreadNavigationSidebarPane( |
| 496 | serverConfigs, | 501 | serverConfigs, |
| 497 | threads, | 502 | threads, |
| 498 | pendingOrder, | 503 | pendingOrder, |
| 504 | queuedThreadKeys, | ||
| 499 | settlementEnvironmentIds, | 505 | settlementEnvironmentIds, |
| 500 | snoozeEnvironmentIds, | 506 | snoozeEnvironmentIds, |
| 501 | nowMinute, | 507 | nowMinute, |
| ... | @@ -521,6 +527,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -521,6 +527,7 @@ function ThreadNavigationSidebarPane( |
| 521 | matchedThreadKeys, | 527 | matchedThreadKeys, |
| 522 | settlementEnvironmentIds, | 528 | settlementEnvironmentIds, |
| 523 | snoozeEnvironmentIds, | 529 | snoozeEnvironmentIds, |
| 530 | queuedThreadKeys, | ||
| 524 | settledLimit: settledVisibleCount, | 531 | settledLimit: settledVisibleCount, |
| 525 | now: new Date().toISOString(), | 532 | now: new Date().toISOString(), |
| 526 | snoozedShelfExpanded, | 533 | snoozedShelfExpanded, |
| ... | @@ -529,6 +536,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -529,6 +536,7 @@ function ThreadNavigationSidebarPane( |
| 529 | }); | 536 | }); |
| 530 | }, [ | 537 | }, [ |
| 531 | pendingOrder, | 538 | pendingOrder, |
| 539 | queuedThreadKeys, | ||
| 532 | nowMinute, | 540 | nowMinute, |
| 533 | snoozeWakeTick, | 541 | snoozeWakeTick, |
| 534 | snoozedShelfExpanded, | 542 | snoozedShelfExpanded, |
| ... | @@ -884,6 +892,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -884,6 +892,7 @@ function ThreadNavigationSidebarPane( |
| 884 | <ThreadListV2Row | 892 | <ThreadListV2Row |
| 885 | thread={thread} | 893 | thread={thread} |
| 886 | variant={item.item.variant} | 894 | variant={item.item.variant} |
| 895 | hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)} | ||
| 887 | snoozed={item.item.snoozed} | 896 | snoozed={item.item.snoozed} |
| 888 | pinned={item.item.pinned} | 897 | pinned={item.item.pinned} |
| 889 | snoozePresetMinute={nowMinute} | 898 | snoozePresetMinute={nowMinute} |
| ... | @@ -1017,6 +1026,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -1017,6 +1026,7 @@ function ThreadNavigationSidebarPane( |
| 1017 | <ThreadListRow | 1026 | <ThreadListRow |
| 1018 | variant="sidebar" | 1027 | variant="sidebar" |
| 1019 | thread={thread} | 1028 | thread={thread} |
| 1029 | hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)} | ||
| 1020 | environmentLabel={ | 1030 | environmentLabel={ |
| 1021 | savedConnectionsById[thread.environmentId]?.environmentLabel ?? null | 1031 | savedConnectionsById[thread.environmentId]?.environmentLabel ?? null |
| 1022 | } | 1032 | } |
| ... | @@ -1061,6 +1071,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -1061,6 +1071,7 @@ function ThreadNavigationSidebarPane( |
| 1061 | activeReorderEnvironmentIds, | 1071 | activeReorderEnvironmentIds, |
| 1062 | threadMovePlanners, | 1072 | threadMovePlanners, |
| 1063 | pendingOrder, | 1073 | pendingOrder, |
| 1074 | queuedThreadKeys, | ||
| 1064 | confirmDeletePendingTask, | 1075 | confirmDeletePendingTask, |
| 1065 | confirmDeleteThread, | 1076 | confirmDeleteThread, |
| 1066 | handleSelectThread, | 1077 | handleSelectThread, |
apps/mobile/src/features/threads/queued-message-icon.tsx created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 2 | |||
| 3 | /** Outbox state is independent of the agent's status, so both stay visible. */ | ||
| 4 | export function QueuedMessageIcon({ selected = false }: { readonly selected?: boolean }) { | ||
| 5 | return ( | ||
| 6 | <SymbolView | ||
| 7 | name="tray.and.arrow.up" | ||
| 8 | size={12} | ||
| 9 | tintColorClassName={ | ||
| 10 | selected ? "accent-user-bubble-foreground-muted" : "accent-foreground-muted" | ||
| 11 | } | ||
| 12 | type="monochrome" | ||
| 13 | /> | ||
| 14 | ); | ||
| 15 | } | ||
apps/mobile/src/features/threads/thread-list-items.tsx+12-1| ... | @@ -27,6 +27,7 @@ import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-p | ... | @@ -27,6 +27,7 @@ import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-p |
| 27 | import type { HomeGroupDisplayAction } from "../home/homeListItems"; | 27 | import type { HomeGroupDisplayAction } from "../home/homeListItems"; |
| 28 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; | 28 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 29 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; | 29 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; |
| 30 | import { QueuedMessageIcon } from "./queued-message-icon"; | ||
| 30 | import { resolveThreadStatus } from "./threadPresentation"; | 31 | import { resolveThreadStatus } from "./threadPresentation"; |
| 31 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; | 32 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; |
| 32 | 33 | ||
| ... | @@ -440,6 +441,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -440,6 +441,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 440 | readonly thread: EnvironmentThreadShell; | 441 | readonly thread: EnvironmentThreadShell; |
| 441 | readonly environmentLabel: string | null; | 442 | readonly environmentLabel: string | null; |
| 442 | readonly environmentMachine?: EnvironmentMachineKind; | 443 | readonly environmentMachine?: EnvironmentMachineKind; |
| 444 | /** A message for this thread is waiting in the outbox. */ | ||
| 445 | readonly hasQueuedMessages?: boolean; | ||
| 443 | readonly searchMatch?: EnvironmentThreadSearchMatch; | 446 | readonly searchMatch?: EnvironmentThreadSearchMatch; |
| 444 | readonly searchQuery?: string; | 447 | readonly searchQuery?: string; |
| 445 | readonly isLast: boolean; | 448 | readonly isLast: boolean; |
| ... | @@ -480,7 +483,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -480,7 +483,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 480 | const timestamp = relativeTime( | 483 | const timestamp = relativeTime( |
| 481 | thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, | 484 | thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, |
| 482 | ); | 485 | ); |
| 483 | const threadAccessibilityLabel = pr ? `${thread.title}, ${pr.accessibilityLabel}` : thread.title; | 486 | const threadAccessibilityLabel = [ |
| 487 | thread.title, | ||
| 488 | pr?.accessibilityLabel, | ||
| 489 | props.hasQueuedMessages ? "messages queued to send" : null, | ||
| 490 | ] | ||
| 491 | .filter(Boolean) | ||
| 492 | .join(", "); | ||
| 484 | const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string => | 493 | const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string => |
| 485 | Boolean(part), | 494 | Boolean(part), |
| 486 | ); | 495 | ); |
| ... | @@ -611,6 +620,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -611,6 +620,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 611 | {thread.title} | 620 | {thread.title} |
| 612 | </Text> | 621 | </Text> |
| 613 | <View className="flex-row items-center gap-2"> | 622 | <View className="flex-row items-center gap-2"> |
| 623 | {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null} | ||
| 614 | {statusPill} | 624 | {statusPill} |
| 615 | <Text className="text-base tabular-nums text-foreground-tertiary">{timestamp}</Text> | 625 | <Text className="text-base tabular-nums text-foreground-tertiary">{timestamp}</Text> |
| 616 | <SymbolView | 626 | <SymbolView |
| ... | @@ -670,6 +680,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { | ... | @@ -670,6 +680,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 670 | {thread.title} | 680 | {thread.title} |
| 671 | </Text> | 681 | </Text> |
| 672 | <View className="flex-row items-center gap-2"> | 682 | <View className="flex-row items-center gap-2"> |
| 683 | {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null} | ||
| 673 | {statusPill} | 684 | {statusPill} |
| 674 | <Text | 685 | <Text |
| 675 | className={cn( | 686 | className={cn( |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+11-2| ... | @@ -31,6 +31,7 @@ import { | ... | @@ -31,6 +31,7 @@ import { |
| 31 | resolveThreadListV2SwipeActions, | 31 | resolveThreadListV2SwipeActions, |
| 32 | type ThreadListV2Status, | 32 | type ThreadListV2Status, |
| 33 | } from "./threadListV2"; | 33 | } from "./threadListV2"; |
| 34 | import { QueuedMessageIcon } from "./queued-message-icon"; | ||
| 34 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; | 35 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; |
| 35 | 36 | ||
| 36 | /** | 37 | /** |
| ... | @@ -334,6 +335,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props | ... | @@ -334,6 +335,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props |
| 334 | export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | 335 | export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 335 | readonly thread: EnvironmentThreadShell; | 336 | readonly thread: EnvironmentThreadShell; |
| 336 | readonly variant: "card" | "slim"; | 337 | readonly variant: "card" | "slim"; |
| 338 | /** A message for this thread is waiting in the outbox. */ | ||
| 339 | readonly hasQueuedMessages?: boolean; | ||
| 337 | /** Snoozed-shelf row: shows its wake time and offers Wake. */ | 340 | /** Snoozed-shelf row: shows its wake time and offers Wake. */ |
| 338 | readonly snoozed?: boolean; | 341 | readonly snoozed?: boolean; |
| 339 | /** Pinned-block row: shows the pin glyph and offers Unpin. */ | 342 | /** Pinned-block row: shows the pin glyph and offers Unpin. */ |
| ... | @@ -709,6 +712,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -709,6 +712,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 709 | > | 712 | > |
| 710 | {props.projectTitle ?? props.project?.title ?? ""} | 713 | {props.projectTitle ?? props.project?.title ?? ""} |
| 711 | </Text> | 714 | </Text> |
| 715 | {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null} | ||
| 712 | {pinnedRow ? ( | 716 | {pinnedRow ? ( |
| 713 | <SymbolView | 717 | <SymbolView |
| 714 | name="pin" | 718 | name="pin" |
| ... | @@ -830,7 +834,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -830,7 +834,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 830 | variant === "card" ? ( | 834 | variant === "card" ? ( |
| 831 | <Pressable | 835 | <Pressable |
| 832 | accessibilityHint={swipeAccessibilityHint} | 836 | accessibilityHint={swipeAccessibilityHint} |
| 833 | accessibilityLabel={thread.title} | 837 | accessibilityLabel={ |
| 838 | props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title | ||
| 839 | } | ||
| 834 | accessibilityRole="button" | 840 | accessibilityRole="button" |
| 835 | accessibilityState={{ selected }} | 841 | accessibilityState={{ selected }} |
| 836 | onPress={() => { | 842 | onPress={() => { |
| ... | @@ -870,7 +876,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -870,7 +876,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 870 | ) : ( | 876 | ) : ( |
| 871 | <Pressable | 877 | <Pressable |
| 872 | accessibilityHint={swipeAccessibilityHint} | 878 | accessibilityHint={swipeAccessibilityHint} |
| 873 | accessibilityLabel={thread.title} | 879 | accessibilityLabel={ |
| 880 | props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title | ||
| 881 | } | ||
| 874 | accessibilityRole="button" | 882 | accessibilityRole="button" |
| 875 | accessibilityState={{ selected }} | 883 | accessibilityState={{ selected }} |
| 876 | className={sidebarPane ? undefined : "bg-screen"} | 884 | className={sidebarPane ? undefined : "bg-screen"} |
| ... | @@ -927,6 +935,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -927,6 +935,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 927 | /> | 935 | /> |
| 928 | ) : null} | 936 | ) : null} |
| 929 | </View> | 937 | </View> |
| 938 | {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null} | ||
| 930 | <Text | 939 | <Text |
| 931 | className={cn( | 940 | className={cn( |
| 932 | "text-sm tabular-nums", | 941 | "text-sm tabular-nums", |
apps/mobile/src/features/threads/threadListV2.test.ts+42| ... | @@ -166,6 +166,48 @@ describe("resolveThreadListV2Status", () => { | ... | @@ -166,6 +166,48 @@ describe("resolveThreadListV2Status", () => { |
| 166 | }); | 166 | }); |
| 167 | }); | 167 | }); |
| 168 | 168 | ||
| 169 | describe("queued messages keep a settled thread active", () => { | ||
| 170 | const threads = [ | ||
| 171 | makeThread({ id: ThreadId.make("active"), title: "Active" }), | ||
| 172 | makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), | ||
| 173 | makeThread({ | ||
| 174 | id: ThreadId.make("settled-queued"), | ||
| 175 | title: "Settled with outbox", | ||
| 176 | settledOverride: "settled", | ||
| 177 | }), | ||
| 178 | ]; | ||
| 179 | const queuedThreadKeys = new Set([`${environmentId}:settled-queued`]); | ||
| 180 | |||
| 181 | it("lists the thread in the active block instead of the settled shelf", () => { | ||
| 182 | const layout = buildThreadListV2Items({ | ||
| 183 | threads, | ||
| 184 | environmentId: null, | ||
| 185 | searchQuery: "", | ||
| 186 | now: NOW, | ||
| 187 | queuedThreadKeys, | ||
| 188 | }); | ||
| 189 | expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ | ||
| 190 | ["active", "card"], | ||
| 191 | ["settled-queued", "card"], | ||
| 192 | ["settled", "slim"], | ||
| 193 | ]); | ||
| 194 | expect(layout.settledCount).toBe(1); | ||
| 195 | }); | ||
| 196 | |||
| 197 | it("includes it in the reorderable active section", () => { | ||
| 198 | expect( | ||
| 199 | getThreadListV2OrderedSection({ threads, section: "active", now: NOW, queuedThreadKeys }).map( | ||
| 200 | (thread) => thread.id, | ||
| 201 | ), | ||
| 202 | ).toEqual(["active", "settled-queued"]); | ||
| 203 | expect( | ||
| 204 | getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map( | ||
| 205 | (thread) => thread.id, | ||
| 206 | ), | ||
| 207 | ).toEqual(["active"]); | ||
| 208 | }); | ||
| 209 | }); | ||
| 210 | |||
| 169 | describe("resolveThreadListV2SwipeActions", () => { | 211 | describe("resolveThreadListV2SwipeActions", () => { |
| 170 | it("offers settle and snooze for an active snoozable thread", () => { | 212 | it("offers settle and snooze for an active snoozable thread", () => { |
| 171 | expect( | 213 | expect( |
apps/mobile/src/features/threads/threadListV2.ts+10-2| ... | @@ -178,12 +178,14 @@ export function getThreadListV2OrderedSection(input: { | ... | @@ -178,12 +178,14 @@ export function getThreadListV2OrderedSection(input: { |
| 178 | readonly now: string; | 178 | readonly now: string; |
| 179 | readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>; | 179 | readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>; |
| 180 | readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; | 180 | readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; |
| 181 | readonly queuedThreadKeys?: ReadonlySet<string>; | ||
| 181 | }): EnvironmentThreadShell[] { | 182 | }): EnvironmentThreadShell[] { |
| 182 | const threads = input.threads.filter((thread) => { | 183 | const threads = input.threads.filter((thread) => { |
| 183 | if (thread.archivedAt !== null) return false; | 184 | if (thread.archivedAt !== null) return false; |
| 184 | if ( | 185 | if ( |
| 185 | (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && | 186 | (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && |
| 186 | thread.settledOverride === "settled" | 187 | thread.settledOverride === "settled" && |
| 188 | input.queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) !== true | ||
| 187 | ) { | 189 | ) { |
| 188 | return false; | 190 | return false; |
| 189 | } | 191 | } |
| ... | @@ -362,6 +364,10 @@ export function buildThreadListV2Items(input: { | ... | @@ -362,6 +364,10 @@ export function buildThreadListV2Items(input: { |
| 362 | /** The selected thread remains visible on an otherwise collapsed shelf so | 364 | /** The selected thread remains visible on an otherwise collapsed shelf so |
| 363 | a split-view detail can never lose its navigation row. */ | 365 | a split-view detail can never lose its navigation row. */ |
| 364 | readonly selectedThreadKey?: string | null; | 366 | readonly selectedThreadKey?: string | null; |
| 367 | /** Thread keys (`environmentId:threadId`) with a message waiting in the | ||
| 368 | outbox. Such a thread has work the user is waiting on, so it stays in | ||
| 369 | the active block even when the server has settled it. */ | ||
| 370 | readonly queuedThreadKeys?: ReadonlySet<string>; | ||
| 365 | }): ThreadListV2Layout { | 371 | }): ThreadListV2Layout { |
| 366 | const now = input.now; | 372 | const now = input.now; |
| 367 | const pending = | 373 | const pending = |
| ... | @@ -417,7 +423,9 @@ export function buildThreadListV2Items(input: { | ... | @@ -417,7 +423,9 @@ export function buildThreadListV2Items(input: { |
| 417 | } | 423 | } |
| 418 | continue; | 424 | continue; |
| 419 | } | 425 | } |
| 420 | if (supportsSettlement && thread.settledOverride === "settled") { | 426 | const hasQueuedMessages = |
| 427 | input.queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) === true; | ||
| 428 | if (supportsSettlement && thread.settledOverride === "settled" && !hasQueuedMessages) { | ||
| 421 | settled.push(thread); | 429 | settled.push(thread); |
| 422 | } else if (thread.pinnedAt != null) { | 430 | } else if (thread.pinnedAt != null) { |
| 423 | pinned.push(thread); | 431 | pinned.push(thread); |
apps/mobile/src/state/thread-order.test.ts+4| ... | @@ -24,6 +24,10 @@ vi.mock("./server", async () => { | ... | @@ -24,6 +24,10 @@ vi.mock("./server", async () => { |
| 24 | const { Atom } = await import("effect/unstable/reactivity"); | 24 | const { Atom } = await import("effect/unstable/reactivity"); |
| 25 | return { environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive) }; | 25 | return { environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive) }; |
| 26 | }); | 26 | }); |
| 27 | vi.mock("./use-thread-outbox", async () => { | ||
| 28 | const { Atom } = await import("effect/unstable/reactivity"); | ||
| 29 | return { queuedThreadKeysAtom: Atom.make(new Set<string>()).pipe(Atom.keepAlive) }; | ||
| 30 | }); | ||
| 27 | 31 | ||
| 28 | // The mocked shell source is writable so tests can deliver canonical upserts. | 32 | // The mocked shell source is writable so tests can deliver canonical upserts. |
| 29 | const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable< | 33 | const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable< |
apps/mobile/src/state/thread-order.ts+3| ... | @@ -10,6 +10,7 @@ import { getThreadListV2OrderedSection } from "../features/threads/threadListV2" | ... | @@ -10,6 +10,7 @@ import { getThreadListV2OrderedSection } from "../features/threads/threadListV2" |
| 10 | import { appAtomRegistry } from "./atom-registry"; | 10 | import { appAtomRegistry } from "./atom-registry"; |
| 11 | import { environmentServerConfigsAtom } from "./server"; | 11 | import { environmentServerConfigsAtom } from "./server"; |
| 12 | import { environmentThreadShells } from "./threads"; | 12 | import { environmentThreadShells } from "./threads"; |
| 13 | import { queuedThreadKeysAtom } from "./use-thread-outbox"; | ||
| 13 | 14 | ||
| 14 | export const pendingThreadOrderAtom = Atom.make<PendingThreadOrder | null>(null).pipe( | 15 | export const pendingThreadOrderAtom = Atom.make<PendingThreadOrder | null>(null).pipe( |
| 15 | Atom.keepAlive, | 16 | Atom.keepAlive, |
| ... | @@ -50,6 +51,7 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { | ... | @@ -50,6 +51,7 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { |
| 50 | threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom), | 51 | threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom), |
| 51 | section: current.section, | 52 | section: current.section, |
| 52 | now: new Date().toISOString(), | 53 | now: new Date().toISOString(), |
| 54 | queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom), | ||
| 53 | settlementEnvironmentIds: new Set( | 55 | settlementEnvironmentIds: new Set( |
| 54 | [...configs].flatMap(([id, config]) => | 56 | [...configs].flatMap(([id, config]) => |
| 55 | config.environment.capabilities.threadSettlement === true ? [id] : [], | 57 | config.environment.capabilities.threadSettlement === true ? [id] : [], |
| ... | @@ -70,6 +72,7 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { | ... | @@ -70,6 +72,7 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { |
| 70 | unsubscribers.push( | 72 | unsubscribers.push( |
| 71 | appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh), | 73 | appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh), |
| 72 | appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh), | 74 | appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh), |
| 75 | appAtomRegistry.subscribe(queuedThreadKeysAtom, refresh), | ||
| 73 | ); | 76 | ); |
| 74 | return { | 77 | return { |
| 75 | isPending: () => { | 78 | isPending: () => { |
apps/mobile/src/state/use-thread-outbox.ts+22| ... | @@ -58,6 +58,28 @@ export function useThreadOutboxMessages() { | ... | @@ -58,6 +58,28 @@ export function useThreadOutboxMessages() { |
| 58 | return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom); | 58 | return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom); |
| 59 | } | 59 | } |
| 60 | 60 | ||
| 61 | /** | ||
| 62 | * Thread keys (`environmentId:threadId`) of existing threads with a message | ||
| 63 | * waiting in the outbox. Creations are excluded: they have no thread row yet | ||
| 64 | * and surface as pending tasks instead. Derived once so list builders and | ||
| 65 | * reorder planners agree on which settled threads are pulled back to active. | ||
| 66 | */ | ||
| 67 | export const queuedThreadKeysAtom = Atom.make((get): ReadonlySet<string> => { | ||
| 68 | const keys = new Set<string>(); | ||
| 69 | for (const [threadKey, queue] of Object.entries( | ||
| 70 | get(threadOutboxManager.queuedMessagesByThreadKeyAtom), | ||
| 71 | )) { | ||
| 72 | if (queue.some((message) => message.creation === undefined)) { | ||
| 73 | keys.add(threadKey); | ||
| 74 | } | ||
| 75 | } | ||
| 76 | return keys; | ||
| 77 | }).pipe(Atom.withLabel("mobile:thread-outbox:queued-thread-keys")); | ||
| 78 | |||
| 79 | export function useQueuedThreadKeys(): ReadonlySet<string> { | ||
| 80 | return useAtomValue(queuedThreadKeysAtom); | ||
| 81 | } | ||
| 82 | |||
| 61 | export function useThreadOutboxShellStatuses() { | 83 | export function useThreadOutboxShellStatuses() { |
| 62 | return useAtomValue(threadOutboxShellStatusesAtom); | 84 | return useAtomValue(threadOutboxShellStatusesAtom); |
| 63 | } | 85 | } |