authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 16:40:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 16:40:05-07:00
logd6aa179ad2afe0424e1410cbafbb119bbd5f7809
tree9feb70250c2c48b120dae226ae1edb369c6a33a2
parent66a24d6c1a008430eebd855cd5640607d5aef38c
signature Signed by PGP key B5690EEEBB952194

feat(mobile): show when an existing thread has a message waiting in the outbox (#10405)

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";
8484import IconTool from "@tabler/icons-react-native/IconTool";
8585import IconTrash from "@tabler/icons-react-native/IconTrash";
8686import IconTypography from "@tabler/icons-react-native/IconTypography";
87import IconUpload from "@tabler/icons-react-native/IconUpload";
8788import IconUserCircle from "@tabler/icons-react-native/IconUserCircle";
8889import IconWifiOff from "@tabler/icons-react-native/IconWifiOff";
8990import IconWorld from "@tabler/icons-react-native/IconWorld";
......@@ -173,6 +174,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
173174 "textformat.size": IconTypography,
174175 "textformat.size.larger": IconTextIncrease,
175176 "textformat.size.smaller": IconTextDecrease,
177 "tray.and.arrow.up": IconUpload,
176178 trash: IconTrash,
177179 "wifi.slash": IconWifiOff,
178180 xmark: IconX,
apps/mobile/src/features/home/HomeScreen.tsx+12
......@@ -38,6 +38,7 @@ import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
3838import { usePendingThreadOrder } from "../../state/thread-order";
3939import { environmentServerConfigsAtom } from "../../state/server";
4040import type { PendingNewTask } from "../../state/use-pending-new-tasks";
41import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
4142import {
4243 PendingTaskListRow,
4344 ThreadListGroupHeader,
......@@ -212,6 +213,7 @@ export function HomeScreen(props: HomeScreenProps) {
212213 >(() => new Map());
213214 const preferencesResult = useAtomValue(mobilePreferencesAtom);
214215 const threadListV2Enabled = useThreadListV2Enabled();
216 const queuedThreadKeys = useQueuedThreadKeys();
215217 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
216218 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
217219 const listRef = useRef<LegendListRef | null>(null);
......@@ -384,6 +386,7 @@ export function HomeScreen(props: HomeScreenProps) {
384386 projects: scopedProjects,
385387 threads: scopedThreads,
386388 pendingTasks: scopedPendingTasks,
389 queuedThreadKeys,
387390 environmentId: props.selectedEnvironmentId,
388391 searchQuery: props.searchQuery,
389392 matchedThreadKeys,
......@@ -393,6 +396,7 @@ export function HomeScreen(props: HomeScreenProps) {
393396 }),
394397 [
395398 threadListV2Enabled,
399 queuedThreadKeys,
396400 props.projectGroupingMode,
397401 props.projectSortOrder,
398402 props.searchQuery,
......@@ -655,6 +659,7 @@ export function HomeScreen(props: HomeScreenProps) {
655659 now: new Date().toISOString(),
656660 settlementEnvironmentIds,
657661 snoozeEnvironmentIds,
662 queuedThreadKeys,
658663 }),
659664 });
660665 return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
......@@ -662,6 +667,7 @@ export function HomeScreen(props: HomeScreenProps) {
662667 serverConfigs,
663668 props.threads,
664669 pendingOrder,
670 queuedThreadKeys,
665671 settlementEnvironmentIds,
666672 snoozeEnvironmentIds,
667673 nowMinute,
......@@ -689,6 +695,7 @@ export function HomeScreen(props: HomeScreenProps) {
689695 matchedThreadKeys,
690696 settlementEnvironmentIds,
691697 snoozeEnvironmentIds,
698 queuedThreadKeys,
692699 settledLimit: settledVisibleCount,
693700 now: new Date().toISOString(),
694701 snoozedShelfExpanded,
......@@ -697,6 +704,7 @@ export function HomeScreen(props: HomeScreenProps) {
697704 });
698705 }, [
699706 pendingOrder,
707 queuedThreadKeys,
700708 nowMinute,
701709 snoozeWakeTick,
702710 snoozedShelfExpanded,
......@@ -818,6 +826,7 @@ export function HomeScreen(props: HomeScreenProps) {
818826 <ThreadListV2Row
819827 thread={thread}
820828 variant={item.item.variant}
829 hasQueuedMessages={queuedThreadKeys.has(movedId)}
821830 snoozed={item.item.snoozed}
822831 pinned={item.item.pinned}
823832 snoozePresetMinute={nowMinute}
......@@ -883,6 +892,7 @@ export function HomeScreen(props: HomeScreenProps) {
883892 activeReorderEnvironmentIds,
884893 threadMovePlanners,
885894 pendingOrder,
895 queuedThreadKeys,
886896 handleMoveThread,
887897 handlePinThread,
888898 handleRegenerateThreadTitle,
......@@ -993,6 +1003,7 @@ export function HomeScreen(props: HomeScreenProps) {
9931003 <ThreadListRow
9941004 variant="compact"
9951005 thread={thread}
1006 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
9961007 environmentLabel={
9971008 props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
9981009 }
......@@ -1032,6 +1043,7 @@ export function HomeScreen(props: HomeScreenProps) {
10321043 handleSwipeableWillOpen,
10331044 handleRegenerateThreadTitle,
10341045 machineByEnvironmentId,
1046 queuedThreadKeys,
10351047 props.onArchiveThread,
10361048 props.onDeletePendingTask,
10371049 props.onDeleteThread,
apps/mobile/src/features/home/homeThreadList.test.ts+37
......@@ -615,6 +615,43 @@ describe("buildHomeThreadGroups", () => {
615615 expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]);
616616 });
617617
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
618655 it("falls back to the most recent 3 threads when none are within 5 days", () => {
619656 const environmentId = EnvironmentId.make("environment-1");
620657 const project = makeProject({
apps/mobile/src/features/home/homeThreadList.ts+10-3
......@@ -24,7 +24,7 @@ import * as Arr from "effect/Array";
2424import * as Option from "effect/Option";
2525import * as Order from "effect/Order";
2626
27import { scopedProjectKey } from "../../lib/scopedEntities";
27import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
2828import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2929
3030export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;
......@@ -190,10 +190,15 @@ function selectRecentThreads(
190190 sortedThreads: ReadonlyArray<EnvironmentThreadShell>,
191191 threadSortOrder: SidebarThreadSortOrder,
192192 now: number,
193 queuedThreadKeys: ReadonlySet<string> | undefined,
193194): ReadonlyArray<EnvironmentThreadShell> {
194195 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.
195198 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,
197202 );
198203 return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT);
199204}
......@@ -202,6 +207,8 @@ export function buildHomeThreadGroups(input: {
202207 readonly projects: ReadonlyArray<EnvironmentProject>;
203208 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
204209 readonly pendingTasks?: ReadonlyArray<PendingNewTask>;
210 /** Thread keys with a message waiting in the outbox; kept in the default view. */
211 readonly queuedThreadKeys?: ReadonlySet<string>;
205212 readonly environmentId: EnvironmentId | null;
206213 readonly searchQuery: string;
207214 readonly matchedThreadKeys?: ReadonlySet<string>;
......@@ -326,7 +333,7 @@ export function buildHomeThreadGroups(input: {
326333 // only trims the default (no-query) view.
327334 const recentThreads =
328335 query.length === 0
329 ? selectRecentThreads(sortedThreads, input.threadSortOrder, now)
336 ? selectRecentThreads(sortedThreads, input.threadSortOrder, now, input.queuedThreadKeys)
330337 : sortedThreads;
331338
332339 // 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";
1212import { appAtomRegistry } from "../../state/atom-registry";
1313import { environmentServerConfigsAtom } from "../../state/server";
1414import { environmentThreadShells, threadEnvironment } from "../../state/threads";
15import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
1516import { useAtomCommand } from "../../state/use-atom-command";
1617import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order";
1718import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder";
......@@ -480,6 +481,7 @@ export function useThreadListActions(): {
480481 threads: shells,
481482 section,
482483 now: new Date().toISOString(),
484 queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom),
483485 settlementEnvironmentIds: new Set(
484486 [...configs].flatMap(([id, config]) =>
485487 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
3333import { usePendingThreadOrder } from "../../state/thread-order";
3434import { environmentServerConfigsAtom } from "../../state/server";
3535import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
36import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
3637import { useWorkspaceState } from "../../state/workspace";
3738import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
3839import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
......@@ -165,6 +166,7 @@ function ThreadNavigationSidebarPane(
165166 } = useThreadListActions();
166167 const threadListV2Enabled = useThreadListV2Enabled();
167168 const pendingTasks = usePendingNewTasks();
169 const queuedThreadKeys = useQueuedThreadKeys();
168170 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
169171 const environments = useMemo(
170172 () =>
......@@ -313,6 +315,7 @@ function ThreadNavigationSidebarPane(
313315 projects: scopedProjects,
314316 threads: scopedThreads,
315317 pendingTasks: scopedPendingTasks,
318 queuedThreadKeys,
316319 environmentId: options.selectedEnvironmentId,
317320 searchQuery: props.searchQuery,
318321 matchedThreadKeys,
......@@ -322,6 +325,7 @@ function ThreadNavigationSidebarPane(
322325 }),
323326 [
324327 threadListV2Enabled,
328 queuedThreadKeys,
325329 matchedThreadKeys,
326330 options,
327331 props.searchQuery,
......@@ -489,6 +493,7 @@ function ThreadNavigationSidebarPane(
489493 now: new Date().toISOString(),
490494 settlementEnvironmentIds,
491495 snoozeEnvironmentIds,
496 queuedThreadKeys,
492497 }),
493498 });
494499 return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
......@@ -496,6 +501,7 @@ function ThreadNavigationSidebarPane(
496501 serverConfigs,
497502 threads,
498503 pendingOrder,
504 queuedThreadKeys,
499505 settlementEnvironmentIds,
500506 snoozeEnvironmentIds,
501507 nowMinute,
......@@ -521,6 +527,7 @@ function ThreadNavigationSidebarPane(
521527 matchedThreadKeys,
522528 settlementEnvironmentIds,
523529 snoozeEnvironmentIds,
530 queuedThreadKeys,
524531 settledLimit: settledVisibleCount,
525532 now: new Date().toISOString(),
526533 snoozedShelfExpanded,
......@@ -529,6 +536,7 @@ function ThreadNavigationSidebarPane(
529536 });
530537 }, [
531538 pendingOrder,
539 queuedThreadKeys,
532540 nowMinute,
533541 snoozeWakeTick,
534542 snoozedShelfExpanded,
......@@ -884,6 +892,7 @@ function ThreadNavigationSidebarPane(
884892 <ThreadListV2Row
885893 thread={thread}
886894 variant={item.item.variant}
895 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
887896 snoozed={item.item.snoozed}
888897 pinned={item.item.pinned}
889898 snoozePresetMinute={nowMinute}
......@@ -1017,6 +1026,7 @@ function ThreadNavigationSidebarPane(
10171026 <ThreadListRow
10181027 variant="sidebar"
10191028 thread={thread}
1029 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
10201030 environmentLabel={
10211031 savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
10221032 }
......@@ -1061,6 +1071,7 @@ function ThreadNavigationSidebarPane(
10611071 activeReorderEnvironmentIds,
10621072 threadMovePlanners,
10631073 pendingOrder,
1074 queuedThreadKeys,
10641075 confirmDeletePendingTask,
10651076 confirmDeleteThread,
10661077 handleSelectThread,
apps/mobile/src/features/threads/queued-message-icon.tsx created+15
......@@ -0,0 +1,15 @@
1import { SymbolView } from "../../components/AppSymbol";
2
3/** Outbox state is independent of the agent's status, so both stay visible. */
4export 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
2727import type { HomeGroupDisplayAction } from "../home/homeListItems";
2828import { ThreadSwipeable } from "../home/thread-swipe-actions";
2929import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
30import { QueuedMessageIcon } from "./queued-message-icon";
3031import { resolveThreadStatus } from "./threadPresentation";
3132import { ThreadSearchMatchExcerpt } from "./thread-search-match";
3233
......@@ -440,6 +441,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
440441 readonly thread: EnvironmentThreadShell;
441442 readonly environmentLabel: string | null;
442443 readonly environmentMachine?: EnvironmentMachineKind;
444 /** A message for this thread is waiting in the outbox. */
445 readonly hasQueuedMessages?: boolean;
443446 readonly searchMatch?: EnvironmentThreadSearchMatch;
444447 readonly searchQuery?: string;
445448 readonly isLast: boolean;
......@@ -480,7 +483,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
480483 const timestamp = relativeTime(
481484 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
482485 );
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(", ");
484493 const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string =>
485494 Boolean(part),
486495 );
......@@ -611,6 +620,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
611620 {thread.title}
612621 </Text>
613622 <View className="flex-row items-center gap-2">
623 {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
614624 {statusPill}
615625 <Text className="text-base tabular-nums text-foreground-tertiary">{timestamp}</Text>
616626 <SymbolView
......@@ -670,6 +680,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
670680 {thread.title}
671681 </Text>
672682 <View className="flex-row items-center gap-2">
683 {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
673684 {statusPill}
674685 <Text
675686 className={cn(
apps/mobile/src/features/threads/thread-list-v2-items.tsx+11-2
......@@ -31,6 +31,7 @@ import {
3131 resolveThreadListV2SwipeActions,
3232 type ThreadListV2Status,
3333} from "./threadListV2";
34import { QueuedMessageIcon } from "./queued-message-icon";
3435import { ThreadSearchMatchExcerpt } from "./thread-search-match";
3536
3637/**
......@@ -334,6 +335,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
334335export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
335336 readonly thread: EnvironmentThreadShell;
336337 readonly variant: "card" | "slim";
338 /** A message for this thread is waiting in the outbox. */
339 readonly hasQueuedMessages?: boolean;
337340 /** Snoozed-shelf row: shows its wake time and offers Wake. */
338341 readonly snoozed?: boolean;
339342 /** Pinned-block row: shows the pin glyph and offers Unpin. */
......@@ -709,6 +712,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
709712 >
710713 {props.projectTitle ?? props.project?.title ?? ""}
711714 </Text>
715 {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
712716 {pinnedRow ? (
713717 <SymbolView
714718 name="pin"
......@@ -830,7 +834,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
830834 variant === "card" ? (
831835 <Pressable
832836 accessibilityHint={swipeAccessibilityHint}
833 accessibilityLabel={thread.title}
837 accessibilityLabel={
838 props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
839 }
834840 accessibilityRole="button"
835841 accessibilityState={{ selected }}
836842 onPress={() => {
......@@ -870,7 +876,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
870876 ) : (
871877 <Pressable
872878 accessibilityHint={swipeAccessibilityHint}
873 accessibilityLabel={thread.title}
879 accessibilityLabel={
880 props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
881 }
874882 accessibilityRole="button"
875883 accessibilityState={{ selected }}
876884 className={sidebarPane ? undefined : "bg-screen"}
......@@ -927,6 +935,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
927935 />
928936 ) : null}
929937 </View>
938 {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
930939 <Text
931940 className={cn(
932941 "text-sm tabular-nums",
apps/mobile/src/features/threads/threadListV2.test.ts+42
......@@ -166,6 +166,48 @@ describe("resolveThreadListV2Status", () => {
166166 });
167167});
168168
169describe("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
169211describe("resolveThreadListV2SwipeActions", () => {
170212 it("offers settle and snooze for an active snoozable thread", () => {
171213 expect(
apps/mobile/src/features/threads/threadListV2.ts+10-2
......@@ -178,12 +178,14 @@ export function getThreadListV2OrderedSection(input: {
178178 readonly now: string;
179179 readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
180180 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
181 readonly queuedThreadKeys?: ReadonlySet<string>;
181182}): EnvironmentThreadShell[] {
182183 const threads = input.threads.filter((thread) => {
183184 if (thread.archivedAt !== null) return false;
184185 if (
185186 (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) &&
186 thread.settledOverride === "settled"
187 thread.settledOverride === "settled" &&
188 input.queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) !== true
187189 ) {
188190 return false;
189191 }
......@@ -362,6 +364,10 @@ export function buildThreadListV2Items(input: {
362364 /** The selected thread remains visible on an otherwise collapsed shelf so
363365 a split-view detail can never lose its navigation row. */
364366 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>;
365371}): ThreadListV2Layout {
366372 const now = input.now;
367373 const pending =
......@@ -417,7 +423,9 @@ export function buildThreadListV2Items(input: {
417423 }
418424 continue;
419425 }
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) {
421429 settled.push(thread);
422430 } else if (thread.pinnedAt != null) {
423431 pinned.push(thread);
apps/mobile/src/state/thread-order.test.ts+4
......@@ -24,6 +24,10 @@ vi.mock("./server", async () => {
2424 const { Atom } = await import("effect/unstable/reactivity");
2525 return { environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive) };
2626});
27vi.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});
2731
2832// The mocked shell source is writable so tests can deliver canonical upserts.
2933const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable<
apps/mobile/src/state/thread-order.ts+3
......@@ -10,6 +10,7 @@ import { getThreadListV2OrderedSection } from "../features/threads/threadListV2"
1010import { appAtomRegistry } from "./atom-registry";
1111import { environmentServerConfigsAtom } from "./server";
1212import { environmentThreadShells } from "./threads";
13import { queuedThreadKeysAtom } from "./use-thread-outbox";
1314
1415export const pendingThreadOrderAtom = Atom.make<PendingThreadOrder | null>(null).pipe(
1516 Atom.keepAlive,
......@@ -50,6 +51,7 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) {
5051 threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom),
5152 section: current.section,
5253 now: new Date().toISOString(),
54 queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom),
5355 settlementEnvironmentIds: new Set(
5456 [...configs].flatMap(([id, config]) =>
5557 config.environment.capabilities.threadSettlement === true ? [id] : [],
......@@ -70,6 +72,7 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) {
7072 unsubscribers.push(
7173 appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh),
7274 appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh),
75 appAtomRegistry.subscribe(queuedThreadKeysAtom, refresh),
7376 );
7477 return {
7578 isPending: () => {
apps/mobile/src/state/use-thread-outbox.ts+22
......@@ -58,6 +58,28 @@ export function useThreadOutboxMessages() {
5858 return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom);
5959}
6060
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 */
67export 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
79export function useQueuedThreadKeys(): ReadonlySet<string> {
80 return useAtomValue(queuedThreadKeysAtom);
81}
82
6183export function useThreadOutboxShellStatuses() {
6284 return useAtomValue(threadOutboxShellStatusesAtom);
6385}