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";...@@ -84,6 +84,7 @@ import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease";
84import IconTool from "@tabler/icons-react-native/IconTool";84import IconTool from "@tabler/icons-react-native/IconTool";
85import IconTrash from "@tabler/icons-react-native/IconTrash";85import IconTrash from "@tabler/icons-react-native/IconTrash";
86import IconTypography from "@tabler/icons-react-native/IconTypography";86import IconTypography from "@tabler/icons-react-native/IconTypography";
87import IconUpload from "@tabler/icons-react-native/IconUpload";
87import IconUserCircle from "@tabler/icons-react-native/IconUserCircle";88import IconUserCircle from "@tabler/icons-react-native/IconUserCircle";
88import IconWifiOff from "@tabler/icons-react-native/IconWifiOff";89import IconWifiOff from "@tabler/icons-react-native/IconWifiOff";
89import IconWorld from "@tabler/icons-react-native/IconWorld";90import 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";
38import { usePendingThreadOrder } from "../../state/thread-order";38import { usePendingThreadOrder } from "../../state/thread-order";
39import { environmentServerConfigsAtom } from "../../state/server";39import { environmentServerConfigsAtom } from "../../state/server";
40import type { PendingNewTask } from "../../state/use-pending-new-tasks";40import type { PendingNewTask } from "../../state/use-pending-new-tasks";
41import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
41import {42import {
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 <ThreadListV2Row826 <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 <ThreadListRow1003 <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 ?? null1008 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 });
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
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";
24import * as Option from "effect/Option";24import * as Option from "effect/Option";
25import * as Order from "effect/Order";25import * as Order from "effect/Order";
2626
27import { scopedProjectKey } from "../../lib/scopedEntities";27import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
28import type { PendingNewTask } from "../../state/use-pending-new-tasks";28import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2929
30export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;30export 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 === 0335 query.length === 0
329 ? selectRecentThreads(sortedThreads, input.threadSortOrder, now)336 ? selectRecentThreads(sortedThreads, input.threadSortOrder, now, input.queuedThreadKeys)
330 : sortedThreads;337 : sortedThreads;
331338
332 // A stale project id still resolves to the canonical member with the same339 // 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";
12import { appAtomRegistry } from "../../state/atom-registry";12import { appAtomRegistry } from "../../state/atom-registry";
13import { environmentServerConfigsAtom } from "../../state/server";13import { environmentServerConfigsAtom } from "../../state/server";
14import { environmentThreadShells, threadEnvironment } from "../../state/threads";14import { environmentThreadShells, threadEnvironment } from "../../state/threads";
15import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
15import { useAtomCommand } from "../../state/use-atom-command";16import { useAtomCommand } from "../../state/use-atom-command";
16import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order";17import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order";
17import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder";18import { 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
33import { usePendingThreadOrder } from "../../state/thread-order";33import { usePendingThreadOrder } from "../../state/thread-order";
34import { environmentServerConfigsAtom } from "../../state/server";34import { environmentServerConfigsAtom } from "../../state/server";
35import { usePendingNewTasks } from "../../state/use-pending-new-tasks";35import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
36import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
36import { useWorkspaceState } from "../../state/workspace";37import { useWorkspaceState } from "../../state/workspace";
37import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";38import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
38import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";39import { 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 <ThreadListV2Row892 <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 <ThreadListRow1026 <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 ?? null1031 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 @@
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...@@ -27,6 +27,7 @@ import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-p
27import type { HomeGroupDisplayAction } from "../home/homeListItems";27import type { HomeGroupDisplayAction } from "../home/homeListItems";
28import { ThreadSwipeable } from "../home/thread-swipe-actions";28import { ThreadSwipeable } from "../home/thread-swipe-actions";
29import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";29import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
30import { QueuedMessageIcon } from "./queued-message-icon";
30import { resolveThreadStatus } from "./threadPresentation";31import { resolveThreadStatus } from "./threadPresentation";
31import { ThreadSearchMatchExcerpt } from "./thread-search-match";32import { ThreadSearchMatchExcerpt } from "./thread-search-match";
3233
...@@ -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 <SymbolView626 <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 <Text685 <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";
34import { QueuedMessageIcon } from "./queued-message-icon";
34import { ThreadSearchMatchExcerpt } from "./thread-search-match";35import { ThreadSearchMatchExcerpt } from "./thread-search-match";
3536
36/**37/**
...@@ -334,6 +335,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props...@@ -334,6 +335,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
334export const ThreadListV2Row = memo(function ThreadListV2Row(props: {335export 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 <SymbolView717 <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 <Pressable835 <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 <Pressable877 <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 <Text939 <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});
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
169describe("resolveThreadListV2SwipeActions", () => {211describe("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 so364 /** 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});
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
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.
29const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable<33const 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"
10import { appAtomRegistry } from "./atom-registry";10import { appAtomRegistry } from "./atom-registry";
11import { environmentServerConfigsAtom } from "./server";11import { environmentServerConfigsAtom } from "./server";
12import { environmentThreadShells } from "./threads";12import { environmentThreadShells } from "./threads";
13import { queuedThreadKeysAtom } from "./use-thread-outbox";
1314
14export const pendingThreadOrderAtom = Atom.make<PendingThreadOrder | null>(null).pipe(15export 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}
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
61export function useThreadOutboxShellStatuses() {83export function useThreadOutboxShellStatuses() {
62 return useAtomValue(threadOutboxShellStatusesAtom);84 return useAtomValue(threadOutboxShellStatusesAtom);
63}85}