# Files changed · PR \#10405 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/10405/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(mobile\): show when an existing thread has a message waiting in the outbox](https://git.cafe/pingdotgg/t3code/pull/10405.md)

Version: 1

Head commit: a177efd3104cd7f86937636bdf8fc12d366e31b5

Base commit: 071c390a9a28712879f1f8e0b944cb32adbe27da

Comparison base: 071c390a9a28712879f1f8e0b944cb32adbe27da

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/components/AppSymbol.tsx

Status: modified; +2; −0

```diff
@@ -84,6 +84,7 @@
 import IconTool from "@tabler/icons-react-native/IconTool";
 import IconTrash from "@tabler/icons-react-native/IconTrash";
 import IconTypography from "@tabler/icons-react-native/IconTypography";
+import IconUpload from "@tabler/icons-react-native/IconUpload";
 import IconUserCircle from "@tabler/icons-react-native/IconUserCircle";
 import IconWifiOff from "@tabler/icons-react-native/IconWifiOff";
 import IconWorld from "@tabler/icons-react-native/IconWorld";
@@ -173,6 +174,7 @@
   "textformat.size": IconTypography,
   "textformat.size.larger": IconTextIncrease,
   "textformat.size.smaller": IconTextDecrease,
+  "tray.and.arrow.up": IconUpload,
   trash: IconTrash,
   "wifi.slash": IconWifiOff,
   xmark: IconX,
```

## apps/mobile/src/features/home/HomeScreen.tsx

Status: modified; +12; −0

```diff
@@ -38,6 +38,7 @@
 import { usePendingThreadOrder } from "../../state/thread-order";
 import { environmentServerConfigsAtom } from "../../state/server";
 import type { PendingNewTask } from "../../state/use-pending-new-tasks";
+import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
 import {
   PendingTaskListRow,
   ThreadListGroupHeader,
@@ -212,6 +213,7 @@
   >(() => new Map());
   const preferencesResult = useAtomValue(mobilePreferencesAtom);
   const threadListV2Enabled = useThreadListV2Enabled();
+  const queuedThreadKeys = useQueuedThreadKeys();
   const savePreferences = useAtomSet(updateMobilePreferencesAtom);
   const openSwipeableRef = useRef<SwipeableMethods | null>(null);
   const listRef = useRef<LegendListRef | null>(null);
@@ -384,6 +386,7 @@
             projects: scopedProjects,
             threads: scopedThreads,
             pendingTasks: scopedPendingTasks,
+            queuedThreadKeys,
             environmentId: props.selectedEnvironmentId,
             searchQuery: props.searchQuery,
             matchedThreadKeys,
@@ -393,6 +396,7 @@
           }),
     [
       threadListV2Enabled,
+      queuedThreadKeys,
       props.projectGroupingMode,
       props.projectSortOrder,
       props.searchQuery,
@@ -655,6 +659,7 @@
           now: new Date().toISOString(),
           settlementEnvironmentIds,
           snoozeEnvironmentIds,
+          queuedThreadKeys,
         }),
       });
     return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
@@ -662,6 +667,7 @@
     serverConfigs,
     props.threads,
     pendingOrder,
+    queuedThreadKeys,
     settlementEnvironmentIds,
     snoozeEnvironmentIds,
     nowMinute,
@@ -689,6 +695,7 @@
       matchedThreadKeys,
       settlementEnvironmentIds,
       snoozeEnvironmentIds,
+      queuedThreadKeys,
       settledLimit: settledVisibleCount,
       now: new Date().toISOString(),
       snoozedShelfExpanded,
@@ -697,6 +704,7 @@
     });
   }, [
     pendingOrder,
+    queuedThreadKeys,
     nowMinute,
     snoozeWakeTick,
     snoozedShelfExpanded,
@@ -818,6 +826,7 @@
         <ThreadListV2Row
           thread={thread}
           variant={item.item.variant}
+          hasQueuedMessages={queuedThreadKeys.has(movedId)}
           snoozed={item.item.snoozed}
           pinned={item.item.pinned}
           snoozePresetMinute={nowMinute}
@@ -883,6 +892,7 @@
       activeReorderEnvironmentIds,
       threadMovePlanners,
       pendingOrder,
+      queuedThreadKeys,
       handleMoveThread,
       handlePinThread,
       handleRegenerateThreadTitle,
@@ -993,6 +1003,7 @@
             <ThreadListRow
               variant="compact"
               thread={thread}
+              hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
               environmentLabel={
                 props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
               }
@@ -1032,6 +1043,7 @@
       handleSwipeableWillOpen,
       handleRegenerateThreadTitle,
       machineByEnvironmentId,
+      queuedThreadKeys,
       props.onArchiveThread,
       props.onDeletePendingTask,
       props.onDeleteThread,
```

## apps/mobile/src/features/home/homeThreadList.test.ts

Status: modified; +37; −0

```diff
@@ -615,6 +615,43 @@
     expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]);
   });
 
+  it("keeps an old thread in the default view while a message waits in its outbox", () => {
+    const environmentId = EnvironmentId.make("environment-1");
+    const project = makeProject({
+      environmentId,
+      id: ProjectId.make("project-1"),
+      title: "T3 Code",
+    });
+    const threads = [
+      makeThread({
+        environmentId,
+        id: ThreadId.make("recent"),
+        projectId: project.id,
+        title: "Today",
+        updatedAt: "2026-06-28T00:00:00.000Z",
+      }),
+      makeThread({
+        environmentId,
+        id: ThreadId.make("old-queued"),
+        projectId: project.id,
+        title: "Two weeks ago, follow-up queued offline",
+        updatedAt: "2026-06-14T00:00:00.000Z",
+      }),
+      makeThread({
+        environmentId,
+        id: ThreadId.make("old"),
+        projectId: project.id,
+        title: "Two weeks ago",
+        updatedAt: "2026-06-13T00:00:00.000Z",
+      }),
+    ];
+
+    const group = buildGroups([project], threads, {
+      queuedThreadKeys: new Set([`${environmentId}:old-queued`]),
+    })[0];
+    expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent", "old-queued"]);
+  });
+
   it("falls back to the most recent 3 threads when none are within 5 days", () => {
     const environmentId = EnvironmentId.make("environment-1");
     const project = makeProject({
```

## apps/mobile/src/features/home/homeThreadList.ts

Status: modified; +10; −3

```diff
@@ -24,7 +24,7 @@
 import * as Option from "effect/Option";
 import * as Order from "effect/Order";
 
-import { scopedProjectKey } from "../../lib/scopedEntities";
+import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
 import type { PendingNewTask } from "../../state/use-pending-new-tasks";
 
 export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;
@@ -190,10 +190,15 @@
   sortedThreads: ReadonlyArray<EnvironmentThreadShell>,
   threadSortOrder: SidebarThreadSortOrder,
   now: number,
+  queuedThreadKeys: ReadonlySet<string> | undefined,
 ): ReadonlyArray<EnvironmentThreadShell> {
   const cutoff = now - RECENT_THREAD_WINDOW_MS;
+  // A thread with a message waiting in the outbox has work the user is
+  // waiting on, however old its last activity; it never trims away.
   const recent = sortedThreads.filter(
+    (thread) =>
+      getThreadSortTimestamp(thread, threadSortOrder) >= cutoff ||
-    (thread) => getThreadSortTimestamp(thread, threadSortOrder) >= cutoff,
+      queuedThreadKeys?.has(scopedThreadKey(thread.environmentId, thread.id)) === true,
   );
   return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT);
 }
@@ -202,6 +207,8 @@
   readonly projects: ReadonlyArray<EnvironmentProject>;
   readonly threads: ReadonlyArray<EnvironmentThreadShell>;
   readonly pendingTasks?: ReadonlyArray<PendingNewTask>;
+  /** Thread keys with a message waiting in the outbox; kept in the default view. */
+  readonly queuedThreadKeys?: ReadonlySet<string>;
   readonly environmentId: EnvironmentId | null;
   readonly searchQuery: string;
   readonly matchedThreadKeys?: ReadonlySet<string>;
@@ -326,7 +333,7 @@
     // only trims the default (no-query) view.
     const recentThreads =
       query.length === 0
+        ? selectRecentThreads(sortedThreads, input.threadSortOrder, now, input.queuedThreadKeys)
-        ? selectRecentThreads(sortedThreads, input.threadSortOrder, now)
         : sortedThreads;
 
     // A stale project id still resolves to the canonical member with the same
```

## apps/mobile/src/features/home/useThreadListActions.ts

Status: modified; +2; −0

```diff
@@ -12,6 +12,7 @@
 import { appAtomRegistry } from "../../state/atom-registry";
 import { environmentServerConfigsAtom } from "../../state/server";
 import { environmentThreadShells, threadEnvironment } from "../../state/threads";
+import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
 import { useAtomCommand } from "../../state/use-atom-command";
 import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order";
 import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder";
@@ -480,6 +481,7 @@
         threads: shells,
         section,
         now: new Date().toISOString(),
+        queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom),
         settlementEnvironmentIds: new Set(
           [...configs].flatMap(([id, config]) =>
             config.environment.capabilities.threadSettlement === true ? [id] : [],
```

## apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx

Status: modified; +11; −0

```diff
@@ -33,6 +33,7 @@
 import { usePendingThreadOrder } from "../../state/thread-order";
 import { environmentServerConfigsAtom } from "../../state/server";
 import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
+import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
 import { useWorkspaceState } from "../../state/workspace";
 import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
 import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
@@ -165,6 +166,7 @@
   } = useThreadListActions();
   const threadListV2Enabled = useThreadListV2Enabled();
   const pendingTasks = usePendingNewTasks();
+  const queuedThreadKeys = useQueuedThreadKeys();
   const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
   const environments = useMemo(
     () =>
@@ -313,6 +315,7 @@
             projects: scopedProjects,
             threads: scopedThreads,
             pendingTasks: scopedPendingTasks,
+            queuedThreadKeys,
             environmentId: options.selectedEnvironmentId,
             searchQuery: props.searchQuery,
             matchedThreadKeys,
@@ -322,6 +325,7 @@
           }),
     [
       threadListV2Enabled,
+      queuedThreadKeys,
       matchedThreadKeys,
       options,
       props.searchQuery,
@@ -489,6 +493,7 @@
           now: new Date().toISOString(),
           settlementEnvironmentIds,
           snoozeEnvironmentIds,
+          queuedThreadKeys,
         }),
       });
     return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
@@ -496,6 +501,7 @@
     serverConfigs,
     threads,
     pendingOrder,
+    queuedThreadKeys,
     settlementEnvironmentIds,
     snoozeEnvironmentIds,
     nowMinute,
@@ -521,6 +527,7 @@
       matchedThreadKeys,
       settlementEnvironmentIds,
       snoozeEnvironmentIds,
+      queuedThreadKeys,
       settledLimit: settledVisibleCount,
       now: new Date().toISOString(),
       snoozedShelfExpanded,
@@ -529,6 +536,7 @@
     });
   }, [
     pendingOrder,
+    queuedThreadKeys,
     nowMinute,
     snoozeWakeTick,
     snoozedShelfExpanded,
@@ -884,6 +892,7 @@
             <ThreadListV2Row
               thread={thread}
               variant={item.item.variant}
+              hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
               snoozed={item.item.snoozed}
               pinned={item.item.pinned}
               snoozePresetMinute={nowMinute}
@@ -1017,6 +1026,7 @@
             <ThreadListRow
               variant="sidebar"
               thread={thread}
+              hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
               environmentLabel={
                 savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
               }
@@ -1061,6 +1071,7 @@
       activeReorderEnvironmentIds,
       threadMovePlanners,
       pendingOrder,
+      queuedThreadKeys,
       confirmDeletePendingTask,
       confirmDeleteThread,
       handleSelectThread,
```

## apps/mobile/src/features/threads/queued-message-icon.tsx

Status: added; +15; −0

```diff
@@ -1,0 +1,15 @@
+import { SymbolView } from "../../components/AppSymbol";
+
+/** Outbox state is independent of the agent's status, so both stay visible. */
+export function QueuedMessageIcon({ selected = false }: { readonly selected?: boolean }) {
+  return (
+    <SymbolView
+      name="tray.and.arrow.up"
+      size={12}
+      tintColorClassName={
+        selected ? "accent-user-bubble-foreground-muted" : "accent-foreground-muted"
+      }
+      type="monochrome"
+    />
+  );
+}
```

## apps/mobile/src/features/threads/thread-list-items.tsx

Status: modified; +12; −1

```diff
@@ -27,6 +27,7 @@
 import type { HomeGroupDisplayAction } from "../home/homeListItems";
 import { ThreadSwipeable } from "../home/thread-swipe-actions";
 import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
+import { QueuedMessageIcon } from "./queued-message-icon";
 import { resolveThreadStatus } from "./threadPresentation";
 import { ThreadSearchMatchExcerpt } from "./thread-search-match";
 
@@ -440,6 +441,8 @@
   readonly thread: EnvironmentThreadShell;
   readonly environmentLabel: string | null;
   readonly environmentMachine?: EnvironmentMachineKind;
+  /** A message for this thread is waiting in the outbox. */
+  readonly hasQueuedMessages?: boolean;
   readonly searchMatch?: EnvironmentThreadSearchMatch;
   readonly searchQuery?: string;
   readonly isLast: boolean;
@@ -480,7 +483,13 @@
   const timestamp = relativeTime(
     thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
   );
+  const threadAccessibilityLabel = [
+    thread.title,
+    pr?.accessibilityLabel,
+    props.hasQueuedMessages ? "messages queued to send" : null,
+  ]
+    .filter(Boolean)
-  const threadAccessibilityLabel = pr ? `${thread.title}, ${pr.accessibilityLabel}` : thread.title;
+    .join(", ");
   const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string =>
     Boolean(part),
   );
@@ -611,6 +620,7 @@
                 {thread.title}
               </Text>
               <View className="flex-row items-center gap-2">
+                {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
                 {statusPill}
                 <Text className="text-base tabular-nums text-foreground-tertiary">{timestamp}</Text>
                 <SymbolView
@@ -670,6 +680,7 @@
               {thread.title}
             </Text>
             <View className="flex-row items-center gap-2">
+              {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
               {statusPill}
               <Text
                 className={cn(
```

## apps/mobile/src/features/threads/thread-list-v2-items.tsx

Status: modified; +11; −2

```diff
@@ -31,6 +31,7 @@
   resolveThreadListV2SwipeActions,
   type ThreadListV2Status,
 } from "./threadListV2";
+import { QueuedMessageIcon } from "./queued-message-icon";
 import { ThreadSearchMatchExcerpt } from "./thread-search-match";
 
 /**
@@ -334,6 +335,8 @@
 export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
   readonly thread: EnvironmentThreadShell;
   readonly variant: "card" | "slim";
+  /** A message for this thread is waiting in the outbox. */
+  readonly hasQueuedMessages?: boolean;
   /** Snoozed-shelf row: shows its wake time and offers Wake. */
   readonly snoozed?: boolean;
   /** Pinned-block row: shows the pin glyph and offers Unpin. */
@@ -709,6 +712,7 @@
         >
           {props.projectTitle ?? props.project?.title ?? ""}
         </Text>
+        {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
         {pinnedRow ? (
           <SymbolView
             name="pin"
@@ -830,7 +834,9 @@
     variant === "card" ? (
       <Pressable
         accessibilityHint={swipeAccessibilityHint}
+        accessibilityLabel={
+          props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
+        }
-        accessibilityLabel={thread.title}
         accessibilityRole="button"
         accessibilityState={{ selected }}
         onPress={() => {
@@ -870,7 +876,9 @@
     ) : (
       <Pressable
         accessibilityHint={swipeAccessibilityHint}
+        accessibilityLabel={
-        accessibilityLabel={thread.title}
+          props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title
+        }
         accessibilityRole="button"
         accessibilityState={{ selected }}
         className={sidebarPane ? undefined : "bg-screen"}
@@ -927,6 +935,7 @@
               />
             ) : null}
           </View>
+          {props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
           <Text
             className={cn(
               "text-sm tabular-nums",
```

## apps/mobile/src/features/threads/threadListV2.test.ts

Status: modified; +42; −0

```diff
@@ -166,6 +166,48 @@
   });
 });
 
+describe("queued messages keep a settled thread active", () => {
+  const threads = [
+    makeThread({ id: ThreadId.make("active"), title: "Active" }),
+    makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }),
+    makeThread({
+      id: ThreadId.make("settled-queued"),
+      title: "Settled with outbox",
+      settledOverride: "settled",
+    }),
+  ];
+  const queuedThreadKeys = new Set([`${environmentId}:settled-queued`]);
+
+  it("lists the thread in the active block instead of the settled shelf", () => {
+    const layout = buildThreadListV2Items({
+      threads,
+      environmentId: null,
+      searchQuery: "",
+      now: NOW,
+      queuedThreadKeys,
+    });
+    expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([
+      ["active", "card"],
+      ["settled-queued", "card"],
+      ["settled", "slim"],
+    ]);
+    expect(layout.settledCount).toBe(1);
+  });
+
+  it("includes it in the reorderable active section", () => {
+    expect(
+      getThreadListV2OrderedSection({ threads, section: "active", now: NOW, queuedThreadKeys }).map(
+        (thread) => thread.id,
+      ),
+    ).toEqual(["active", "settled-queued"]);
+    expect(
+      getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map(
+        (thread) => thread.id,
+      ),
+    ).toEqual(["active"]);
+  });
+});
+
 describe("resolveThreadListV2SwipeActions", () => {
   it("offers settle and snooze for an active snoozable thread", () => {
     expect(
```

## apps/mobile/src/features/threads/threadListV2.ts

Status: modified; +10; −2

```diff
@@ -178,12 +178,14 @@
   readonly now: string;
   readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
   readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
+  readonly queuedThreadKeys?: ReadonlySet<string>;
 }): EnvironmentThreadShell[] {
   const threads = input.threads.filter((thread) => {
     if (thread.archivedAt !== null) return false;
     if (
       (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) &&
+      thread.settledOverride === "settled" &&
-      thread.settledOverride === "settled"
+      input.queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) !== true
     ) {
       return false;
     }
@@ -362,6 +364,10 @@
   /** The selected thread remains visible on an otherwise collapsed shelf so
       a split-view detail can never lose its navigation row. */
   readonly selectedThreadKey?: string | null;
+  /** Thread keys (`environmentId:threadId`) with a message waiting in the
+      outbox. Such a thread has work the user is waiting on, so it stays in
+      the active block even when the server has settled it. */
+  readonly queuedThreadKeys?: ReadonlySet<string>;
 }): ThreadListV2Layout {
   const now = input.now;
   const pending =
@@ -417,7 +423,9 @@
       }
       continue;
     }
-    if (supportsSettlement && thread.settledOverride === "settled") {
+    const hasQueuedMessages =
+      input.queuedThreadKeys?.has(`${thread.environmentId}:${thread.id}`) === true;
+    if (supportsSettlement && thread.settledOverride === "settled" && !hasQueuedMessages) {
       settled.push(thread);
     } else if (thread.pinnedAt != null) {
       pinned.push(thread);
```

## apps/mobile/src/state/thread-order.test.ts

Status: modified; +4; −0

```diff
@@ -24,6 +24,10 @@
   const { Atom } = await import("effect/unstable/reactivity");
   return { environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive) };
 });
+vi.mock("./use-thread-outbox", async () => {
+  const { Atom } = await import("effect/unstable/reactivity");
+  return { queuedThreadKeysAtom: Atom.make(new Set<string>()).pipe(Atom.keepAlive) };
+});
 
 // The mocked shell source is writable so tests can deliver canonical upserts.
 const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable<
```

## apps/mobile/src/state/thread-order.ts

Status: modified; +3; −0

```diff
@@ -10,6 +10,7 @@
 import { appAtomRegistry } from "./atom-registry";
 import { environmentServerConfigsAtom } from "./server";
 import { environmentThreadShells } from "./threads";
+import { queuedThreadKeysAtom } from "./use-thread-outbox";
 
 export const pendingThreadOrderAtom = Atom.make<PendingThreadOrder | null>(null).pipe(
   Atom.keepAlive,
@@ -50,6 +51,7 @@
       threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom),
       section: current.section,
       now: new Date().toISOString(),
+      queuedThreadKeys: appAtomRegistry.get(queuedThreadKeysAtom),
       settlementEnvironmentIds: new Set(
         [...configs].flatMap(([id, config]) =>
           config.environment.capabilities.threadSettlement === true ? [id] : [],
@@ -70,6 +72,7 @@
   unsubscribers.push(
     appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh),
     appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh),
+    appAtomRegistry.subscribe(queuedThreadKeysAtom, refresh),
   );
   return {
     isPending: () => {
```

## apps/mobile/src/state/use-thread-outbox.ts

Status: modified; +22; −0

```diff
@@ -58,6 +58,28 @@
   return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom);
 }
 
+/**
+ * Thread keys (`environmentId:threadId`) of existing threads with a message
+ * waiting in the outbox. Creations are excluded: they have no thread row yet
+ * and surface as pending tasks instead. Derived once so list builders and
+ * reorder planners agree on which settled threads are pulled back to active.
+ */
+export const queuedThreadKeysAtom = Atom.make((get): ReadonlySet<string> => {
+  const keys = new Set<string>();
+  for (const [threadKey, queue] of Object.entries(
+    get(threadOutboxManager.queuedMessagesByThreadKeyAtom),
+  )) {
+    if (queue.some((message) => message.creation === undefined)) {
+      keys.add(threadKey);
+    }
+  }
+  return keys;
+}).pipe(Atom.withLabel("mobile:thread-outbox:queued-thread-keys"));
+
+export function useQueuedThreadKeys(): ReadonlySet<string> {
+  return useAtomValue(queuedThreadKeysAtom);
+}
+
 export function useThreadOutboxShellStatuses() {
   return useAtomValue(threadOutboxShellStatusesAtom);
 }
```
