diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index aab3667101764f3709e6863f78716ca962976b63..6538697da2975e14592cd371661538af5a73d07b 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -44,6 +44,7 @@ import {
ThreadListV2SettledShelfHeader,
ThreadListV2ShowMoreRow,
ThreadListV2SnoozedShelfHeader,
+ ThreadListV2WorkingShelfHeader,
} from "../threads/thread-list-v2-items";
import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance";
import {
@@ -51,6 +52,7 @@ import {
getThreadListV2OrderedSection,
buildThreadListV2ListItems,
threadListV2ListItemsAreEqual,
+ threadListInboxReturns,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ListItem,
@@ -466,8 +468,11 @@ export function HomeScreen(props: HomeScreenProps) {
loaded: shelfPreferencesLoaded,
settledShelfExpanded,
snoozedShelfExpanded,
+ workingShelfEnabled,
+ workingShelfExpanded,
toggleSettledShelf,
toggleSnoozedShelf,
+ toggleWorkingShelf,
} = useThreadListV2ShelfPreferences();
// The queued-start and snooze helpers need a clock while the list stays open.
const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
@@ -520,8 +525,13 @@ export function HomeScreen(props: HomeScreenProps) {
queuedThreadKeys,
}),
});
- return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]);
+ // The Working beta orders the inbox by time, so only pins can move.
+ return new Map([
+ ...sectionAvailability("pinned"),
+ ...(workingShelfEnabled ? [] : sectionAvailability("active")),
+ ]);
}, [
+ workingShelfEnabled,
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
props.threads,
@@ -533,6 +543,7 @@ export function HomeScreen(props: HomeScreenProps) {
snoozeWakeTick,
]);
const threadListV2Layout = useMemo(() => {
+ threadListInboxReturns.observe(workingShelfEnabled ? props.threads : null);
// Settled threads are live shells; archived threads keep their original
// "hidden from lists" meaning.
return buildThreadListV2Items({
@@ -547,11 +558,16 @@ export function HomeScreen(props: HomeScreenProps) {
queuedThreadKeys,
settledLimit: settledVisibleCount,
now: new Date().toISOString(),
+ workingShelfEnabled,
+ workingShelfExpanded,
+ inboxReturnAt: threadListInboxReturns.returnedAt,
snoozedShelfExpanded,
settledShelfExpanded,
selectedThreadKey: null,
});
}, [
+ workingShelfEnabled,
+ workingShelfExpanded,
pendingOrder,
queuedThreadKeys,
nowMinute,
@@ -606,6 +622,9 @@ export function HomeScreen(props: HomeScreenProps) {
buildThreadListV2ListItems({
items: threadListV2Layout.items,
pendingTasks: v2PendingTasks,
+ workingCount: threadListV2Layout.workingCount,
+ workingShelfExpanded,
+ workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex,
snoozedCount: threadListV2Layout.snoozedCount,
snoozedShelfExpanded,
snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex,
@@ -628,6 +647,7 @@ export function HomeScreen(props: HomeScreenProps) {
snoozeEnvironmentIds,
threadListV2Layout,
v2PendingTasks,
+ workingShelfExpanded,
],
);
@@ -662,6 +682,16 @@ export function HomeScreen(props: HomeScreenProps) {
/>
);
}
+ if (item.type === "v2-working-shelf") {
+ return (
+
+ );
+ }
if (item.type === "v2-snoozed-shelf") {
return (
item.key, []);
@@ -800,6 +832,8 @@ export function HomeScreen(props: HomeScreenProps) {
savedConnectionsById: props.savedConnectionsById,
searchQuery: props.searchQuery,
threadSearchMatchByKey,
+ // Rows read it for their reorder menu items.
+ workingShelfEnabled,
}),
[
projectByKey,
@@ -808,6 +842,7 @@ export function HomeScreen(props: HomeScreenProps) {
listEnvironments,
threadSearchMatchByKey,
v2ProjectTitleByProjectKey,
+ workingShelfEnabled,
],
);
diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx
index 5ff83dea728fedf327936c2e88b59b70e65d9b9d..dab724e236a30c5cfbe32dfefa673356983979f6 100644
--- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx
@@ -45,6 +45,7 @@ export function SettingsThreadsRouteScreen() {
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
>
+
@@ -239,6 +240,35 @@ function AutoSettleSettingsRows() {
);
}
+/**
+ * Device-local beta toggles, the counterpart of web's Working section (beta)
+ * in Settings → General.
+ */
+function BetaSettingsSection() {
+ const savePreferences = useAtomSet(updateMobilePreferencesAtom);
+ const preferences = useAtomValue(mobilePreferencesAtom);
+ const workingShelfEnabled =
+ AsyncResult.isSuccess(preferences) && preferences.value.workingShelfEnabled === true;
+
+ return (
+
+
+ savePreferences({ workingShelfEnabled: value })}
+ />
+
+
+ Fold working and monitoring threads into a Working section. They return to the top of the
+ list when they need you. While this is on, active threads are ordered by time and cannot be
+ moved.
+
+
+ );
+}
+
/**
* Device-local legacy toggles. Mobile has no client-settings sync, so this is
* the counterpart of web's Settings → General → Legacy features backed by
diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx
index cb25aa67af78ff7c57d835c516c8bb9b2313dd31..97dc6cf85eca70599ba0415a9434bc8f01c7b181 100644
--- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx
+++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx
@@ -2,6 +2,7 @@ import { appAtomRegistry } from "../../state/atom-registry";
import { useAtomValue } from "@effect/atom-react";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
+import { sortInboxThreadsByReturn } from "@t3tools/client-runtime/state/thread-inbox";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Animated, FlatList, Modal, Pressable, View } from "react-native";
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
@@ -26,7 +27,8 @@ import {
threadDragAction,
type ThreadMoveDestination,
} from "./threadOrder";
-import { getThreadListV2OrderedSection } from "./threadListV2";
+import { getThreadListV2OrderedSection, threadListInboxReturns } from "./threadListV2";
+import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
const ROW_HEIGHT = 56;
const HEADER_HEIGHT = 48;
@@ -154,6 +156,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
const pendingOrder = useAtomValue(pendingThreadOrderAtom);
const dropBusy = useAtomValue(threadDropBusyAtom);
const { moveThread } = useThreadListActions();
+ const { workingShelfEnabled } = useThreadListV2ShelfPreferences();
const [now, setNow] = useState(() => new Date().toISOString());
const [expanded, setExpanded] = useState({ snoozed: false, settled: false });
useEffect(() => {
@@ -195,23 +198,28 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
);
return {
pinned,
- active,
+ // The Working beta orders the inbox by time; show that order here too.
+ active: workingShelfEnabled
+ ? sortInboxThreadsByReturn(active, threadListInboxReturns.returnedAt)
+ : active,
snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })),
settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })),
};
- }, [threads, configs, now, queuedThreadKeys, pendingOrder]);
+ }, [threads, configs, now, queuedThreadKeys, pendingOrder, workingShelfEnabled]);
const planners = useMemo(() => {
const planner = (section: "pinned" | "active") =>
createThreadMovePlanner({
ordered: sections[section],
allThreads: threads,
section,
+ // A time-ordered inbox has no slots, so Active takes no drops while
+ // the Working beta is on. The saved arrangement stays untouched.
reorderableEnvironmentIds: new Set(
[...configs].flatMap(([id, config]) =>
(
section === "pinned"
? config.environment.capabilities.threadPinReorder
- : config.environment.capabilities.threadActiveReorder
+ : !workingShelfEnabled && config.environment.capabilities.threadActiveReorder
)
? [id]
: [],
@@ -219,7 +227,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
),
});
return { pinned: planner("pinned"), active: planner("active") };
- }, [sections, threads, configs]);
+ }, [sections, threads, configs, workingShelfEnabled]);
const rows = useMemo(() => {
const result: Row[] = [];
let offset = 0;
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index d60425a6af721495ff9477aca70ae504233ce113..27d23b0a0b152b41c27aee2a42bb964ee915497d 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -61,6 +61,7 @@ import {
ThreadListV2SettledShelfHeader,
ThreadListV2ShowMoreRow,
ThreadListV2SnoozedShelfHeader,
+ ThreadListV2WorkingShelfHeader,
} from "./thread-list-v2-items";
import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance";
import {
@@ -69,6 +70,7 @@ import {
buildThreadListV2ListItems,
isThreadListV2ListItem,
threadListV2ListItemsAreEqual,
+ threadListInboxReturns,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ListItem,
@@ -291,8 +293,11 @@ function ThreadNavigationSidebarPane(
loaded: shelfPreferencesLoaded,
settledShelfExpanded,
snoozedShelfExpanded,
+ workingShelfEnabled,
+ workingShelfExpanded,
toggleSettledShelf,
toggleSnoozedShelf,
+ toggleWorkingShelf,
} = useThreadListV2ShelfPreferences();
// The queued-start and snooze helpers need a clock while the pane stays open.
const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
@@ -343,8 +348,13 @@ function ThreadNavigationSidebarPane(
queuedThreadKeys,
}),
});
- return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]);
+ // The Working beta orders the inbox by time, so only pins can move.
+ return new Map([
+ ...sectionAvailability("pinned"),
+ ...(workingShelfEnabled ? [] : sectionAvailability("active")),
+ ]);
}, [
+ workingShelfEnabled,
pinReorderEnvironmentIds,
activeReorderEnvironmentIds,
threads,
@@ -356,6 +366,7 @@ function ThreadNavigationSidebarPane(
snoozeWakeTick,
]);
const threadListV2Layout = useMemo(() => {
+ threadListInboxReturns.observe(workingShelfEnabled ? threads : null);
return buildThreadListV2Items({
pendingOrder,
threads: threads.filter((thread) => thread.archivedAt === null),
@@ -368,11 +379,16 @@ function ThreadNavigationSidebarPane(
queuedThreadKeys,
settledLimit: settledVisibleCount,
now: new Date().toISOString(),
+ workingShelfEnabled,
+ workingShelfExpanded,
+ inboxReturnAt: threadListInboxReturns.returnedAt,
snoozedShelfExpanded,
settledShelfExpanded,
selectedThreadKey: props.selectedThreadKey ?? null,
});
}, [
+ workingShelfEnabled,
+ workingShelfExpanded,
pendingOrder,
queuedThreadKeys,
nowMinute,
@@ -424,6 +440,9 @@ function ThreadNavigationSidebarPane(
const items: SidebarListItem[] = buildThreadListV2ListItems({
items: threadListV2Layout.items,
pendingTasks: v2PendingTasks,
+ workingCount: threadListV2Layout.workingCount,
+ workingShelfExpanded,
+ workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex,
snoozedCount: threadListV2Layout.snoozedCount,
snoozedShelfExpanded,
snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex,
@@ -457,6 +476,7 @@ function ThreadNavigationSidebarPane(
snoozedShelfExpanded,
snoozeEnvironmentIds,
threadListV2Layout,
+ workingShelfExpanded,
]);
const listMenuActions = useMemo(
() => [
@@ -587,6 +607,8 @@ function ThreadNavigationSidebarPane(
savedConnectionsById,
listEnvironments,
threadSearchMatchByKey,
+ // Rows read it for their reorder menu items.
+ workingShelfEnabled,
}),
[
props.selectedThreadKey,
@@ -595,6 +617,7 @@ function ThreadNavigationSidebarPane(
savedConnectionsById,
listEnvironments,
threadSearchMatchByKey,
+ workingShelfEnabled,
],
);
useThreadJumpShortcuts(listItems, handleSelectThread);
@@ -712,7 +735,7 @@ function ThreadNavigationSidebarPane(
reorderSupported={
item.item.pinned
? pinReorderEnvironmentIds.has(thread.environmentId)
- : activeReorderEnvironmentIds.has(thread.environmentId)
+ : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId)
}
canMoveUp={item.canMoveUp}
canMoveDown={item.canMoveDown}
@@ -729,6 +752,16 @@ function ThreadNavigationSidebarPane(
/>
);
}
+ case "v2-working-shelf":
+ return (
+
+ );
case "v2-snoozed-shelf":
return (
;
+});
+
export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(
props: ThreadListV2ShelfHeaderProps,
) {
diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts
index 3e60d44f73f50b836fef8c050c2d1a55de399129..7459b17672f9b267b9418298778da342afc0941b 100644
--- a/apps/mobile/src/features/threads/threadListV2.test.ts
+++ b/apps/mobile/src/features/threads/threadListV2.test.ts
@@ -2158,3 +2158,147 @@ describe("buildThreadListV2ListItems row-state stamps", () => {
expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false);
});
});
+
+describe("Working section beta", () => {
+ const running = {
+ status: "running" as const,
+ activeRunId: null,
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ providerName: "Codex",
+ lastError: null,
+ updatedAt: NOW,
+ };
+ const finishedAt = (id: string, completedAt: string) =>
+ makeThread({
+ id: ThreadId.make(id),
+ title: id,
+ createdAt: "2026-06-01T00:00:00.000Z",
+ latestRun: {
+ runId: RunId.make(`run-${id}`),
+ status: "completed",
+ requestedAt: "2026-06-01T00:00:00.000Z",
+ startedAt: "2026-06-01T00:00:00.000Z",
+ completedAt,
+ assistantMessageId: null,
+ },
+ });
+ const threads = [
+ finishedAt("finished-early", "2026-06-01T01:00:00.000Z"),
+ finishedAt("finished-late", "2026-06-01T03:00:00.000Z"),
+ makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }),
+ makeThread({
+ id: ThreadId.make("asks-approval"),
+ title: "asks-approval",
+ createdAt: "2026-06-01T02:00:00.000Z",
+ runtime: running,
+ hasPendingApprovals: true,
+ }),
+ makeThread({
+ id: ThreadId.make("pinned-working"),
+ title: "pinned-working",
+ runtime: running,
+ pinnedAt: "2026-06-01T00:00:00.000Z",
+ }),
+ ];
+ const build = (input: Partial[0]> = {}) =>
+ buildThreadListV2Items({
+ threads,
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ workingShelfEnabled: true,
+ ...input,
+ });
+ const ids = (layout: ReturnType) =>
+ layout.items.map((item) => item.thread.id);
+
+ it("folds unpinned working threads into a collapsed shelf", () => {
+ const layout = build();
+ expect(ids(layout)).toEqual([
+ "pinned-working",
+ "finished-late",
+ "asks-approval",
+ "finished-early",
+ ]);
+ expect(layout.workingCount).toBe(1);
+ expect(layout.workingShelfHeaderIndex).toBe(4);
+
+ const off = build({ workingShelfEnabled: false });
+ expect(ids(off)).toContain("working");
+ expect(off.workingCount).toBe(0);
+ });
+
+ it("shows working rows as cards when expanded, or only the selected one when collapsed", () => {
+ expect(build({ workingShelfExpanded: true }).items.at(-1)).toMatchObject({
+ thread: { id: "working" },
+ variant: "card",
+ });
+ expect(ids(build({ selectedThreadKey: `${environmentId}:working` })).at(-1)).toBe("working");
+ });
+
+ it("orders the inbox by the latest return this device observed", () => {
+ const layout = build({
+ inboxReturnAt: (thread) =>
+ thread.id === "finished-early" ? Date.parse("2026-06-01T04:00:00.000Z") : undefined,
+ });
+ expect(ids(layout).slice(1)).toEqual(["finished-early", "finished-late", "asks-approval"]);
+ });
+
+ it("places the shelf after queued tasks and before snoozed and settled threads", () => {
+ const layout = buildThreadListV2Items({
+ threads: [
+ makeThread({ id: ThreadId.make("active"), title: "active" }),
+ makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }),
+ makeThread({
+ id: ThreadId.make("snoozed"),
+ title: "snoozed",
+ runtime: running,
+ snoozedUntil: "2026-06-03T09:00:00.000Z",
+ snoozedAt: "2026-06-01T12:00:00.000Z",
+ }),
+ makeThread({
+ id: ThreadId.make("settled"),
+ title: "settled",
+ settledOverride: "settled",
+ settledAt: NOW,
+ }),
+ ],
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ workingShelfEnabled: true,
+ workingShelfExpanded: true,
+ snoozedShelfExpanded: true,
+ });
+ const items = buildThreadListV2ListItems({
+ items: layout.items,
+ pendingTasks: [makePendingTask("queued")],
+ workingCount: layout.workingCount,
+ workingShelfExpanded: true,
+ workingShelfHeaderIndex: layout.workingShelfHeaderIndex,
+ snoozedCount: layout.snoozedCount,
+ snoozedShelfExpanded: true,
+ snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex,
+ settledCount: layout.settledCount,
+ settledShelfHeaderIndex: layout.settledShelfHeaderIndex,
+ });
+ expect(
+ items.map((item) =>
+ item.type === "v2-thread"
+ ? item.item.thread.id
+ : item.type === "v2-pending"
+ ? item.pendingTask.title
+ : item.type,
+ ),
+ ).toEqual([
+ "active",
+ "queued",
+ "v2-working-shelf",
+ "working",
+ "v2-snoozed-shelf",
+ "snoozed",
+ "v2-settled-shelf",
+ "settled",
+ ]);
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts
index 48f13e348b08a077d539d22fc244fe6d73c6b1fe..f06f649793b14fa6c5fba63c51793329882a12ad 100644
--- a/apps/mobile/src/features/threads/threadListV2.ts
+++ b/apps/mobile/src/features/threads/threadListV2.ts
@@ -11,6 +11,11 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
+import {
+ createInboxReturnTracker,
+ isThreadWorking,
+ sortInboxThreadsByReturn,
+} from "@t3tools/client-runtime/state/thread-inbox";
import {
sortActiveThreadsByOrderKey,
resolveSettledThreadTimestamp,
@@ -33,6 +38,11 @@ import {
export { snoozeWakeLabel };
+/** Working section beta: when this device saw each thread leave the Working
+ shelf. One instance for Home and the iPad sidebar, so both lists order the
+ inbox the same way and the order survives screens that unmount. */
+export const threadListInboxReturns = createInboxReturnTracker();
+
/**
* Provider drivers for a row's trailing icon stack, back to front. Instances
* missing from the environment's config are skipped, and an unresolved
@@ -269,6 +279,10 @@ export interface ThreadListV2Layout {
readonly items: ThreadListV2Item[];
/** Settled threads beyond the render limit (behind "Show more"). */
readonly hiddenSettledCount: number;
+ /** Working threads folded away by the Working section beta. */
+ readonly workingCount: number;
+ /** Index in `items` where the Working shelf header belongs. */
+ readonly workingShelfHeaderIndex: number | null;
/** Snoozed threads matching the current filters. */
readonly snoozedCount: number;
/** Index in `items` where the Snoozed shelf header belongs. The header is
@@ -325,6 +339,15 @@ export interface ThreadListV2PendingListItem {
readonly showTrailingDivider: boolean;
}
+export interface ThreadListV2WorkingShelfListItem {
+ readonly type: "v2-working-shelf";
+ readonly key: "v2-working-shelf";
+ readonly count: number;
+ readonly expanded: boolean;
+ /** See the snoozed shelf header's field. */
+ readonly disabled: boolean;
+}
+
export interface ThreadListV2SnoozedShelfListItem {
readonly type: "v2-snoozed-shelf";
readonly key: "v2-snoozed-shelf";
@@ -349,6 +372,7 @@ export interface ThreadListV2SettledShelfListItem {
export type ThreadListV2ListItem =
| ThreadListV2ThreadListItem
| ThreadListV2PendingListItem
+ | ThreadListV2WorkingShelfListItem
| ThreadListV2SnoozedShelfListItem
| ThreadListV2SettledShelfListItem;
@@ -360,6 +384,7 @@ export function isThreadListV2ListItem(value: {
return (
value.type === "v2-thread" ||
value.type === "v2-pending" ||
+ value.type === "v2-working-shelf" ||
value.type === "v2-snoozed-shelf" ||
value.type === "v2-settled-shelf"
);
@@ -401,6 +426,13 @@ export function threadListV2ListItemsAreEqual(
previous.showPendingDivider === item.showPendingDivider &&
previous.showTrailingDivider === item.showTrailingDivider
);
+ case "v2-working-shelf":
+ return (
+ previous.type === "v2-working-shelf" &&
+ previous.count === item.count &&
+ previous.expanded === item.expanded &&
+ previous.disabled === item.disabled
+ );
case "v2-snoozed-shelf":
return (
previous.type === "v2-snoozed-shelf" &&
@@ -441,13 +473,17 @@ function resolveThreadListV2ItemTimeLabel(
}
/**
- * Builds the shared mobile order: active → pending → snoozed shelf → settled.
- * Pending tasks are waiting rather than asking, and parked work remains
- * reachable without competing with either the inbox or settled history.
+ * Builds the shared mobile order: active → pending → working shelf (beta) →
+ * snoozed shelf → settled. Pending tasks are waiting rather than asking, and
+ * busy or parked work remains reachable without competing with either the
+ * inbox or settled history.
*/
export function buildThreadListV2ListItems(input: {
readonly items: ReadonlyArray;
readonly pendingTasks: ReadonlyArray;
+ readonly workingCount?: number;
+ readonly workingShelfExpanded?: boolean;
+ readonly workingShelfHeaderIndex?: number | null;
readonly snoozedCount?: number;
readonly snoozedShelfExpanded?: boolean;
readonly snoozedShelfHeaderIndex?: number | null;
@@ -514,14 +550,27 @@ export function buildThreadListV2ListItems(input: {
showPendingDivider: index === 0,
showTrailingDivider: false,
}));
+ const workingCount = input.workingCount ?? 0;
+ const workingShelfHeaderIndex = input.workingShelfHeaderIndex ?? null;
const snoozedCount = input.snoozedCount ?? 0;
const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null;
const settledCount = input.settledCount ?? 0;
const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null;
- const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length;
const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;
+ const workingEnd = snoozedShelfHeaderIndex ?? snoozedEnd;
+ const activeEnd = workingShelfHeaderIndex ?? workingEnd;
const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems];
const shelfDisabled = input.shelfPreferencesLoading === true;
+ if (workingShelfHeaderIndex !== null && workingCount > 0) {
+ result.push({
+ type: "v2-working-shelf",
+ key: "v2-working-shelf",
+ count: workingCount,
+ expanded: input.workingShelfExpanded === true,
+ disabled: shelfDisabled,
+ });
+ result.push(...threadItems.slice(workingShelfHeaderIndex, workingEnd));
+ }
if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {
result.push({
type: "v2-snoozed-shelf",
@@ -580,6 +629,15 @@ export function buildThreadListV2Items(input: {
readonly settledLimit?: number;
/** Second-precise clock used for time-based classification. */
readonly now: string;
+ /** Working section beta: unpinned working threads fold into the Working
+ shelf, and the inbox orders by when each thread came back to the user
+ instead of the saved arrangement. */
+ readonly workingShelfEnabled?: boolean;
+ /** Expands the Working shelf into rows. Collapsed is the default. */
+ readonly workingShelfExpanded?: boolean;
+ /** Returns this device observed but the server does not stamp, such as an
+ approval request mid-turn. Only read while the beta is on. */
+ readonly inboxReturnAt?: (thread: EnvironmentThreadShell) => number | undefined;
/** Expands the snoozed shelf into rows. Collapsed is the default. */
readonly snoozedShelfExpanded?: boolean;
/** Expands the settled shelf into rows. Expanded is the default. */
@@ -609,8 +667,10 @@ export function buildThreadListV2Items(input: {
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
: null;
+ const workingShelfEnabled = input.workingShelfEnabled === true;
const pinned: EnvironmentThreadShell[] = [];
const active: EnvironmentThreadShell[] = [];
+ const working: EnvironmentThreadShell[] = [];
const settled: EnvironmentThreadShell[] = [];
const snoozed: EnvironmentThreadShell[] = [];
let nextSnoozeWakeAt: string | null = null;
@@ -656,17 +716,31 @@ export function buildThreadListV2Items(input: {
settled.push(thread);
} else if (thread.pinnedAt != null) {
pinned.push(thread);
+ } else if (workingShelfEnabled && isThreadWorking(thread)) {
+ working.push(thread);
} else {
active.push(thread);
}
}
- const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending);
+ // The beta inbox is time-ordered, so the saved arrangement (and any move in
+ // flight) is kept but not applied until the beta is off again.
+ const orderedActive = workingShelfEnabled
+ ? sortInboxThreadsByReturn(active, input.inboxReturnAt)
+ : applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending);
+ // Newest work first, by the same clock as the inbox.
+ const orderedWorking = sortInboxThreadsByReturn(working);
const orderedSnoozed = [...snoozed].sort(
(left, right) =>
parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),
);
const selectedThreadKey = input.selectedThreadKey ?? null;
+ const visibleWorking =
+ input.workingShelfExpanded === true
+ ? orderedWorking
+ : orderedWorking.filter(
+ (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
+ );
const visibleSnoozed =
input.snoozedShelfExpanded === true
? orderedSnoozed
@@ -711,6 +785,16 @@ export function buildThreadListV2Items(input: {
isLast: false,
});
}
+ const workingShelfHeaderIndex = orderedWorking.length > 0 ? items.length : null;
+ for (const thread of visibleWorking) {
+ items.push({
+ thread,
+ variant: "card",
+ snoozed: false,
+ pinned: false,
+ isLast: false,
+ });
+ }
const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null;
for (const thread of visibleSnoozed) {
items.push({
@@ -738,6 +822,8 @@ export function buildThreadListV2Items(input: {
return {
items,
hiddenSettledCount: orderedSettled.length - pagedSettled.length,
+ workingCount: orderedWorking.length,
+ workingShelfHeaderIndex,
snoozedCount: orderedSnoozed.length,
snoozedShelfHeaderIndex,
settledCount: orderedSettled.length,
diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts
index 8ffab4e045b49bd1788aa820667ee40a6aac6927..b32d9ad400437e91f7bce31b2d149d4a690b4619 100644
--- a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts
+++ b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts
@@ -17,10 +17,16 @@ export function useThreadListV2ShelfPreferences() {
loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true;
const settledShelfExpanded =
loaded && preferencesResult.value.threadListSettledShelfExpanded === true;
+ // Working section beta: off until the preference loads and is enabled.
+ const workingShelfEnabled = loaded && preferencesResult.value.workingShelfEnabled === true;
+ const workingShelfExpanded =
+ loaded && preferencesResult.value.threadListWorkingShelfExpanded === true;
const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded);
const settledShelfExpandedRef = useRef(settledShelfExpanded);
+ const workingShelfExpandedRef = useRef(workingShelfExpanded);
snoozedShelfExpandedRef.current = snoozedShelfExpanded;
settledShelfExpandedRef.current = settledShelfExpanded;
+ workingShelfExpandedRef.current = workingShelfExpanded;
const toggleSnoozedShelf = useCallback(() => {
if (!loaded) return;
@@ -34,12 +40,21 @@ export function useThreadListV2ShelfPreferences() {
settledShelfExpandedRef.current = expanded;
savePreferences({ threadListSettledShelfExpanded: expanded });
}, [loaded, savePreferences]);
+ const toggleWorkingShelf = useCallback(() => {
+ if (!loaded) return;
+ const expanded = !workingShelfExpandedRef.current;
+ workingShelfExpandedRef.current = expanded;
+ savePreferences({ threadListWorkingShelfExpanded: expanded });
+ }, [loaded, savePreferences]);
return {
loaded,
settledShelfExpanded,
snoozedShelfExpanded,
+ workingShelfEnabled,
+ workingShelfExpanded,
toggleSettledShelf,
toggleSnoozedShelf,
+ toggleWorkingShelf,
} as const;
}
diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts
index 78a165c5ff1d89a987090117f0e8b7e8fee11936..f56c3a7eff0efc05112e892a166eafbb5ef22a1e 100644
--- a/apps/mobile/src/lib/storage.test.ts
+++ b/apps/mobile/src/lib/storage.test.ts
@@ -232,24 +232,32 @@ describe("mobile connection storage", () => {
expect(fallback.updatedAt).toEqual(expect.any(Number));
});
- it("persists thread list shelf expansion preferences", async () => {
+ it("persists thread list shelf preferences", async () => {
await expect(
savePreferencesPatch({
threadListSettledShelfExpanded: false,
threadListSnoozedShelfExpanded: true,
+ threadListWorkingShelfExpanded: true,
+ workingShelfEnabled: true,
}),
).resolves.toEqual({
threadListSettledShelfExpanded: false,
threadListSnoozedShelfExpanded: true,
+ threadListWorkingShelfExpanded: true,
+ workingShelfEnabled: true,
});
await expect(loadPreferences()).resolves.toEqual({
threadListSettledShelfExpanded: false,
threadListSnoozedShelfExpanded: true,
+ threadListWorkingShelfExpanded: true,
+ workingShelfEnabled: true,
});
expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({
threadListSettledShelfExpanded: false,
threadListSnoozedShelfExpanded: true,
+ threadListWorkingShelfExpanded: true,
+ workingShelfEnabled: true,
});
});
diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts
index 7868d53f9b93e4a78a986280d48d62204fc18696..ee96bce2d5cec0ffae8d7db83ceaab88ec93a436 100644
--- a/apps/mobile/src/persistence/mobile-preferences.ts
+++ b/apps/mobile/src/persistence/mobile-preferences.ts
@@ -41,6 +41,8 @@ export interface Preferences {
readonly projectGroupingMode?: SidebarProjectGroupingMode;
/** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */
readonly planModeEnabled?: boolean;
+ /** Device-local counterpart of web's `sidebarWorkingShelfEnabled` beta. */
+ readonly workingShelfEnabled?: boolean;
/** Model favorites belong to this device, like the web client setting. */
readonly modelFavorites?: ReadonlyArray<{
readonly provider: ProviderInstanceId;
@@ -49,6 +51,7 @@ export interface Preferences {
/** Fresh keys reset both shelves to collapsed when users update. */
readonly threadListSettledShelfExpanded?: boolean;
readonly threadListSnoozedShelfExpanded?: boolean;
+ readonly threadListWorkingShelfExpanded?: boolean;
}
export class MobilePreferencesLoadError extends Schema.TaggedError()(
@@ -107,9 +110,11 @@ function sanitizePreferences(parsed: Preferences): Preferences {
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
planModeEnabled?: boolean;
+ workingShelfEnabled?: boolean;
modelFavorites?: Preferences["modelFavorites"];
threadListSettledShelfExpanded?: boolean;
threadListSnoozedShelfExpanded?: boolean;
+ threadListWorkingShelfExpanded?: boolean;
} = {};
if (typeof parsed.liveActivitiesEnabled === "boolean") {
@@ -180,6 +185,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.planModeEnabled === "boolean") {
preferences.planModeEnabled = parsed.planModeEnabled;
}
+ if (typeof parsed.workingShelfEnabled === "boolean") {
+ preferences.workingShelfEnabled = parsed.workingShelfEnabled;
+ }
if (Array.isArray(parsed.modelFavorites)) {
preferences.modelFavorites = parsed.modelFavorites.filter(
(favorite) =>
@@ -197,6 +205,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") {
preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded;
}
+ if (typeof parsed.threadListWorkingShelfExpanded === "boolean") {
+ preferences.threadListWorkingShelfExpanded = parsed.threadListWorkingShelfExpanded;
+ }
return preferences;
}
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index 5a579bc503a410c195f8c4d5cf1866d3d4a90f3d..5dd6444ad8fbc5cfad1ee50a2b13f74667328475 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -1008,21 +1008,6 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea
return status === "working";
}
-/** Working beta: threads busy with work that does not need the user fold into
- the Working shelf: a running run, or one stopped with background work
- that will wake it. Approvals, questions, plan prompts, and failures stay in the
- inbox. */
-export function isSidebarThreadWorking(thread: ThreadStatusInput): boolean {
- const status = resolveSidebarThreadStatus(thread);
- if (status !== "working" && status !== "waiting") return false;
- // A plan prompt outranks lingering background work: the user has to act on it.
- return !(
- thread.interactionMode === "plan" &&
- thread.hasActionableProposedPlan &&
- isLatestRunSettled(thread.latestRun, thread.runtime)
- );
-}
-
/** First VALID timestamp wins: `a ?? b` falls through on null, but a present-
yet-malformed string must also fall through to the next candidate rather
than sink the row to the epoch. */
@@ -1038,6 +1023,11 @@ export function firstValidTimestampMs(
}
export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
+// The Working section beta folds and orders the inbox the same way on mobile.
+export {
+ isThreadWorking as isSidebarThreadWorking,
+ sortInboxThreadsByReturn,
+} from "@t3tools/client-runtime/state/thread-inbox";
// Pinned-reorder key math and the keyed sort live in client-runtime
// (state/thread-sort) so web and mobile compute identical pinned orders.
@@ -1119,36 +1109,6 @@ export function reduceSidebarProjectScopeMenuState(
}
}
-/** Working beta: the inbox lists threads newest first by when each last came
- back to the user, so a thread that leaves the Working shelf lands on top.
- `observedReturnAt` adds returns the server does not stamp, such as an
- approval request mid-turn or background work ending. */
-export function sortInboxThreadsByReturn<
- T extends Pick<
- SidebarThreadSummary,
- "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestRun"
- >,
->(threads: readonly T[], observedReturnAt?: (thread: T) => number | undefined): T[] {
- const timestamps = new Map(
- threads.map((thread) => [
- thread,
- Math.max(
- toSortableTimestamp(thread.createdAt) ?? 0,
- toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0,
- toSortableTimestamp(thread.latestRun?.requestedAt ?? undefined) ?? 0,
- toSortableTimestamp(thread.latestRun?.completedAt ?? undefined) ?? 0,
- observedReturnAt?.(thread) ?? 0,
- ),
- ]),
- );
- return [...threads].sort(
- (left, right) =>
- timestamps.get(right)! - timestamps.get(left)! ||
- left.id.localeCompare(right.id) ||
- left.environmentId.localeCompare(right.environmentId),
- );
-}
-
/** The timestamp a working thread's elapsed label counts from: when its
current work started (request time until adoption). Background wakes do
not reset it. Malformed timestamps fall through to the next candidate. */
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index eb0499b0d93f21eef0290c7f296a8d6e8c6dc56c..ffea669cc1b73a44c97c37700ed6798efd94b603 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -31,6 +31,7 @@ import {
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
+import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox";
import {
resolveSettledThreadTimestamp,
sortSettledThreads,
@@ -289,35 +290,7 @@ const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded";
// Working beta: when this client saw each thread leave the Working shelf.
// Module scope keeps the inbox order across routes that unmount the sidebar.
-let lastWorkingThreadKeys: ReadonlySet | null = null;
-const observedInboxReturns = new Map();
-
-/** Stamps threads that stopped working since the last call. The first call
- only takes a baseline, so mounting never reshuffles the inbox. Pass null
- to reset when the beta is off. */
-function observeInboxReturns(threads: readonly EnvironmentThreadShell[] | null): void {
- if (threads === null) {
- lastWorkingThreadKeys = null;
- observedInboxReturns.clear();
- return;
- }
- const working = new Set();
- const present = new Set();
- for (const thread of threads) {
- const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
- present.add(key);
- if (isSidebarThreadWorking(thread)) working.add(key);
- }
- // Drop deleted threads so the map stays bounded by the live thread list.
- for (const key of observedInboxReturns.keys()) {
- if (!present.has(key)) observedInboxReturns.delete(key);
- }
- const now = Date.now();
- for (const key of lastWorkingThreadKeys ?? []) {
- if (present.has(key) && !working.has(key)) observedInboxReturns.set(key, now);
- }
- lastWorkingThreadKeys = working;
-}
+const inboxReturns = createInboxReturnTracker();
function compactSidebarTimeLabel(label: string): string {
if (label === "just now") return "now";
@@ -2669,7 +2642,7 @@ export default function Sidebar() {
// Subagent child threads live in the parent's Agents surface, not the
// sidebar roster (v2 models them as real threads with lineage).
const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys);
- observeInboxReturns(workingShelfEnabled ? threads : null);
+ inboxReturns.observe(workingShelfEnabled ? threads : null);
const pinned: EnvironmentThreadShell[] = [];
const active: EnvironmentThreadShell[] = [];
const working: EnvironmentThreadShell[] = [];
@@ -2736,11 +2709,7 @@ export default function Sidebar() {
// web and mobile from the same data.
const sortedPinned = sortPinnedThreadsForSidebar(pinned);
const sortedActive = workingShelfEnabled
- ? sortInboxThreadsByReturn(active, (thread) =>
- observedInboxReturns.get(
- scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
- ),
- )
+ ? sortInboxThreadsByReturn(active, inboxReturns.returnedAt)
: sortThreadsForSidebar(active);
return {
pinnedThreads:
@@ -3701,7 +3670,7 @@ export default function Sidebar() {
...activeThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
applySidebarThreadDrop(thread, "active", dragState.occurredAt),
],
- (candidate) => observedInboxReturns.get(key(candidate)),
+ inboxReturns.returnedAt,
).map(key);
}, [activeThreads, dragState, threadByKey, workingShelfEnabled]);
const sidebarSortingStrategy = useMemo(
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index 1172678440c7fd647267dab23b8fe496cd1d1a7b..b591d518805da2ffad10693d6bf0e5cd40b17a0b 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -111,13 +111,14 @@ open.
### Fold working threads (beta)
-On web and desktop, turn on **Settings → General → Working section (beta)** to move threads that
-are working or monitoring into a collapsed **Working** section at the bottom of the sidebar. A
-thread returns to the top of the active list when it finishes, fails, or needs an approval or
-answer. Pinned threads stay in the pinned section.
+Turn on **Settings → General → Working section (beta)** on web and desktop, or **Settings →
+Thread behavior → Working section** on iOS and Android, to move threads that are working or
+monitoring into a collapsed **Working** section below the active list. A thread returns to the top
+of the active list when it finishes, fails, or needs an approval or answer. Pinned threads stay in
+the pinned section. Each device keeps its own choice.
While this is on, the active list is ordered by when each thread last came back to you, so you
-cannot drag to reorder it. Your saved order returns when you turn it off.
+cannot drag or move threads within it. Your saved order returns when you turn it off.
## Settle finished work
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index 087d29f698d7d2d2b849e627145fe374c33a889a..067f44ffea72834637a31dd193899780b158fbca 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -259,6 +259,10 @@
"types": "./src/state/threadSort.ts",
"default": "./src/state/threadSort.ts"
},
+ "./state/thread-inbox": {
+ "types": "./src/state/threadInbox.ts",
+ "default": "./src/state/threadInbox.ts"
+ },
"./state/thread-relationships": {
"types": "./src/state/threadRelationships.ts",
"default": "./src/state/threadRelationships.ts"
diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..aade72fc85b9a632622141da485ff9c9ab3a9b92
--- /dev/null
+++ b/packages/client-runtime/src/state/threadInbox.test.ts
@@ -0,0 +1,59 @@
+import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { createInboxReturnTracker } from "./threadInbox.ts";
+
+const environmentId = EnvironmentId.make("environment-1");
+
+function thread(id: string, working: boolean) {
+ return {
+ id: ThreadId.make(id),
+ environmentId,
+ createdAt: "2026-06-01T00:00:00.000Z",
+ unsettledAt: null,
+ latestRun: null,
+ hasActionableProposedPlan: false,
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+ interactionMode: "default" as const,
+ runtime: working
+ ? {
+ status: "running" as const,
+ activeRunId: null,
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ providerName: "Codex",
+ lastError: null,
+ updatedAt: "2026-06-01T00:00:00.000Z",
+ }
+ : null,
+ };
+}
+
+describe("createInboxReturnTracker", () => {
+ it("stamps a thread when it stops working, but never on the first observation", () => {
+ const tracker = createInboxReturnTracker();
+ tracker.observe([thread("a", true), thread("b", false)]);
+ expect(tracker.returnedAt(thread("a", true))).toBeUndefined();
+ expect(tracker.returnedAt(thread("b", false))).toBeUndefined();
+
+ tracker.observe([thread("a", false), thread("b", false)]);
+ expect(tracker.returnedAt(thread("a", false))).toBeDefined();
+ expect(tracker.returnedAt(thread("b", false))).toBeUndefined();
+ });
+
+ it("forgets deleted threads and resets when the beta turns off", () => {
+ const tracker = createInboxReturnTracker();
+ tracker.observe([thread("a", true), thread("b", true)]);
+ tracker.observe([thread("a", false), thread("b", false)]);
+ tracker.observe([thread("b", false)]);
+ expect(tracker.returnedAt(thread("a", false))).toBeUndefined();
+ expect(tracker.returnedAt(thread("b", false))).toBeDefined();
+
+ tracker.observe(null);
+ expect(tracker.returnedAt(thread("b", false))).toBeUndefined();
+ // After a reset the next call is a fresh baseline again.
+ tracker.observe([thread("b", true)]);
+ tracker.observe([thread("b", false)]);
+ expect(tracker.returnedAt(thread("b", false))).toBeDefined();
+ });
+});
diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1a71bc189ec7892d9980791c6333681a976b8f87
--- /dev/null
+++ b/packages/client-runtime/src/state/threadInbox.ts
@@ -0,0 +1,111 @@
+import { threadRuntimeIsActive, type EnvironmentThreadShell } from "./models.ts";
+import { toSortableTimestamp } from "./threadSort.ts";
+
+// Working section beta, shared so web and mobile fold and order the inbox the
+// same way. Off by default; each client owns its own toggle.
+
+type WorkingThreadInput = Pick<
+ EnvironmentThreadShell,
+ | "hasActionableProposedPlan"
+ | "hasPendingApprovals"
+ | "hasPendingUserInput"
+ | "interactionMode"
+ | "latestRun"
+ | "runtime"
+>;
+
+/** Threads busy with work that does not need the user fold into the Working
+ section: a running run, or one stopped with background work that will wake
+ it. Approvals, questions, plan prompts, and failures stay in the inbox. */
+export function isThreadWorking(thread: WorkingThreadInput): boolean {
+ if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;
+ if (!threadRuntimeIsActive(thread.runtime) && thread.runtime?.status !== "idle") return false;
+ // A plan prompt outranks lingering background work: the user has to act on it.
+ const run = thread.latestRun;
+ const runSettled =
+ run !== null &&
+ run.status !== "preparing" &&
+ run.status !== "queued" &&
+ run.status !== "starting" &&
+ run.status !== "running" &&
+ run.status !== "waiting" &&
+ thread.runtime?.activeRunId !== run.runId;
+ return !(thread.interactionMode === "plan" && thread.hasActionableProposedPlan && runSettled);
+}
+
+type InboxThreadInput = Pick<
+ EnvironmentThreadShell,
+ "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestRun"
+>;
+
+/** The inbox lists threads newest first by when each last came back to the
+ user, so a thread that leaves the Working section lands on top.
+ `observedReturnAt` adds returns the server does not stamp, such as an
+ approval request mid-turn or background work ending. */
+export function sortInboxThreadsByReturn(
+ threads: readonly T[],
+ observedReturnAt?: (thread: T) => number | undefined,
+): T[] {
+ const timestamps = new Map(
+ threads.map((thread) => [
+ thread,
+ Math.max(
+ toSortableTimestamp(thread.createdAt) ?? 0,
+ toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0,
+ toSortableTimestamp(thread.latestRun?.requestedAt ?? undefined) ?? 0,
+ toSortableTimestamp(thread.latestRun?.completedAt ?? undefined) ?? 0,
+ observedReturnAt?.(thread) ?? 0,
+ ),
+ ]),
+ );
+ return [...threads].sort(
+ (left, right) =>
+ timestamps.get(right)! - timestamps.get(left)! ||
+ left.id.localeCompare(right.id) ||
+ left.environmentId.localeCompare(right.environmentId),
+ );
+}
+
+/**
+ * Remembers when this client saw each thread leave the Working section. Keep
+ * one at module scope so the inbox order survives routes that unmount the
+ * list. Call `observe` with every thread shell on each list rebuild, or with
+ * null to reset while the beta is off. The first call only takes a baseline,
+ * so mounting never reshuffles the inbox.
+ */
+export function createInboxReturnTracker() {
+ const keyOf = (thread: Pick) =>
+ `${thread.environmentId}:${thread.id}`;
+ let lastWorkingKeys: ReadonlySet | null = null;
+ const returns = new Map();
+ return {
+ observe(threads: ReadonlyArray | null): void {
+ if (threads === null) {
+ lastWorkingKeys = null;
+ returns.clear();
+ return;
+ }
+ const working = new Set();
+ const present = new Set();
+ for (const thread of threads) {
+ const key = keyOf(thread);
+ present.add(key);
+ if (isThreadWorking(thread)) working.add(key);
+ }
+ // Drop deleted threads so the map stays bounded by the live thread list.
+ for (const key of returns.keys()) {
+ if (!present.has(key)) returns.delete(key);
+ }
+ // The moment this client saw the change is the data; there is no
+ // server stamp to read instead.
+ // @effect-diagnostics-next-line globalDate:off
+ const at = Date.now();
+ for (const key of lastWorkingKeys ?? []) {
+ if (present.has(key) && !working.has(key)) returns.set(key, at);
+ }
+ lastWorkingKeys = working;
+ },
+ returnedAt: (thread: Pick) =>
+ returns.get(keyOf(thread)),
+ };
+}