authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-03 15:03:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 15:03:59-07:00
log8d846660cecfc69ef4e192322fced29fa07f374c
treee3a72e2791efe1b99167702e33cfa97c21ab9cfc
parenta78760e5e7b11ea51e21a1640fec54bd3f210fd8
signature Signed by PGP key B5690EEEBB952194

feat(mobile): beta Working section hides busy threads until they need you (#15346)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

16 files changed, 587 insertions(+), 103 deletions(-)

apps/mobile/src/features/home/HomeScreen.tsx+37-2
...@@ -44,6 +44,7 @@ import {...@@ -44,6 +44,7 @@ import {
44 ThreadListV2SettledShelfHeader,44 ThreadListV2SettledShelfHeader,
45 ThreadListV2ShowMoreRow,45 ThreadListV2ShowMoreRow,
46 ThreadListV2SnoozedShelfHeader,46 ThreadListV2SnoozedShelfHeader,
47 ThreadListV2WorkingShelfHeader,
47} from "../threads/thread-list-v2-items";48} from "../threads/thread-list-v2-items";
48import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance";49import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance";
49import {50import {
...@@ -51,6 +52,7 @@ import {...@@ -51,6 +52,7 @@ import {
51 getThreadListV2OrderedSection,52 getThreadListV2OrderedSection,
52 buildThreadListV2ListItems,53 buildThreadListV2ListItems,
53 threadListV2ListItemsAreEqual,54 threadListV2ListItemsAreEqual,
55 threadListInboxReturns,
54 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,56 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
55 THREAD_LIST_V2_SETTLED_PAGE_COUNT,57 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
56 type ThreadListV2ListItem,58 type ThreadListV2ListItem,
...@@ -466,8 +468,11 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -466,8 +468,11 @@ export function HomeScreen(props: HomeScreenProps) {
466 loaded: shelfPreferencesLoaded,468 loaded: shelfPreferencesLoaded,
467 settledShelfExpanded,469 settledShelfExpanded,
468 snoozedShelfExpanded,470 snoozedShelfExpanded,
471 workingShelfEnabled,
472 workingShelfExpanded,
469 toggleSettledShelf,473 toggleSettledShelf,
470 toggleSnoozedShelf,474 toggleSnoozedShelf,
475 toggleWorkingShelf,
471 } = useThreadListV2ShelfPreferences();476 } = useThreadListV2ShelfPreferences();
472 // The queued-start and snooze helpers need a clock while the list stays open.477 // The queued-start and snooze helpers need a clock while the list stays open.
473 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));478 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
...@@ -520,8 +525,13 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -520,8 +525,13 @@ export function HomeScreen(props: HomeScreenProps) {
520 queuedThreadKeys,525 queuedThreadKeys,
521 }),526 }),
522 });527 });
523 return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]);528 // The Working beta orders the inbox by time, so only pins can move.
529 return new Map([
530 ...sectionAvailability("pinned"),
531 ...(workingShelfEnabled ? [] : sectionAvailability("active")),
532 ]);
524 }, [533 }, [
534 workingShelfEnabled,
525 pinReorderEnvironmentIds,535 pinReorderEnvironmentIds,
526 activeReorderEnvironmentIds,536 activeReorderEnvironmentIds,
527 props.threads,537 props.threads,
...@@ -533,6 +543,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -533,6 +543,7 @@ export function HomeScreen(props: HomeScreenProps) {
533 snoozeWakeTick,543 snoozeWakeTick,
534 ]);544 ]);
535 const threadListV2Layout = useMemo(() => {545 const threadListV2Layout = useMemo(() => {
546 threadListInboxReturns.observe(workingShelfEnabled ? props.threads : null);
536 // Settled threads are live shells; archived threads keep their original547 // Settled threads are live shells; archived threads keep their original
537 // "hidden from lists" meaning.548 // "hidden from lists" meaning.
538 return buildThreadListV2Items({549 return buildThreadListV2Items({
...@@ -547,11 +558,16 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -547,11 +558,16 @@ export function HomeScreen(props: HomeScreenProps) {
547 queuedThreadKeys,558 queuedThreadKeys,
548 settledLimit: settledVisibleCount,559 settledLimit: settledVisibleCount,
549 now: new Date().toISOString(),560 now: new Date().toISOString(),
561 workingShelfEnabled,
562 workingShelfExpanded,
563 inboxReturnAt: threadListInboxReturns.returnedAt,
550 snoozedShelfExpanded,564 snoozedShelfExpanded,
551 settledShelfExpanded,565 settledShelfExpanded,
552 selectedThreadKey: null,566 selectedThreadKey: null,
553 });567 });
554 }, [568 }, [
569 workingShelfEnabled,
570 workingShelfExpanded,
555 pendingOrder,571 pendingOrder,
556 queuedThreadKeys,572 queuedThreadKeys,
557 nowMinute,573 nowMinute,
...@@ -606,6 +622,9 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -606,6 +622,9 @@ export function HomeScreen(props: HomeScreenProps) {
606 buildThreadListV2ListItems({622 buildThreadListV2ListItems({
607 items: threadListV2Layout.items,623 items: threadListV2Layout.items,
608 pendingTasks: v2PendingTasks,624 pendingTasks: v2PendingTasks,
625 workingCount: threadListV2Layout.workingCount,
626 workingShelfExpanded,
627 workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex,
609 snoozedCount: threadListV2Layout.snoozedCount,628 snoozedCount: threadListV2Layout.snoozedCount,
610 snoozedShelfExpanded,629 snoozedShelfExpanded,
611 snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex,630 snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex,
...@@ -628,6 +647,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -628,6 +647,7 @@ export function HomeScreen(props: HomeScreenProps) {
628 snoozeEnvironmentIds,647 snoozeEnvironmentIds,
629 threadListV2Layout,648 threadListV2Layout,
630 v2PendingTasks,649 v2PendingTasks,
650 workingShelfExpanded,
631 ],651 ],
632 );652 );
633653
...@@ -662,6 +682,16 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -662,6 +682,16 @@ export function HomeScreen(props: HomeScreenProps) {
662 />682 />
663 );683 );
664 }684 }
685 if (item.type === "v2-working-shelf") {
686 return (
687 <ThreadListV2WorkingShelfHeader
688 count={item.count}
689 disabled={item.disabled}
690 expanded={item.expanded}
691 onToggle={toggleWorkingShelf}
692 />
693 );
694 }
665 if (item.type === "v2-snoozed-shelf") {695 if (item.type === "v2-snoozed-shelf") {
666 return (696 return (
667 <ThreadListV2SnoozedShelfHeader697 <ThreadListV2SnoozedShelfHeader
...@@ -730,7 +760,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -730,7 +760,7 @@ export function HomeScreen(props: HomeScreenProps) {
730 reorderSupported={760 reorderSupported={
731 item.item.pinned761 item.item.pinned
732 ? pinReorderEnvironmentIds.has(thread.environmentId)762 ? pinReorderEnvironmentIds.has(thread.environmentId)
733 : activeReorderEnvironmentIds.has(thread.environmentId)763 : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId)
734 }764 }
735 canMoveUp={item.canMoveUp}765 canMoveUp={item.canMoveUp}
736 canMoveDown={item.canMoveDown}766 canMoveDown={item.canMoveDown}
...@@ -782,8 +812,10 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -782,8 +812,10 @@ export function HomeScreen(props: HomeScreenProps) {
782 titleRegenerationEnvironmentIds,812 titleRegenerationEnvironmentIds,
783 toggleSettledShelf,813 toggleSettledShelf,
784 toggleSnoozedShelf,814 toggleSnoozedShelf,
815 toggleWorkingShelf,
785 v2ProjectTitleByProjectKey,816 v2ProjectTitleByProjectKey,
786 props.searchQuery,817 props.searchQuery,
818 workingShelfEnabled,
787 ],819 ],
788 );820 );
789 const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);821 const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);
...@@ -800,6 +832,8 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -800,6 +832,8 @@ export function HomeScreen(props: HomeScreenProps) {
800 savedConnectionsById: props.savedConnectionsById,832 savedConnectionsById: props.savedConnectionsById,
801 searchQuery: props.searchQuery,833 searchQuery: props.searchQuery,
802 threadSearchMatchByKey,834 threadSearchMatchByKey,
835 // Rows read it for their reorder menu items.
836 workingShelfEnabled,
803 }),837 }),
804 [838 [
805 projectByKey,839 projectByKey,
...@@ -808,6 +842,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -808,6 +842,7 @@ export function HomeScreen(props: HomeScreenProps) {
808 listEnvironments,842 listEnvironments,
809 threadSearchMatchByKey,843 threadSearchMatchByKey,
810 v2ProjectTitleByProjectKey,844 v2ProjectTitleByProjectKey,
845 workingShelfEnabled,
811 ],846 ],
812 );847 );
813848
apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx+30
...@@ -45,6 +45,7 @@ export function SettingsThreadsRouteScreen() {...@@ -45,6 +45,7 @@ export function SettingsThreadsRouteScreen() {
45 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}45 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
46 >46 >
47 <AutoSettleSettingsRows />47 <AutoSettleSettingsRows />
48 <BetaSettingsSection />
48 <LegacySettingsSection />49 <LegacySettingsSection />
49 </ScrollView>50 </ScrollView>
50 </SettingsScreen>51 </SettingsScreen>
...@@ -239,6 +240,35 @@ function AutoSettleSettingsRows() {...@@ -239,6 +240,35 @@ function AutoSettleSettingsRows() {
239 );240 );
240}241}
241242
243/**
244 * Device-local beta toggles, the counterpart of web's Working section (beta)
245 * in Settings → General.
246 */
247function BetaSettingsSection() {
248 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
249 const preferences = useAtomValue(mobilePreferencesAtom);
250 const workingShelfEnabled =
251 AsyncResult.isSuccess(preferences) && preferences.value.workingShelfEnabled === true;
252
253 return (
254 <View className="gap-3">
255 <SettingsSection title="Beta">
256 <SettingsSwitchRow
257 icon="bolt.circle"
258 label="Working section"
259 value={workingShelfEnabled}
260 onValueChange={(value) => savePreferences({ workingShelfEnabled: value })}
261 />
262 </SettingsSection>
263 <Text className="px-2 text-sm text-foreground-muted">
264 Fold working and monitoring threads into a Working section. They return to the top of the
265 list when they need you. While this is on, active threads are ordered by time and cannot be
266 moved.
267 </Text>
268 </View>
269 );
270}
271
242/**272/**
243 * Device-local legacy toggles. Mobile has no client-settings sync, so this is273 * Device-local legacy toggles. Mobile has no client-settings sync, so this is
244 * the counterpart of web's Settings → General → Legacy features backed by274 * the counterpart of web's Settings → General → Legacy features backed by
apps/mobile/src/features/threads/ThreadArrangementSheet.tsx+13-5
...@@ -2,6 +2,7 @@ import { appAtomRegistry } from "../../state/atom-registry";...@@ -2,6 +2,7 @@ import { appAtomRegistry } from "../../state/atom-registry";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";3import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
4import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";4import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
5import { sortInboxThreadsByReturn } from "@t3tools/client-runtime/state/thread-inbox";
5import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";6import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
6import { Animated, FlatList, Modal, Pressable, View } from "react-native";7import { Animated, FlatList, Modal, Pressable, View } from "react-native";
7import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";8import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
...@@ -26,7 +27,8 @@ import {...@@ -26,7 +27,8 @@ import {
26 threadDragAction,27 threadDragAction,
27 type ThreadMoveDestination,28 type ThreadMoveDestination,
28} from "./threadOrder";29} from "./threadOrder";
29import { getThreadListV2OrderedSection } from "./threadListV2";30import { getThreadListV2OrderedSection, threadListInboxReturns } from "./threadListV2";
31import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
3032
31const ROW_HEIGHT = 56;33const ROW_HEIGHT = 56;
32const HEADER_HEIGHT = 48;34const HEADER_HEIGHT = 48;
...@@ -154,6 +156,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {...@@ -154,6 +156,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
154 const pendingOrder = useAtomValue(pendingThreadOrderAtom);156 const pendingOrder = useAtomValue(pendingThreadOrderAtom);
155 const dropBusy = useAtomValue(threadDropBusyAtom);157 const dropBusy = useAtomValue(threadDropBusyAtom);
156 const { moveThread } = useThreadListActions();158 const { moveThread } = useThreadListActions();
159 const { workingShelfEnabled } = useThreadListV2ShelfPreferences();
157 const [now, setNow] = useState(() => new Date().toISOString());160 const [now, setNow] = useState(() => new Date().toISOString());
158 const [expanded, setExpanded] = useState({ snoozed: false, settled: false });161 const [expanded, setExpanded] = useState({ snoozed: false, settled: false });
159 useEffect(() => {162 useEffect(() => {
...@@ -195,23 +198,28 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {...@@ -195,23 +198,28 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
195 );198 );
196 return {199 return {
197 pinned,200 pinned,
198 active,201 // The Working beta orders the inbox by time; show that order here too.
202 active: workingShelfEnabled
203 ? sortInboxThreadsByReturn(active, threadListInboxReturns.returnedAt)
204 : active,
199 snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })),205 snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })),
200 settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })),206 settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })),
201 };207 };
202 }, [threads, configs, now, queuedThreadKeys, pendingOrder]);208 }, [threads, configs, now, queuedThreadKeys, pendingOrder, workingShelfEnabled]);
203 const planners = useMemo(() => {209 const planners = useMemo(() => {
204 const planner = (section: "pinned" | "active") =>210 const planner = (section: "pinned" | "active") =>
205 createThreadMovePlanner({211 createThreadMovePlanner({
206 ordered: sections[section],212 ordered: sections[section],
207 allThreads: threads,213 allThreads: threads,
208 section,214 section,
215 // A time-ordered inbox has no slots, so Active takes no drops while
216 // the Working beta is on. The saved arrangement stays untouched.
209 reorderableEnvironmentIds: new Set(217 reorderableEnvironmentIds: new Set(
210 [...configs].flatMap(([id, config]) =>218 [...configs].flatMap(([id, config]) =>
211 (219 (
212 section === "pinned"220 section === "pinned"
213 ? config.environment.capabilities.threadPinReorder221 ? config.environment.capabilities.threadPinReorder
214 : config.environment.capabilities.threadActiveReorder222 : !workingShelfEnabled && config.environment.capabilities.threadActiveReorder
215 )223 )
216 ? [id]224 ? [id]
217 : [],225 : [],
...@@ -219,7 +227,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {...@@ -219,7 +227,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
219 ),227 ),
220 });228 });
221 return { pinned: planner("pinned"), active: planner("active") };229 return { pinned: planner("pinned"), active: planner("active") };
222 }, [sections, threads, configs]);230 }, [sections, threads, configs, workingShelfEnabled]);
223 const rows = useMemo(() => {231 const rows = useMemo(() => {
224 const result: Row[] = [];232 const result: Row[] = [];
225 let offset = 0;233 let offset = 0;
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+37-2
...@@ -61,6 +61,7 @@ import {...@@ -61,6 +61,7 @@ import {
61 ThreadListV2SettledShelfHeader,61 ThreadListV2SettledShelfHeader,
62 ThreadListV2ShowMoreRow,62 ThreadListV2ShowMoreRow,
63 ThreadListV2SnoozedShelfHeader,63 ThreadListV2SnoozedShelfHeader,
64 ThreadListV2WorkingShelfHeader,
64} from "./thread-list-v2-items";65} from "./thread-list-v2-items";
65import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance";66import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance";
66import {67import {
...@@ -69,6 +70,7 @@ import {...@@ -69,6 +70,7 @@ import {
69 buildThreadListV2ListItems,70 buildThreadListV2ListItems,
70 isThreadListV2ListItem,71 isThreadListV2ListItem,
71 threadListV2ListItemsAreEqual,72 threadListV2ListItemsAreEqual,
73 threadListInboxReturns,
72 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,74 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
73 THREAD_LIST_V2_SETTLED_PAGE_COUNT,75 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
74 type ThreadListV2ListItem,76 type ThreadListV2ListItem,
...@@ -291,8 +293,11 @@ function ThreadNavigationSidebarPane(...@@ -291,8 +293,11 @@ function ThreadNavigationSidebarPane(
291 loaded: shelfPreferencesLoaded,293 loaded: shelfPreferencesLoaded,
292 settledShelfExpanded,294 settledShelfExpanded,
293 snoozedShelfExpanded,295 snoozedShelfExpanded,
296 workingShelfEnabled,
297 workingShelfExpanded,
294 toggleSettledShelf,298 toggleSettledShelf,
295 toggleSnoozedShelf,299 toggleSnoozedShelf,
300 toggleWorkingShelf,
296 } = useThreadListV2ShelfPreferences();301 } = useThreadListV2ShelfPreferences();
297 // The queued-start and snooze helpers need a clock while the pane stays open.302 // The queued-start and snooze helpers need a clock while the pane stays open.
298 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));303 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
...@@ -343,8 +348,13 @@ function ThreadNavigationSidebarPane(...@@ -343,8 +348,13 @@ function ThreadNavigationSidebarPane(
343 queuedThreadKeys,348 queuedThreadKeys,
344 }),349 }),
345 });350 });
346 return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]);351 // The Working beta orders the inbox by time, so only pins can move.
352 return new Map([
353 ...sectionAvailability("pinned"),
354 ...(workingShelfEnabled ? [] : sectionAvailability("active")),
355 ]);
347 }, [356 }, [
357 workingShelfEnabled,
348 pinReorderEnvironmentIds,358 pinReorderEnvironmentIds,
349 activeReorderEnvironmentIds,359 activeReorderEnvironmentIds,
350 threads,360 threads,
...@@ -356,6 +366,7 @@ function ThreadNavigationSidebarPane(...@@ -356,6 +366,7 @@ function ThreadNavigationSidebarPane(
356 snoozeWakeTick,366 snoozeWakeTick,
357 ]);367 ]);
358 const threadListV2Layout = useMemo(() => {368 const threadListV2Layout = useMemo(() => {
369 threadListInboxReturns.observe(workingShelfEnabled ? threads : null);
359 return buildThreadListV2Items({370 return buildThreadListV2Items({
360 pendingOrder,371 pendingOrder,
361 threads: threads.filter((thread) => thread.archivedAt === null),372 threads: threads.filter((thread) => thread.archivedAt === null),
...@@ -368,11 +379,16 @@ function ThreadNavigationSidebarPane(...@@ -368,11 +379,16 @@ function ThreadNavigationSidebarPane(
368 queuedThreadKeys,379 queuedThreadKeys,
369 settledLimit: settledVisibleCount,380 settledLimit: settledVisibleCount,
370 now: new Date().toISOString(),381 now: new Date().toISOString(),
382 workingShelfEnabled,
383 workingShelfExpanded,
384 inboxReturnAt: threadListInboxReturns.returnedAt,
371 snoozedShelfExpanded,385 snoozedShelfExpanded,
372 settledShelfExpanded,386 settledShelfExpanded,
373 selectedThreadKey: props.selectedThreadKey ?? null,387 selectedThreadKey: props.selectedThreadKey ?? null,
374 });388 });
375 }, [389 }, [
390 workingShelfEnabled,
391 workingShelfExpanded,
376 pendingOrder,392 pendingOrder,
377 queuedThreadKeys,393 queuedThreadKeys,
378 nowMinute,394 nowMinute,
...@@ -424,6 +440,9 @@ function ThreadNavigationSidebarPane(...@@ -424,6 +440,9 @@ function ThreadNavigationSidebarPane(
424 const items: SidebarListItem[] = buildThreadListV2ListItems({440 const items: SidebarListItem[] = buildThreadListV2ListItems({
425 items: threadListV2Layout.items,441 items: threadListV2Layout.items,
426 pendingTasks: v2PendingTasks,442 pendingTasks: v2PendingTasks,
443 workingCount: threadListV2Layout.workingCount,
444 workingShelfExpanded,
445 workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex,
427 snoozedCount: threadListV2Layout.snoozedCount,446 snoozedCount: threadListV2Layout.snoozedCount,
428 snoozedShelfExpanded,447 snoozedShelfExpanded,
429 snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex,448 snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex,
...@@ -457,6 +476,7 @@ function ThreadNavigationSidebarPane(...@@ -457,6 +476,7 @@ function ThreadNavigationSidebarPane(
457 snoozedShelfExpanded,476 snoozedShelfExpanded,
458 snoozeEnvironmentIds,477 snoozeEnvironmentIds,
459 threadListV2Layout,478 threadListV2Layout,
479 workingShelfExpanded,
460 ]);480 ]);
461 const listMenuActions = useMemo<MenuAction[]>(481 const listMenuActions = useMemo<MenuAction[]>(
462 () => [482 () => [
...@@ -587,6 +607,8 @@ function ThreadNavigationSidebarPane(...@@ -587,6 +607,8 @@ function ThreadNavigationSidebarPane(
587 savedConnectionsById,607 savedConnectionsById,
588 listEnvironments,608 listEnvironments,
589 threadSearchMatchByKey,609 threadSearchMatchByKey,
610 // Rows read it for their reorder menu items.
611 workingShelfEnabled,
590 }),612 }),
591 [613 [
592 props.selectedThreadKey,614 props.selectedThreadKey,
...@@ -595,6 +617,7 @@ function ThreadNavigationSidebarPane(...@@ -595,6 +617,7 @@ function ThreadNavigationSidebarPane(
595 savedConnectionsById,617 savedConnectionsById,
596 listEnvironments,618 listEnvironments,
597 threadSearchMatchByKey,619 threadSearchMatchByKey,
620 workingShelfEnabled,
598 ],621 ],
599 );622 );
600 useThreadJumpShortcuts(listItems, handleSelectThread);623 useThreadJumpShortcuts(listItems, handleSelectThread);
...@@ -712,7 +735,7 @@ function ThreadNavigationSidebarPane(...@@ -712,7 +735,7 @@ function ThreadNavigationSidebarPane(
712 reorderSupported={735 reorderSupported={
713 item.item.pinned736 item.item.pinned
714 ? pinReorderEnvironmentIds.has(thread.environmentId)737 ? pinReorderEnvironmentIds.has(thread.environmentId)
715 : activeReorderEnvironmentIds.has(thread.environmentId)738 : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId)
716 }739 }
717 canMoveUp={item.canMoveUp}740 canMoveUp={item.canMoveUp}
718 canMoveDown={item.canMoveDown}741 canMoveDown={item.canMoveDown}
...@@ -729,6 +752,16 @@ function ThreadNavigationSidebarPane(...@@ -729,6 +752,16 @@ function ThreadNavigationSidebarPane(
729 />752 />
730 );753 );
731 }754 }
755 case "v2-working-shelf":
756 return (
757 <ThreadListV2WorkingShelfHeader
758 count={item.count}
759 disabled={item.disabled}
760 expanded={item.expanded}
761 onToggle={toggleWorkingShelf}
762 pane="sidebar"
763 />
764 );
732 case "v2-snoozed-shelf":765 case "v2-snoozed-shelf":
733 return (766 return (
734 <ThreadListV2SnoozedShelfHeader767 <ThreadListV2SnoozedShelfHeader
...@@ -797,9 +830,11 @@ function ThreadNavigationSidebarPane(...@@ -797,9 +830,11 @@ function ThreadNavigationSidebarPane(
797 snoozeThread,830 snoozeThread,
798 toggleSettledShelf,831 toggleSettledShelf,
799 toggleSnoozedShelf,832 toggleSnoozedShelf,
833 toggleWorkingShelf,
800 unpinThread,834 unpinThread,
801 unsettleThread,835 unsettleThread,
802 unsnoozeThread,836 unsnoozeThread,
837 workingShelfEnabled,
803 ],838 ],
804 );839 );
805 // The list ignores sort/group options, so only the environment and project840 // The list ignores sort/group options, so only the environment and project
apps/mobile/src/features/threads/thread-list-v2-items.tsx+10-2
...@@ -191,10 +191,12 @@ type ThreadListV2ShelfHeaderProps = {...@@ -191,10 +191,12 @@ type ThreadListV2ShelfHeaderProps = {
191 readonly pane?: "screen" | "sidebar";191 readonly pane?: "screen" | "sidebar";
192};192};
193193
194const SHELF_LABEL = { working: "Working", snoozed: "Snoozed", settled: "Settled" } as const;
195
194function ThreadListV2ShelfHeader(196function ThreadListV2ShelfHeader(
195 props: ThreadListV2ShelfHeaderProps & { readonly kind: "snoozed" | "settled" },197 props: ThreadListV2ShelfHeaderProps & { readonly kind: keyof typeof SHELF_LABEL },
196) {198) {
197 const label = props.kind === "snoozed" ? "Snoozed" : "Settled";199 const label = SHELF_LABEL[props.kind];
198 return (200 return (
199 <ThreadListV2Section201 <ThreadListV2Section
200 label={props.expanded ? label : `${label} (${props.count})`}202 label={props.expanded ? label : `${label} (${props.count})`}
...@@ -211,6 +213,12 @@ function ThreadListV2ShelfHeader(...@@ -211,6 +213,12 @@ function ThreadListV2ShelfHeader(
211 );213 );
212}214}
213215
216export const ThreadListV2WorkingShelfHeader = memo(function ThreadListV2WorkingShelfHeader(
217 props: ThreadListV2ShelfHeaderProps,
218) {
219 return <ThreadListV2ShelfHeader {...props} kind="working" />;
220});
221
214export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(222export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(
215 props: ThreadListV2ShelfHeaderProps,223 props: ThreadListV2ShelfHeaderProps,
216) {224) {
apps/mobile/src/features/threads/threadListV2.test.ts+144
...@@ -2158,3 +2158,147 @@ describe("buildThreadListV2ListItems row-state stamps", () => {...@@ -2158,3 +2158,147 @@ describe("buildThreadListV2ListItems row-state stamps", () => {
2158 expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false);2158 expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false);
2159 });2159 });
2160});2160});
2161
2162describe("Working section beta", () => {
2163 const running = {
2164 status: "running" as const,
2165 activeRunId: null,
2166 providerInstanceId: ProviderInstanceId.make("codex"),
2167 providerName: "Codex",
2168 lastError: null,
2169 updatedAt: NOW,
2170 };
2171 const finishedAt = (id: string, completedAt: string) =>
2172 makeThread({
2173 id: ThreadId.make(id),
2174 title: id,
2175 createdAt: "2026-06-01T00:00:00.000Z",
2176 latestRun: {
2177 runId: RunId.make(`run-${id}`),
2178 status: "completed",
2179 requestedAt: "2026-06-01T00:00:00.000Z",
2180 startedAt: "2026-06-01T00:00:00.000Z",
2181 completedAt,
2182 assistantMessageId: null,
2183 },
2184 });
2185 const threads = [
2186 finishedAt("finished-early", "2026-06-01T01:00:00.000Z"),
2187 finishedAt("finished-late", "2026-06-01T03:00:00.000Z"),
2188 makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }),
2189 makeThread({
2190 id: ThreadId.make("asks-approval"),
2191 title: "asks-approval",
2192 createdAt: "2026-06-01T02:00:00.000Z",
2193 runtime: running,
2194 hasPendingApprovals: true,
2195 }),
2196 makeThread({
2197 id: ThreadId.make("pinned-working"),
2198 title: "pinned-working",
2199 runtime: running,
2200 pinnedAt: "2026-06-01T00:00:00.000Z",
2201 }),
2202 ];
2203 const build = (input: Partial<Parameters<typeof buildThreadListV2Items>[0]> = {}) =>
2204 buildThreadListV2Items({
2205 threads,
2206 environmentId: null,
2207 searchQuery: "",
2208 now: NOW,
2209 workingShelfEnabled: true,
2210 ...input,
2211 });
2212 const ids = (layout: ReturnType<typeof buildThreadListV2Items>) =>
2213 layout.items.map((item) => item.thread.id);
2214
2215 it("folds unpinned working threads into a collapsed shelf", () => {
2216 const layout = build();
2217 expect(ids(layout)).toEqual([
2218 "pinned-working",
2219 "finished-late",
2220 "asks-approval",
2221 "finished-early",
2222 ]);
2223 expect(layout.workingCount).toBe(1);
2224 expect(layout.workingShelfHeaderIndex).toBe(4);
2225
2226 const off = build({ workingShelfEnabled: false });
2227 expect(ids(off)).toContain("working");
2228 expect(off.workingCount).toBe(0);
2229 });
2230
2231 it("shows working rows as cards when expanded, or only the selected one when collapsed", () => {
2232 expect(build({ workingShelfExpanded: true }).items.at(-1)).toMatchObject({
2233 thread: { id: "working" },
2234 variant: "card",
2235 });
2236 expect(ids(build({ selectedThreadKey: `${environmentId}:working` })).at(-1)).toBe("working");
2237 });
2238
2239 it("orders the inbox by the latest return this device observed", () => {
2240 const layout = build({
2241 inboxReturnAt: (thread) =>
2242 thread.id === "finished-early" ? Date.parse("2026-06-01T04:00:00.000Z") : undefined,
2243 });
2244 expect(ids(layout).slice(1)).toEqual(["finished-early", "finished-late", "asks-approval"]);
2245 });
2246
2247 it("places the shelf after queued tasks and before snoozed and settled threads", () => {
2248 const layout = buildThreadListV2Items({
2249 threads: [
2250 makeThread({ id: ThreadId.make("active"), title: "active" }),
2251 makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }),
2252 makeThread({
2253 id: ThreadId.make("snoozed"),
2254 title: "snoozed",
2255 runtime: running,
2256 snoozedUntil: "2026-06-03T09:00:00.000Z",
2257 snoozedAt: "2026-06-01T12:00:00.000Z",
2258 }),
2259 makeThread({
2260 id: ThreadId.make("settled"),
2261 title: "settled",
2262 settledOverride: "settled",
2263 settledAt: NOW,
2264 }),
2265 ],
2266 environmentId: null,
2267 searchQuery: "",
2268 now: NOW,
2269 workingShelfEnabled: true,
2270 workingShelfExpanded: true,
2271 snoozedShelfExpanded: true,
2272 });
2273 const items = buildThreadListV2ListItems({
2274 items: layout.items,
2275 pendingTasks: [makePendingTask("queued")],
2276 workingCount: layout.workingCount,
2277 workingShelfExpanded: true,
2278 workingShelfHeaderIndex: layout.workingShelfHeaderIndex,
2279 snoozedCount: layout.snoozedCount,
2280 snoozedShelfExpanded: true,
2281 snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex,
2282 settledCount: layout.settledCount,
2283 settledShelfHeaderIndex: layout.settledShelfHeaderIndex,
2284 });
2285 expect(
2286 items.map((item) =>
2287 item.type === "v2-thread"
2288 ? item.item.thread.id
2289 : item.type === "v2-pending"
2290 ? item.pendingTask.title
2291 : item.type,
2292 ),
2293 ).toEqual([
2294 "active",
2295 "queued",
2296 "v2-working-shelf",
2297 "working",
2298 "v2-snoozed-shelf",
2299 "snoozed",
2300 "v2-settled-shelf",
2301 "settled",
2302 ]);
2303 });
2304});
apps/mobile/src/features/threads/threadListV2.ts+91-5
...@@ -11,6 +11,11 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"...@@ -11,6 +11,11 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"
11import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";11import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
12import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models";12import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models";
13import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";13import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
14import {
15 createInboxReturnTracker,
16 isThreadWorking,
17 sortInboxThreadsByReturn,
18} from "@t3tools/client-runtime/state/thread-inbox";
14import {19import {
15 sortActiveThreadsByOrderKey,20 sortActiveThreadsByOrderKey,
16 resolveSettledThreadTimestamp,21 resolveSettledThreadTimestamp,
...@@ -33,6 +38,11 @@ import {...@@ -33,6 +38,11 @@ import {
3338
34export { snoozeWakeLabel };39export { snoozeWakeLabel };
3540
41/** Working section beta: when this device saw each thread leave the Working
42 shelf. One instance for Home and the iPad sidebar, so both lists order the
43 inbox the same way and the order survives screens that unmount. */
44export const threadListInboxReturns = createInboxReturnTracker();
45
36/**46/**
37 * Provider drivers for a row's trailing icon stack, back to front. Instances47 * Provider drivers for a row's trailing icon stack, back to front. Instances
38 * missing from the environment's config are skipped, and an unresolved48 * missing from the environment's config are skipped, and an unresolved
...@@ -269,6 +279,10 @@ export interface ThreadListV2Layout {...@@ -269,6 +279,10 @@ export interface ThreadListV2Layout {
269 readonly items: ThreadListV2Item[];279 readonly items: ThreadListV2Item[];
270 /** Settled threads beyond the render limit (behind "Show more"). */280 /** Settled threads beyond the render limit (behind "Show more"). */
271 readonly hiddenSettledCount: number;281 readonly hiddenSettledCount: number;
282 /** Working threads folded away by the Working section beta. */
283 readonly workingCount: number;
284 /** Index in `items` where the Working shelf header belongs. */
285 readonly workingShelfHeaderIndex: number | null;
272 /** Snoozed threads matching the current filters. */286 /** Snoozed threads matching the current filters. */
273 readonly snoozedCount: number;287 readonly snoozedCount: number;
274 /** Index in `items` where the Snoozed shelf header belongs. The header is288 /** Index in `items` where the Snoozed shelf header belongs. The header is
...@@ -325,6 +339,15 @@ export interface ThreadListV2PendingListItem {...@@ -325,6 +339,15 @@ export interface ThreadListV2PendingListItem {
325 readonly showTrailingDivider: boolean;339 readonly showTrailingDivider: boolean;
326}340}
327341
342export interface ThreadListV2WorkingShelfListItem {
343 readonly type: "v2-working-shelf";
344 readonly key: "v2-working-shelf";
345 readonly count: number;
346 readonly expanded: boolean;
347 /** See the snoozed shelf header's field. */
348 readonly disabled: boolean;
349}
350
328export interface ThreadListV2SnoozedShelfListItem {351export interface ThreadListV2SnoozedShelfListItem {
329 readonly type: "v2-snoozed-shelf";352 readonly type: "v2-snoozed-shelf";
330 readonly key: "v2-snoozed-shelf";353 readonly key: "v2-snoozed-shelf";
...@@ -349,6 +372,7 @@ export interface ThreadListV2SettledShelfListItem {...@@ -349,6 +372,7 @@ export interface ThreadListV2SettledShelfListItem {
349export type ThreadListV2ListItem =372export type ThreadListV2ListItem =
350 | ThreadListV2ThreadListItem373 | ThreadListV2ThreadListItem
351 | ThreadListV2PendingListItem374 | ThreadListV2PendingListItem
375 | ThreadListV2WorkingShelfListItem
352 | ThreadListV2SnoozedShelfListItem376 | ThreadListV2SnoozedShelfListItem
353 | ThreadListV2SettledShelfListItem;377 | ThreadListV2SettledShelfListItem;
354378
...@@ -360,6 +384,7 @@ export function isThreadListV2ListItem(value: {...@@ -360,6 +384,7 @@ export function isThreadListV2ListItem(value: {
360 return (384 return (
361 value.type === "v2-thread" ||385 value.type === "v2-thread" ||
362 value.type === "v2-pending" ||386 value.type === "v2-pending" ||
387 value.type === "v2-working-shelf" ||
363 value.type === "v2-snoozed-shelf" ||388 value.type === "v2-snoozed-shelf" ||
364 value.type === "v2-settled-shelf"389 value.type === "v2-settled-shelf"
365 );390 );
...@@ -401,6 +426,13 @@ export function threadListV2ListItemsAreEqual(...@@ -401,6 +426,13 @@ export function threadListV2ListItemsAreEqual(
401 previous.showPendingDivider === item.showPendingDivider &&426 previous.showPendingDivider === item.showPendingDivider &&
402 previous.showTrailingDivider === item.showTrailingDivider427 previous.showTrailingDivider === item.showTrailingDivider
403 );428 );
429 case "v2-working-shelf":
430 return (
431 previous.type === "v2-working-shelf" &&
432 previous.count === item.count &&
433 previous.expanded === item.expanded &&
434 previous.disabled === item.disabled
435 );
404 case "v2-snoozed-shelf":436 case "v2-snoozed-shelf":
405 return (437 return (
406 previous.type === "v2-snoozed-shelf" &&438 previous.type === "v2-snoozed-shelf" &&
...@@ -441,13 +473,17 @@ function resolveThreadListV2ItemTimeLabel(...@@ -441,13 +473,17 @@ function resolveThreadListV2ItemTimeLabel(
441}473}
442474
443/**475/**
444 * Builds the shared mobile order: active → pending → snoozed shelf → settled.476 * Builds the shared mobile order: active → pending → working shelf (beta) →
445 * Pending tasks are waiting rather than asking, and parked work remains477 * snoozed shelf → settled. Pending tasks are waiting rather than asking, and
446 * reachable without competing with either the inbox or settled history.478 * busy or parked work remains reachable without competing with either the
479 * inbox or settled history.
447 */480 */
448export function buildThreadListV2ListItems(input: {481export function buildThreadListV2ListItems(input: {
449 readonly items: ReadonlyArray<ThreadListV2Item>;482 readonly items: ReadonlyArray<ThreadListV2Item>;
450 readonly pendingTasks: ReadonlyArray<PendingNewTask>;483 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
484 readonly workingCount?: number;
485 readonly workingShelfExpanded?: boolean;
486 readonly workingShelfHeaderIndex?: number | null;
451 readonly snoozedCount?: number;487 readonly snoozedCount?: number;
452 readonly snoozedShelfExpanded?: boolean;488 readonly snoozedShelfExpanded?: boolean;
453 readonly snoozedShelfHeaderIndex?: number | null;489 readonly snoozedShelfHeaderIndex?: number | null;
...@@ -514,14 +550,27 @@ export function buildThreadListV2ListItems(input: {...@@ -514,14 +550,27 @@ export function buildThreadListV2ListItems(input: {
514 showPendingDivider: index === 0,550 showPendingDivider: index === 0,
515 showTrailingDivider: false,551 showTrailingDivider: false,
516 }));552 }));
553 const workingCount = input.workingCount ?? 0;
554 const workingShelfHeaderIndex = input.workingShelfHeaderIndex ?? null;
517 const snoozedCount = input.snoozedCount ?? 0;555 const snoozedCount = input.snoozedCount ?? 0;
518 const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null;556 const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null;
519 const settledCount = input.settledCount ?? 0;557 const settledCount = input.settledCount ?? 0;
520 const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null;558 const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null;
521 const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length;
522 const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;559 const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;
560 const workingEnd = snoozedShelfHeaderIndex ?? snoozedEnd;
561 const activeEnd = workingShelfHeaderIndex ?? workingEnd;
523 const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems];562 const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems];
524 const shelfDisabled = input.shelfPreferencesLoading === true;563 const shelfDisabled = input.shelfPreferencesLoading === true;
564 if (workingShelfHeaderIndex !== null && workingCount > 0) {
565 result.push({
566 type: "v2-working-shelf",
567 key: "v2-working-shelf",
568 count: workingCount,
569 expanded: input.workingShelfExpanded === true,
570 disabled: shelfDisabled,
571 });
572 result.push(...threadItems.slice(workingShelfHeaderIndex, workingEnd));
573 }
525 if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {574 if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {
526 result.push({575 result.push({
527 type: "v2-snoozed-shelf",576 type: "v2-snoozed-shelf",
...@@ -580,6 +629,15 @@ export function buildThreadListV2Items(input: {...@@ -580,6 +629,15 @@ export function buildThreadListV2Items(input: {
580 readonly settledLimit?: number;629 readonly settledLimit?: number;
581 /** Second-precise clock used for time-based classification. */630 /** Second-precise clock used for time-based classification. */
582 readonly now: string;631 readonly now: string;
632 /** Working section beta: unpinned working threads fold into the Working
633 shelf, and the inbox orders by when each thread came back to the user
634 instead of the saved arrangement. */
635 readonly workingShelfEnabled?: boolean;
636 /** Expands the Working shelf into rows. Collapsed is the default. */
637 readonly workingShelfExpanded?: boolean;
638 /** Returns this device observed but the server does not stamp, such as an
639 approval request mid-turn. Only read while the beta is on. */
640 readonly inboxReturnAt?: (thread: EnvironmentThreadShell) => number | undefined;
583 /** Expands the snoozed shelf into rows. Collapsed is the default. */641 /** Expands the snoozed shelf into rows. Collapsed is the default. */
584 readonly snoozedShelfExpanded?: boolean;642 readonly snoozedShelfExpanded?: boolean;
585 /** Expands the settled shelf into rows. Expanded is the default. */643 /** Expands the settled shelf into rows. Expanded is the default. */
...@@ -609,8 +667,10 @@ export function buildThreadListV2Items(input: {...@@ -609,8 +667,10 @@ export function buildThreadListV2Items(input: {
609 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))667 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
610 : null;668 : null;
611669
670 const workingShelfEnabled = input.workingShelfEnabled === true;
612 const pinned: EnvironmentThreadShell[] = [];671 const pinned: EnvironmentThreadShell[] = [];
613 const active: EnvironmentThreadShell[] = [];672 const active: EnvironmentThreadShell[] = [];
673 const working: EnvironmentThreadShell[] = [];
614 const settled: EnvironmentThreadShell[] = [];674 const settled: EnvironmentThreadShell[] = [];
615 const snoozed: EnvironmentThreadShell[] = [];675 const snoozed: EnvironmentThreadShell[] = [];
616 let nextSnoozeWakeAt: string | null = null;676 let nextSnoozeWakeAt: string | null = null;
...@@ -656,17 +716,31 @@ export function buildThreadListV2Items(input: {...@@ -656,17 +716,31 @@ export function buildThreadListV2Items(input: {
656 settled.push(thread);716 settled.push(thread);
657 } else if (thread.pinnedAt != null) {717 } else if (thread.pinnedAt != null) {
658 pinned.push(thread);718 pinned.push(thread);
719 } else if (workingShelfEnabled && isThreadWorking(thread)) {
720 working.push(thread);
659 } else {721 } else {
660 active.push(thread);722 active.push(thread);
661 }723 }
662 }724 }
663725
664 const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending);726 // The beta inbox is time-ordered, so the saved arrangement (and any move in
727 // flight) is kept but not applied until the beta is off again.
728 const orderedActive = workingShelfEnabled
729 ? sortInboxThreadsByReturn(active, input.inboxReturnAt)
730 : applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending);
731 // Newest work first, by the same clock as the inbox.
732 const orderedWorking = sortInboxThreadsByReturn(working);
665 const orderedSnoozed = [...snoozed].sort(733 const orderedSnoozed = [...snoozed].sort(
666 (left, right) =>734 (left, right) =>
667 parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),735 parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),
668 );736 );
669 const selectedThreadKey = input.selectedThreadKey ?? null;737 const selectedThreadKey = input.selectedThreadKey ?? null;
738 const visibleWorking =
739 input.workingShelfExpanded === true
740 ? orderedWorking
741 : orderedWorking.filter(
742 (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
743 );
670 const visibleSnoozed =744 const visibleSnoozed =
671 input.snoozedShelfExpanded === true745 input.snoozedShelfExpanded === true
672 ? orderedSnoozed746 ? orderedSnoozed
...@@ -711,6 +785,16 @@ export function buildThreadListV2Items(input: {...@@ -711,6 +785,16 @@ export function buildThreadListV2Items(input: {
711 isLast: false,785 isLast: false,
712 });786 });
713 }787 }
788 const workingShelfHeaderIndex = orderedWorking.length > 0 ? items.length : null;
789 for (const thread of visibleWorking) {
790 items.push({
791 thread,
792 variant: "card",
793 snoozed: false,
794 pinned: false,
795 isLast: false,
796 });
797 }
714 const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null;798 const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null;
715 for (const thread of visibleSnoozed) {799 for (const thread of visibleSnoozed) {
716 items.push({800 items.push({
...@@ -738,6 +822,8 @@ export function buildThreadListV2Items(input: {...@@ -738,6 +822,8 @@ export function buildThreadListV2Items(input: {
738 return {822 return {
739 items,823 items,
740 hiddenSettledCount: orderedSettled.length - pagedSettled.length,824 hiddenSettledCount: orderedSettled.length - pagedSettled.length,
825 workingCount: orderedWorking.length,
826 workingShelfHeaderIndex,
741 snoozedCount: orderedSnoozed.length,827 snoozedCount: orderedSnoozed.length,
742 snoozedShelfHeaderIndex,828 snoozedShelfHeaderIndex,
743 settledCount: orderedSettled.length,829 settledCount: orderedSettled.length,
apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts+15
...@@ -17,10 +17,16 @@ export function useThreadListV2ShelfPreferences() {...@@ -17,10 +17,16 @@ export function useThreadListV2ShelfPreferences() {
17 loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true;17 loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true;
18 const settledShelfExpanded =18 const settledShelfExpanded =
19 loaded && preferencesResult.value.threadListSettledShelfExpanded === true;19 loaded && preferencesResult.value.threadListSettledShelfExpanded === true;
20 // Working section beta: off until the preference loads and is enabled.
21 const workingShelfEnabled = loaded && preferencesResult.value.workingShelfEnabled === true;
22 const workingShelfExpanded =
23 loaded && preferencesResult.value.threadListWorkingShelfExpanded === true;
20 const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded);24 const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded);
21 const settledShelfExpandedRef = useRef(settledShelfExpanded);25 const settledShelfExpandedRef = useRef(settledShelfExpanded);
26 const workingShelfExpandedRef = useRef(workingShelfExpanded);
22 snoozedShelfExpandedRef.current = snoozedShelfExpanded;27 snoozedShelfExpandedRef.current = snoozedShelfExpanded;
23 settledShelfExpandedRef.current = settledShelfExpanded;28 settledShelfExpandedRef.current = settledShelfExpanded;
29 workingShelfExpandedRef.current = workingShelfExpanded;
2430
25 const toggleSnoozedShelf = useCallback(() => {31 const toggleSnoozedShelf = useCallback(() => {
26 if (!loaded) return;32 if (!loaded) return;
...@@ -34,12 +40,21 @@ export function useThreadListV2ShelfPreferences() {...@@ -34,12 +40,21 @@ export function useThreadListV2ShelfPreferences() {
34 settledShelfExpandedRef.current = expanded;40 settledShelfExpandedRef.current = expanded;
35 savePreferences({ threadListSettledShelfExpanded: expanded });41 savePreferences({ threadListSettledShelfExpanded: expanded });
36 }, [loaded, savePreferences]);42 }, [loaded, savePreferences]);
43 const toggleWorkingShelf = useCallback(() => {
44 if (!loaded) return;
45 const expanded = !workingShelfExpandedRef.current;
46 workingShelfExpandedRef.current = expanded;
47 savePreferences({ threadListWorkingShelfExpanded: expanded });
48 }, [loaded, savePreferences]);
3749
38 return {50 return {
39 loaded,51 loaded,
40 settledShelfExpanded,52 settledShelfExpanded,
41 snoozedShelfExpanded,53 snoozedShelfExpanded,
54 workingShelfEnabled,
55 workingShelfExpanded,
42 toggleSettledShelf,56 toggleSettledShelf,
43 toggleSnoozedShelf,57 toggleSnoozedShelf,
58 toggleWorkingShelf,
44 } as const;59 } as const;
45}60}
apps/mobile/src/lib/storage.test.ts+9-1
...@@ -232,24 +232,32 @@ describe("mobile connection storage", () => {...@@ -232,24 +232,32 @@ describe("mobile connection storage", () => {
232 expect(fallback.updatedAt).toEqual(expect.any(Number));232 expect(fallback.updatedAt).toEqual(expect.any(Number));
233 });233 });
234234
235 it("persists thread list shelf expansion preferences", async () => {235 it("persists thread list shelf preferences", async () => {
236 await expect(236 await expect(
237 savePreferencesPatch({237 savePreferencesPatch({
238 threadListSettledShelfExpanded: false,238 threadListSettledShelfExpanded: false,
239 threadListSnoozedShelfExpanded: true,239 threadListSnoozedShelfExpanded: true,
240 threadListWorkingShelfExpanded: true,
241 workingShelfEnabled: true,
240 }),242 }),
241 ).resolves.toEqual({243 ).resolves.toEqual({
242 threadListSettledShelfExpanded: false,244 threadListSettledShelfExpanded: false,
243 threadListSnoozedShelfExpanded: true,245 threadListSnoozedShelfExpanded: true,
246 threadListWorkingShelfExpanded: true,
247 workingShelfEnabled: true,
244 });248 });
245249
246 await expect(loadPreferences()).resolves.toEqual({250 await expect(loadPreferences()).resolves.toEqual({
247 threadListSettledShelfExpanded: false,251 threadListSettledShelfExpanded: false,
248 threadListSnoozedShelfExpanded: true,252 threadListSnoozedShelfExpanded: true,
253 threadListWorkingShelfExpanded: true,
254 workingShelfEnabled: true,
249 });255 });
250 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({256 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({
251 threadListSettledShelfExpanded: false,257 threadListSettledShelfExpanded: false,
252 threadListSnoozedShelfExpanded: true,258 threadListSnoozedShelfExpanded: true,
259 threadListWorkingShelfExpanded: true,
260 workingShelfEnabled: true,
253 });261 });
254 });262 });
255263
apps/mobile/src/persistence/mobile-preferences.ts+11
...@@ -41,6 +41,8 @@ export interface Preferences {...@@ -41,6 +41,8 @@ export interface Preferences {
41 readonly projectGroupingMode?: SidebarProjectGroupingMode;41 readonly projectGroupingMode?: SidebarProjectGroupingMode;
42 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */42 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */
43 readonly planModeEnabled?: boolean;43 readonly planModeEnabled?: boolean;
44 /** Device-local counterpart of web's `sidebarWorkingShelfEnabled` beta. */
45 readonly workingShelfEnabled?: boolean;
44 /** Model favorites belong to this device, like the web client setting. */46 /** Model favorites belong to this device, like the web client setting. */
45 readonly modelFavorites?: ReadonlyArray<{47 readonly modelFavorites?: ReadonlyArray<{
46 readonly provider: ProviderInstanceId;48 readonly provider: ProviderInstanceId;
...@@ -49,6 +51,7 @@ export interface Preferences {...@@ -49,6 +51,7 @@ export interface Preferences {
49 /** Fresh keys reset both shelves to collapsed when users update. */51 /** Fresh keys reset both shelves to collapsed when users update. */
50 readonly threadListSettledShelfExpanded?: boolean;52 readonly threadListSettledShelfExpanded?: boolean;
51 readonly threadListSnoozedShelfExpanded?: boolean;53 readonly threadListSnoozedShelfExpanded?: boolean;
54 readonly threadListWorkingShelfExpanded?: boolean;
52}55}
5356
54export class MobilePreferencesLoadError extends Schema.TaggedError<MobilePreferencesLoadError>()(57export class MobilePreferencesLoadError extends Schema.TaggedError<MobilePreferencesLoadError>()(
...@@ -107,9 +110,11 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -107,9 +110,11 @@ function sanitizePreferences(parsed: Preferences): Preferences {
107 projectGroupingEnabled?: boolean;110 projectGroupingEnabled?: boolean;
108 projectGroupingMode?: SidebarProjectGroupingMode;111 projectGroupingMode?: SidebarProjectGroupingMode;
109 planModeEnabled?: boolean;112 planModeEnabled?: boolean;
113 workingShelfEnabled?: boolean;
110 modelFavorites?: Preferences["modelFavorites"];114 modelFavorites?: Preferences["modelFavorites"];
111 threadListSettledShelfExpanded?: boolean;115 threadListSettledShelfExpanded?: boolean;
112 threadListSnoozedShelfExpanded?: boolean;116 threadListSnoozedShelfExpanded?: boolean;
117 threadListWorkingShelfExpanded?: boolean;
113 } = {};118 } = {};
114119
115 if (typeof parsed.liveActivitiesEnabled === "boolean") {120 if (typeof parsed.liveActivitiesEnabled === "boolean") {
...@@ -180,6 +185,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -180,6 +185,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
180 if (typeof parsed.planModeEnabled === "boolean") {185 if (typeof parsed.planModeEnabled === "boolean") {
181 preferences.planModeEnabled = parsed.planModeEnabled;186 preferences.planModeEnabled = parsed.planModeEnabled;
182 }187 }
188 if (typeof parsed.workingShelfEnabled === "boolean") {
189 preferences.workingShelfEnabled = parsed.workingShelfEnabled;
190 }
183 if (Array.isArray(parsed.modelFavorites)) {191 if (Array.isArray(parsed.modelFavorites)) {
184 preferences.modelFavorites = parsed.modelFavorites.filter(192 preferences.modelFavorites = parsed.modelFavorites.filter(
185 (favorite) =>193 (favorite) =>
...@@ -197,6 +205,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {...@@ -197,6 +205,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
197 if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") {205 if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") {
198 preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded;206 preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded;
199 }207 }
208 if (typeof parsed.threadListWorkingShelfExpanded === "boolean") {
209 preferences.threadListWorkingShelfExpanded = parsed.threadListWorkingShelfExpanded;
210 }
200 return preferences;211 return preferences;
201}212}
202213
apps/web/src/components/Sidebar.logic.ts+5-45
...@@ -1008,21 +1008,6 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea...@@ -1008,21 +1008,6 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea
1008 return status === "working";1008 return status === "working";
1009}1009}
10101010
1011/** Working beta: threads busy with work that does not need the user fold into
1012 the Working shelf: a running run, or one stopped with background work
1013 that will wake it. Approvals, questions, plan prompts, and failures stay in the
1014 inbox. */
1015export function isSidebarThreadWorking(thread: ThreadStatusInput): boolean {
1016 const status = resolveSidebarThreadStatus(thread);
1017 if (status !== "working" && status !== "waiting") return false;
1018 // A plan prompt outranks lingering background work: the user has to act on it.
1019 return !(
1020 thread.interactionMode === "plan" &&
1021 thread.hasActionableProposedPlan &&
1022 isLatestRunSettled(thread.latestRun, thread.runtime)
1023 );
1024}
1025
1026/** First VALID timestamp wins: `a ?? b` falls through on null, but a present-1011/** First VALID timestamp wins: `a ?? b` falls through on null, but a present-
1027 yet-malformed string must also fall through to the next candidate rather1012 yet-malformed string must also fall through to the next candidate rather
1028 than sink the row to the epoch. */1013 than sink the row to the epoch. */
...@@ -1038,6 +1023,11 @@ export function firstValidTimestampMs(...@@ -1038,6 +1023,11 @@ export function firstValidTimestampMs(
1038}1023}
10391024
1040export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";1025export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
1026// The Working section beta folds and orders the inbox the same way on mobile.
1027export {
1028 isThreadWorking as isSidebarThreadWorking,
1029 sortInboxThreadsByReturn,
1030} from "@t3tools/client-runtime/state/thread-inbox";
10411031
1042// Pinned-reorder key math and the keyed sort live in client-runtime1032// Pinned-reorder key math and the keyed sort live in client-runtime
1043// (state/thread-sort) so web and mobile compute identical pinned orders.1033// (state/thread-sort) so web and mobile compute identical pinned orders.
...@@ -1119,36 +1109,6 @@ export function reduceSidebarProjectScopeMenuState(...@@ -1119,36 +1109,6 @@ export function reduceSidebarProjectScopeMenuState(
1119 }1109 }
1120}1110}
11211111
1122/** Working beta: the inbox lists threads newest first by when each last came
1123 back to the user, so a thread that leaves the Working shelf lands on top.
1124 `observedReturnAt` adds returns the server does not stamp, such as an
1125 approval request mid-turn or background work ending. */
1126export function sortInboxThreadsByReturn<
1127 T extends Pick<
1128 SidebarThreadSummary,
1129 "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestRun"
1130 >,
1131>(threads: readonly T[], observedReturnAt?: (thread: T) => number | undefined): T[] {
1132 const timestamps = new Map(
1133 threads.map((thread) => [
1134 thread,
1135 Math.max(
1136 toSortableTimestamp(thread.createdAt) ?? 0,
1137 toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0,
1138 toSortableTimestamp(thread.latestRun?.requestedAt ?? undefined) ?? 0,
1139 toSortableTimestamp(thread.latestRun?.completedAt ?? undefined) ?? 0,
1140 observedReturnAt?.(thread) ?? 0,
1141 ),
1142 ]),
1143 );
1144 return [...threads].sort(
1145 (left, right) =>
1146 timestamps.get(right)! - timestamps.get(left)! ||
1147 left.id.localeCompare(right.id) ||
1148 left.environmentId.localeCompare(right.environmentId),
1149 );
1150}
1151
1152/** The timestamp a working thread's elapsed label counts from: when its1112/** The timestamp a working thread's elapsed label counts from: when its
1153 current work started (request time until adoption). Background wakes do1113 current work started (request time until adoption). Background wakes do
1154 not reset it. Malformed timestamps fall through to the next candidate. */1114 not reset it. Malformed timestamps fall through to the next candidate. */
apps/web/src/components/Sidebar.tsx+5-36
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31 effectiveSnoozed,31 effectiveSnoozed,
32 threadWokeAt,32 threadWokeAt,
33} from "@t3tools/client-runtime/state/thread-settled";33} from "@t3tools/client-runtime/state/thread-settled";
34import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox";
34import {35import {
35 resolveSettledThreadTimestamp,36 resolveSettledThreadTimestamp,
36 sortSettledThreads,37 sortSettledThreads,
...@@ -289,35 +290,7 @@ const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded";...@@ -289,35 +290,7 @@ const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded";
289290
290// Working beta: when this client saw each thread leave the Working shelf.291// Working beta: when this client saw each thread leave the Working shelf.
291// Module scope keeps the inbox order across routes that unmount the sidebar.292// Module scope keeps the inbox order across routes that unmount the sidebar.
292let lastWorkingThreadKeys: ReadonlySet<string> | null = null;293const inboxReturns = createInboxReturnTracker();
293const observedInboxReturns = new Map<string, number>();
294
295/** Stamps threads that stopped working since the last call. The first call
296 only takes a baseline, so mounting never reshuffles the inbox. Pass null
297 to reset when the beta is off. */
298function observeInboxReturns(threads: readonly EnvironmentThreadShell[] | null): void {
299 if (threads === null) {
300 lastWorkingThreadKeys = null;
301 observedInboxReturns.clear();
302 return;
303 }
304 const working = new Set<string>();
305 const present = new Set<string>();
306 for (const thread of threads) {
307 const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
308 present.add(key);
309 if (isSidebarThreadWorking(thread)) working.add(key);
310 }
311 // Drop deleted threads so the map stays bounded by the live thread list.
312 for (const key of observedInboxReturns.keys()) {
313 if (!present.has(key)) observedInboxReturns.delete(key);
314 }
315 const now = Date.now();
316 for (const key of lastWorkingThreadKeys ?? []) {
317 if (present.has(key) && !working.has(key)) observedInboxReturns.set(key, now);
318 }
319 lastWorkingThreadKeys = working;
320}
321294
322function compactSidebarTimeLabel(label: string): string {295function compactSidebarTimeLabel(label: string): string {
323 if (label === "just now") return "now";296 if (label === "just now") return "now";
...@@ -2669,7 +2642,7 @@ export default function Sidebar() {...@@ -2669,7 +2642,7 @@ export default function Sidebar() {
2669 // Subagent child threads live in the parent's Agents surface, not the2642 // Subagent child threads live in the parent's Agents surface, not the
2670 // sidebar roster (v2 models them as real threads with lineage).2643 // sidebar roster (v2 models them as real threads with lineage).
2671 const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys);2644 const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys);
2672 observeInboxReturns(workingShelfEnabled ? threads : null);2645 inboxReturns.observe(workingShelfEnabled ? threads : null);
2673 const pinned: EnvironmentThreadShell[] = [];2646 const pinned: EnvironmentThreadShell[] = [];
2674 const active: EnvironmentThreadShell[] = [];2647 const active: EnvironmentThreadShell[] = [];
2675 const working: EnvironmentThreadShell[] = [];2648 const working: EnvironmentThreadShell[] = [];
...@@ -2736,11 +2709,7 @@ export default function Sidebar() {...@@ -2736,11 +2709,7 @@ export default function Sidebar() {
2736 // web and mobile from the same data.2709 // web and mobile from the same data.
2737 const sortedPinned = sortPinnedThreadsForSidebar(pinned);2710 const sortedPinned = sortPinnedThreadsForSidebar(pinned);
2738 const sortedActive = workingShelfEnabled2711 const sortedActive = workingShelfEnabled
2739 ? sortInboxThreadsByReturn(active, (thread) =>2712 ? sortInboxThreadsByReturn(active, inboxReturns.returnedAt)
2740 observedInboxReturns.get(
2741 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2742 ),
2743 )
2744 : sortThreadsForSidebar(active);2713 : sortThreadsForSidebar(active);
2745 return {2714 return {
2746 pinnedThreads:2715 pinnedThreads:
...@@ -3701,7 +3670,7 @@ export default function Sidebar() {...@@ -3701,7 +3670,7 @@ export default function Sidebar() {
3701 ...activeThreads.filter((candidate) => key(candidate) !== dragState.activeKey),3670 ...activeThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
3702 applySidebarThreadDrop(thread, "active", dragState.occurredAt),3671 applySidebarThreadDrop(thread, "active", dragState.occurredAt),
3703 ],3672 ],
3704 (candidate) => observedInboxReturns.get(key(candidate)),3673 inboxReturns.returnedAt,
3705 ).map(key);3674 ).map(key);
3706 }, [activeThreads, dragState, threadByKey, workingShelfEnabled]);3675 }, [activeThreads, dragState, threadByKey, workingShelfEnabled]);
3707 const sidebarSortingStrategy = useMemo(3676 const sidebarSortingStrategy = useMemo(
docs/user/thread-sidebar.md+6-5
...@@ -111,13 +111,14 @@ open....@@ -111,13 +111,14 @@ open.
111111
112### Fold working threads (beta)112### Fold working threads (beta)
113113
114On web and desktop, turn on **Settings → General → Working section (beta)** to move threads that114Turn on **Settings → General → Working section (beta)** on web and desktop, or **Settings →
115are working or monitoring into a collapsed **Working** section at the bottom of the sidebar. A115Thread behavior → Working section** on iOS and Android, to move threads that are working or
116thread returns to the top of the active list when it finishes, fails, or needs an approval or116monitoring into a collapsed **Working** section below the active list. A thread returns to the top
117answer. Pinned threads stay in the pinned section.117of the active list when it finishes, fails, or needs an approval or answer. Pinned threads stay in
118the pinned section. Each device keeps its own choice.
118119
119While this is on, the active list is ordered by when each thread last came back to you, so you120While this is on, the active list is ordered by when each thread last came back to you, so you
120cannot drag to reorder it. Your saved order returns when you turn it off.121cannot drag or move threads within it. Your saved order returns when you turn it off.
121122
122## Settle finished work123## Settle finished work
123124
packages/client-runtime/package.json+4
...@@ -259,6 +259,10 @@...@@ -259,6 +259,10 @@
259 "types": "./src/state/threadSort.ts",259 "types": "./src/state/threadSort.ts",
260 "default": "./src/state/threadSort.ts"260 "default": "./src/state/threadSort.ts"
261 },261 },
262 "./state/thread-inbox": {
263 "types": "./src/state/threadInbox.ts",
264 "default": "./src/state/threadInbox.ts"
265 },
262 "./state/thread-relationships": {266 "./state/thread-relationships": {
263 "types": "./src/state/threadRelationships.ts",267 "types": "./src/state/threadRelationships.ts",
264 "default": "./src/state/threadRelationships.ts"268 "default": "./src/state/threadRelationships.ts"
packages/client-runtime/src/state/threadInbox.test.ts created+59
...@@ -0,0 +1,59 @@
1import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { createInboxReturnTracker } from "./threadInbox.ts";
5
6const environmentId = EnvironmentId.make("environment-1");
7
8function thread(id: string, working: boolean) {
9 return {
10 id: ThreadId.make(id),
11 environmentId,
12 createdAt: "2026-06-01T00:00:00.000Z",
13 unsettledAt: null,
14 latestRun: null,
15 hasActionableProposedPlan: false,
16 hasPendingApprovals: false,
17 hasPendingUserInput: false,
18 interactionMode: "default" as const,
19 runtime: working
20 ? {
21 status: "running" as const,
22 activeRunId: null,
23 providerInstanceId: ProviderInstanceId.make("codex"),
24 providerName: "Codex",
25 lastError: null,
26 updatedAt: "2026-06-01T00:00:00.000Z",
27 }
28 : null,
29 };
30}
31
32describe("createInboxReturnTracker", () => {
33 it("stamps a thread when it stops working, but never on the first observation", () => {
34 const tracker = createInboxReturnTracker();
35 tracker.observe([thread("a", true), thread("b", false)]);
36 expect(tracker.returnedAt(thread("a", true))).toBeUndefined();
37 expect(tracker.returnedAt(thread("b", false))).toBeUndefined();
38
39 tracker.observe([thread("a", false), thread("b", false)]);
40 expect(tracker.returnedAt(thread("a", false))).toBeDefined();
41 expect(tracker.returnedAt(thread("b", false))).toBeUndefined();
42 });
43
44 it("forgets deleted threads and resets when the beta turns off", () => {
45 const tracker = createInboxReturnTracker();
46 tracker.observe([thread("a", true), thread("b", true)]);
47 tracker.observe([thread("a", false), thread("b", false)]);
48 tracker.observe([thread("b", false)]);
49 expect(tracker.returnedAt(thread("a", false))).toBeUndefined();
50 expect(tracker.returnedAt(thread("b", false))).toBeDefined();
51
52 tracker.observe(null);
53 expect(tracker.returnedAt(thread("b", false))).toBeUndefined();
54 // After a reset the next call is a fresh baseline again.
55 tracker.observe([thread("b", true)]);
56 tracker.observe([thread("b", false)]);
57 expect(tracker.returnedAt(thread("b", false))).toBeDefined();
58 });
59});
packages/client-runtime/src/state/threadInbox.ts created+111
...@@ -0,0 +1,111 @@
1import { threadRuntimeIsActive, type EnvironmentThreadShell } from "./models.ts";
2import { toSortableTimestamp } from "./threadSort.ts";
3
4// Working section beta, shared so web and mobile fold and order the inbox the
5// same way. Off by default; each client owns its own toggle.
6
7type WorkingThreadInput = Pick<
8 EnvironmentThreadShell,
9 | "hasActionableProposedPlan"
10 | "hasPendingApprovals"
11 | "hasPendingUserInput"
12 | "interactionMode"
13 | "latestRun"
14 | "runtime"
15>;
16
17/** Threads busy with work that does not need the user fold into the Working
18 section: a running run, or one stopped with background work that will wake
19 it. Approvals, questions, plan prompts, and failures stay in the inbox. */
20export function isThreadWorking(thread: WorkingThreadInput): boolean {
21 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;
22 if (!threadRuntimeIsActive(thread.runtime) && thread.runtime?.status !== "idle") return false;
23 // A plan prompt outranks lingering background work: the user has to act on it.
24 const run = thread.latestRun;
25 const runSettled =
26 run !== null &&
27 run.status !== "preparing" &&
28 run.status !== "queued" &&
29 run.status !== "starting" &&
30 run.status !== "running" &&
31 run.status !== "waiting" &&
32 thread.runtime?.activeRunId !== run.runId;
33 return !(thread.interactionMode === "plan" && thread.hasActionableProposedPlan && runSettled);
34}
35
36type InboxThreadInput = Pick<
37 EnvironmentThreadShell,
38 "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestRun"
39>;
40
41/** The inbox lists threads newest first by when each last came back to the
42 user, so a thread that leaves the Working section lands on top.
43 `observedReturnAt` adds returns the server does not stamp, such as an
44 approval request mid-turn or background work ending. */
45export function sortInboxThreadsByReturn<T extends InboxThreadInput>(
46 threads: readonly T[],
47 observedReturnAt?: (thread: T) => number | undefined,
48): T[] {
49 const timestamps = new Map(
50 threads.map((thread) => [
51 thread,
52 Math.max(
53 toSortableTimestamp(thread.createdAt) ?? 0,
54 toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0,
55 toSortableTimestamp(thread.latestRun?.requestedAt ?? undefined) ?? 0,
56 toSortableTimestamp(thread.latestRun?.completedAt ?? undefined) ?? 0,
57 observedReturnAt?.(thread) ?? 0,
58 ),
59 ]),
60 );
61 return [...threads].sort(
62 (left, right) =>
63 timestamps.get(right)! - timestamps.get(left)! ||
64 left.id.localeCompare(right.id) ||
65 left.environmentId.localeCompare(right.environmentId),
66 );
67}
68
69/**
70 * Remembers when this client saw each thread leave the Working section. Keep
71 * one at module scope so the inbox order survives routes that unmount the
72 * list. Call `observe` with every thread shell on each list rebuild, or with
73 * null to reset while the beta is off. The first call only takes a baseline,
74 * so mounting never reshuffles the inbox.
75 */
76export function createInboxReturnTracker() {
77 const keyOf = (thread: Pick<EnvironmentThreadShell, "environmentId" | "id">) =>
78 `${thread.environmentId}:${thread.id}`;
79 let lastWorkingKeys: ReadonlySet<string> | null = null;
80 const returns = new Map<string, number>();
81 return {
82 observe(threads: ReadonlyArray<WorkingThreadInput & InboxThreadInput> | null): void {
83 if (threads === null) {
84 lastWorkingKeys = null;
85 returns.clear();
86 return;
87 }
88 const working = new Set<string>();
89 const present = new Set<string>();
90 for (const thread of threads) {
91 const key = keyOf(thread);
92 present.add(key);
93 if (isThreadWorking(thread)) working.add(key);
94 }
95 // Drop deleted threads so the map stays bounded by the live thread list.
96 for (const key of returns.keys()) {
97 if (!present.has(key)) returns.delete(key);
98 }
99 // The moment this client saw the change is the data; there is no
100 // server stamp to read instead.
101 // @effect-diagnostics-next-line globalDate:off
102 const at = Date.now();
103 for (const key of lastWorkingKeys ?? []) {
104 if (present.has(key) && !working.has(key)) returns.set(key, at);
105 }
106 lastWorkingKeys = working;
107 },
108 returnedAt: (thread: Pick<EnvironmentThreadShell, "environmentId" | "id">) =>
109 returns.get(keyOf(thread)),
110 };
111}