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 {
4444 ThreadListV2SettledShelfHeader,
4545 ThreadListV2ShowMoreRow,
4646 ThreadListV2SnoozedShelfHeader,
47 ThreadListV2WorkingShelfHeader,
4748} from "../threads/thread-list-v2-items";
4849import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance";
4950import {
......@@ -51,6 +52,7 @@ import {
5152 getThreadListV2OrderedSection,
5253 buildThreadListV2ListItems,
5354 threadListV2ListItemsAreEqual,
55 threadListInboxReturns,
5456 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
5557 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
5658 type ThreadListV2ListItem,
......@@ -466,8 +468,11 @@ export function HomeScreen(props: HomeScreenProps) {
466468 loaded: shelfPreferencesLoaded,
467469 settledShelfExpanded,
468470 snoozedShelfExpanded,
471 workingShelfEnabled,
472 workingShelfExpanded,
469473 toggleSettledShelf,
470474 toggleSnoozedShelf,
475 toggleWorkingShelf,
471476 } = useThreadListV2ShelfPreferences();
472477 // The queued-start and snooze helpers need a clock while the list stays open.
473478 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
......@@ -520,8 +525,13 @@ export function HomeScreen(props: HomeScreenProps) {
520525 queuedThreadKeys,
521526 }),
522527 });
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 ]);
524533 }, [
534 workingShelfEnabled,
525535 pinReorderEnvironmentIds,
526536 activeReorderEnvironmentIds,
527537 props.threads,
......@@ -533,6 +543,7 @@ export function HomeScreen(props: HomeScreenProps) {
533543 snoozeWakeTick,
534544 ]);
535545 const threadListV2Layout = useMemo(() => {
546 threadListInboxReturns.observe(workingShelfEnabled ? props.threads : null);
536547 // Settled threads are live shells; archived threads keep their original
537548 // "hidden from lists" meaning.
538549 return buildThreadListV2Items({
......@@ -547,11 +558,16 @@ export function HomeScreen(props: HomeScreenProps) {
547558 queuedThreadKeys,
548559 settledLimit: settledVisibleCount,
549560 now: new Date().toISOString(),
561 workingShelfEnabled,
562 workingShelfExpanded,
563 inboxReturnAt: threadListInboxReturns.returnedAt,
550564 snoozedShelfExpanded,
551565 settledShelfExpanded,
552566 selectedThreadKey: null,
553567 });
554568 }, [
569 workingShelfEnabled,
570 workingShelfExpanded,
555571 pendingOrder,
556572 queuedThreadKeys,
557573 nowMinute,
......@@ -606,6 +622,9 @@ export function HomeScreen(props: HomeScreenProps) {
606622 buildThreadListV2ListItems({
607623 items: threadListV2Layout.items,
608624 pendingTasks: v2PendingTasks,
625 workingCount: threadListV2Layout.workingCount,
626 workingShelfExpanded,
627 workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex,
609628 snoozedCount: threadListV2Layout.snoozedCount,
610629 snoozedShelfExpanded,
611630 snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex,
......@@ -628,6 +647,7 @@ export function HomeScreen(props: HomeScreenProps) {
628647 snoozeEnvironmentIds,
629648 threadListV2Layout,
630649 v2PendingTasks,
650 workingShelfExpanded,
631651 ],
632652 );
633653
......@@ -662,6 +682,16 @@ export function HomeScreen(props: HomeScreenProps) {
662682 />
663683 );
664684 }
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 }
665695 if (item.type === "v2-snoozed-shelf") {
666696 return (
667697 <ThreadListV2SnoozedShelfHeader
......@@ -730,7 +760,7 @@ export function HomeScreen(props: HomeScreenProps) {
730760 reorderSupported={
731761 item.item.pinned
732762 ? pinReorderEnvironmentIds.has(thread.environmentId)
733 : activeReorderEnvironmentIds.has(thread.environmentId)
763 : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId)
734764 }
735765 canMoveUp={item.canMoveUp}
736766 canMoveDown={item.canMoveDown}
......@@ -782,8 +812,10 @@ export function HomeScreen(props: HomeScreenProps) {
782812 titleRegenerationEnvironmentIds,
783813 toggleSettledShelf,
784814 toggleSnoozedShelf,
815 toggleWorkingShelf,
785816 v2ProjectTitleByProjectKey,
786817 props.searchQuery,
818 workingShelfEnabled,
787819 ],
788820 );
789821 const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);
......@@ -800,6 +832,8 @@ export function HomeScreen(props: HomeScreenProps) {
800832 savedConnectionsById: props.savedConnectionsById,
801833 searchQuery: props.searchQuery,
802834 threadSearchMatchByKey,
835 // Rows read it for their reorder menu items.
836 workingShelfEnabled,
803837 }),
804838 [
805839 projectByKey,
......@@ -808,6 +842,7 @@ export function HomeScreen(props: HomeScreenProps) {
808842 listEnvironments,
809843 threadSearchMatchByKey,
810844 v2ProjectTitleByProjectKey,
845 workingShelfEnabled,
811846 ],
812847 );
813848
apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx+30
......@@ -45,6 +45,7 @@ export function SettingsThreadsRouteScreen() {
4545 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
4646 >
4747 <AutoSettleSettingsRows />
48 <BetaSettingsSection />
4849 <LegacySettingsSection />
4950 </ScrollView>
5051 </SettingsScreen>
......@@ -239,6 +240,35 @@ function AutoSettleSettingsRows() {
239240 );
240241}
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
242272/**
243273 * Device-local legacy toggles. Mobile has no client-settings sync, so this is
244274 * 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";
22import { useAtomValue } from "@effect/atom-react";
33import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
44import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
5import { sortInboxThreadsByReturn } from "@t3tools/client-runtime/state/thread-inbox";
56import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
67import { Animated, FlatList, Modal, Pressable, View } from "react-native";
78import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
......@@ -26,7 +27,8 @@ import {
2627 threadDragAction,
2728 type ThreadMoveDestination,
2829} from "./threadOrder";
29import { getThreadListV2OrderedSection } from "./threadListV2";
30import { getThreadListV2OrderedSection, threadListInboxReturns } from "./threadListV2";
31import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
3032
3133const ROW_HEIGHT = 56;
3234const HEADER_HEIGHT = 48;
......@@ -154,6 +156,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
154156 const pendingOrder = useAtomValue(pendingThreadOrderAtom);
155157 const dropBusy = useAtomValue(threadDropBusyAtom);
156158 const { moveThread } = useThreadListActions();
159 const { workingShelfEnabled } = useThreadListV2ShelfPreferences();
157160 const [now, setNow] = useState(() => new Date().toISOString());
158161 const [expanded, setExpanded] = useState({ snoozed: false, settled: false });
159162 useEffect(() => {
......@@ -195,23 +198,28 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
195198 );
196199 return {
197200 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,
199205 snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })),
200206 settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })),
201207 };
202 }, [threads, configs, now, queuedThreadKeys, pendingOrder]);
208 }, [threads, configs, now, queuedThreadKeys, pendingOrder, workingShelfEnabled]);
203209 const planners = useMemo(() => {
204210 const planner = (section: "pinned" | "active") =>
205211 createThreadMovePlanner({
206212 ordered: sections[section],
207213 allThreads: threads,
208214 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.
209217 reorderableEnvironmentIds: new Set(
210218 [...configs].flatMap(([id, config]) =>
211219 (
212220 section === "pinned"
213221 ? config.environment.capabilities.threadPinReorder
214 : config.environment.capabilities.threadActiveReorder
222 : !workingShelfEnabled && config.environment.capabilities.threadActiveReorder
215223 )
216224 ? [id]
217225 : [],
......@@ -219,7 +227,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) {
219227 ),
220228 });
221229 return { pinned: planner("pinned"), active: planner("active") };
222 }, [sections, threads, configs]);
230 }, [sections, threads, configs, workingShelfEnabled]);
223231 const rows = useMemo(() => {
224232 const result: Row[] = [];
225233 let offset = 0;
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+37-2
......@@ -61,6 +61,7 @@ import {
6161 ThreadListV2SettledShelfHeader,
6262 ThreadListV2ShowMoreRow,
6363 ThreadListV2SnoozedShelfHeader,
64 ThreadListV2WorkingShelfHeader,
6465} from "./thread-list-v2-items";
6566import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance";
6667import {
......@@ -69,6 +70,7 @@ import {
6970 buildThreadListV2ListItems,
7071 isThreadListV2ListItem,
7172 threadListV2ListItemsAreEqual,
73 threadListInboxReturns,
7274 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
7375 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
7476 type ThreadListV2ListItem,
......@@ -291,8 +293,11 @@ function ThreadNavigationSidebarPane(
291293 loaded: shelfPreferencesLoaded,
292294 settledShelfExpanded,
293295 snoozedShelfExpanded,
296 workingShelfEnabled,
297 workingShelfExpanded,
294298 toggleSettledShelf,
295299 toggleSnoozedShelf,
300 toggleWorkingShelf,
296301 } = useThreadListV2ShelfPreferences();
297302 // The queued-start and snooze helpers need a clock while the pane stays open.
298303 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
......@@ -343,8 +348,13 @@ function ThreadNavigationSidebarPane(
343348 queuedThreadKeys,
344349 }),
345350 });
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 ]);
347356 }, [
357 workingShelfEnabled,
348358 pinReorderEnvironmentIds,
349359 activeReorderEnvironmentIds,
350360 threads,
......@@ -356,6 +366,7 @@ function ThreadNavigationSidebarPane(
356366 snoozeWakeTick,
357367 ]);
358368 const threadListV2Layout = useMemo(() => {
369 threadListInboxReturns.observe(workingShelfEnabled ? threads : null);
359370 return buildThreadListV2Items({
360371 pendingOrder,
361372 threads: threads.filter((thread) => thread.archivedAt === null),
......@@ -368,11 +379,16 @@ function ThreadNavigationSidebarPane(
368379 queuedThreadKeys,
369380 settledLimit: settledVisibleCount,
370381 now: new Date().toISOString(),
382 workingShelfEnabled,
383 workingShelfExpanded,
384 inboxReturnAt: threadListInboxReturns.returnedAt,
371385 snoozedShelfExpanded,
372386 settledShelfExpanded,
373387 selectedThreadKey: props.selectedThreadKey ?? null,
374388 });
375389 }, [
390 workingShelfEnabled,
391 workingShelfExpanded,
376392 pendingOrder,
377393 queuedThreadKeys,
378394 nowMinute,
......@@ -424,6 +440,9 @@ function ThreadNavigationSidebarPane(
424440 const items: SidebarListItem[] = buildThreadListV2ListItems({
425441 items: threadListV2Layout.items,
426442 pendingTasks: v2PendingTasks,
443 workingCount: threadListV2Layout.workingCount,
444 workingShelfExpanded,
445 workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex,
427446 snoozedCount: threadListV2Layout.snoozedCount,
428447 snoozedShelfExpanded,
429448 snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex,
......@@ -457,6 +476,7 @@ function ThreadNavigationSidebarPane(
457476 snoozedShelfExpanded,
458477 snoozeEnvironmentIds,
459478 threadListV2Layout,
479 workingShelfExpanded,
460480 ]);
461481 const listMenuActions = useMemo<MenuAction[]>(
462482 () => [
......@@ -587,6 +607,8 @@ function ThreadNavigationSidebarPane(
587607 savedConnectionsById,
588608 listEnvironments,
589609 threadSearchMatchByKey,
610 // Rows read it for their reorder menu items.
611 workingShelfEnabled,
590612 }),
591613 [
592614 props.selectedThreadKey,
......@@ -595,6 +617,7 @@ function ThreadNavigationSidebarPane(
595617 savedConnectionsById,
596618 listEnvironments,
597619 threadSearchMatchByKey,
620 workingShelfEnabled,
598621 ],
599622 );
600623 useThreadJumpShortcuts(listItems, handleSelectThread);
......@@ -712,7 +735,7 @@ function ThreadNavigationSidebarPane(
712735 reorderSupported={
713736 item.item.pinned
714737 ? pinReorderEnvironmentIds.has(thread.environmentId)
715 : activeReorderEnvironmentIds.has(thread.environmentId)
738 : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId)
716739 }
717740 canMoveUp={item.canMoveUp}
718741 canMoveDown={item.canMoveDown}
......@@ -729,6 +752,16 @@ function ThreadNavigationSidebarPane(
729752 />
730753 );
731754 }
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 );
732765 case "v2-snoozed-shelf":
733766 return (
734767 <ThreadListV2SnoozedShelfHeader
......@@ -797,9 +830,11 @@ function ThreadNavigationSidebarPane(
797830 snoozeThread,
798831 toggleSettledShelf,
799832 toggleSnoozedShelf,
833 toggleWorkingShelf,
800834 unpinThread,
801835 unsettleThread,
802836 unsnoozeThread,
837 workingShelfEnabled,
803838 ],
804839 );
805840 // 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 = {
191191 readonly pane?: "screen" | "sidebar";
192192};
193193
194const SHELF_LABEL = { working: "Working", snoozed: "Snoozed", settled: "Settled" } as const;
195
194196function ThreadListV2ShelfHeader(
195 props: ThreadListV2ShelfHeaderProps & { readonly kind: "snoozed" | "settled" },
197 props: ThreadListV2ShelfHeaderProps & { readonly kind: keyof typeof SHELF_LABEL },
196198) {
197 const label = props.kind === "snoozed" ? "Snoozed" : "Settled";
199 const label = SHELF_LABEL[props.kind];
198200 return (
199201 <ThreadListV2Section
200202 label={props.expanded ? label : `${label} (${props.count})`}
......@@ -211,6 +213,12 @@ function ThreadListV2ShelfHeader(
211213 );
212214}
213215
216export const ThreadListV2WorkingShelfHeader = memo(function ThreadListV2WorkingShelfHeader(
217 props: ThreadListV2ShelfHeaderProps,
218) {
219 return <ThreadListV2ShelfHeader {...props} kind="working" />;
220});
221
214222export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(
215223 props: ThreadListV2ShelfHeaderProps,
216224) {
apps/mobile/src/features/threads/threadListV2.test.ts+144
......@@ -2158,3 +2158,147 @@ describe("buildThreadListV2ListItems row-state stamps", () => {
21582158 expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false);
21592159 });
21602160});
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"
1111import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
1212import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models";
1313import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
14import {
15 createInboxReturnTracker,
16 isThreadWorking,
17 sortInboxThreadsByReturn,
18} from "@t3tools/client-runtime/state/thread-inbox";
1419import {
1520 sortActiveThreadsByOrderKey,
1621 resolveSettledThreadTimestamp,
......@@ -33,6 +38,11 @@ import {
3338
3439export { 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
3646/**
3747 * Provider drivers for a row's trailing icon stack, back to front. Instances
3848 * missing from the environment's config are skipped, and an unresolved
......@@ -269,6 +279,10 @@ export interface ThreadListV2Layout {
269279 readonly items: ThreadListV2Item[];
270280 /** Settled threads beyond the render limit (behind "Show more"). */
271281 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;
272286 /** Snoozed threads matching the current filters. */
273287 readonly snoozedCount: number;
274288 /** Index in `items` where the Snoozed shelf header belongs. The header is
......@@ -325,6 +339,15 @@ export interface ThreadListV2PendingListItem {
325339 readonly showTrailingDivider: boolean;
326340}
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
328351export interface ThreadListV2SnoozedShelfListItem {
329352 readonly type: "v2-snoozed-shelf";
330353 readonly key: "v2-snoozed-shelf";
......@@ -349,6 +372,7 @@ export interface ThreadListV2SettledShelfListItem {
349372export type ThreadListV2ListItem =
350373 | ThreadListV2ThreadListItem
351374 | ThreadListV2PendingListItem
375 | ThreadListV2WorkingShelfListItem
352376 | ThreadListV2SnoozedShelfListItem
353377 | ThreadListV2SettledShelfListItem;
354378
......@@ -360,6 +384,7 @@ export function isThreadListV2ListItem(value: {
360384 return (
361385 value.type === "v2-thread" ||
362386 value.type === "v2-pending" ||
387 value.type === "v2-working-shelf" ||
363388 value.type === "v2-snoozed-shelf" ||
364389 value.type === "v2-settled-shelf"
365390 );
......@@ -401,6 +426,13 @@ export function threadListV2ListItemsAreEqual(
401426 previous.showPendingDivider === item.showPendingDivider &&
402427 previous.showTrailingDivider === item.showTrailingDivider
403428 );
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 );
404436 case "v2-snoozed-shelf":
405437 return (
406438 previous.type === "v2-snoozed-shelf" &&
......@@ -441,13 +473,17 @@ function resolveThreadListV2ItemTimeLabel(
441473}
442474
443475/**
444 * Builds the shared mobile order: active → pending → snoozed shelf → settled.
445 * Pending tasks are waiting rather than asking, and parked work remains
446 * reachable without competing with either the inbox or settled history.
476 * Builds the shared mobile order: active → pending → working shelf (beta) →
477 * snoozed shelf → settled. Pending tasks are waiting rather than asking, and
478 * busy or parked work remains reachable without competing with either the
479 * inbox or settled history.
447480 */
448481export function buildThreadListV2ListItems(input: {
449482 readonly items: ReadonlyArray<ThreadListV2Item>;
450483 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
484 readonly workingCount?: number;
485 readonly workingShelfExpanded?: boolean;
486 readonly workingShelfHeaderIndex?: number | null;
451487 readonly snoozedCount?: number;
452488 readonly snoozedShelfExpanded?: boolean;
453489 readonly snoozedShelfHeaderIndex?: number | null;
......@@ -514,14 +550,27 @@ export function buildThreadListV2ListItems(input: {
514550 showPendingDivider: index === 0,
515551 showTrailingDivider: false,
516552 }));
553 const workingCount = input.workingCount ?? 0;
554 const workingShelfHeaderIndex = input.workingShelfHeaderIndex ?? null;
517555 const snoozedCount = input.snoozedCount ?? 0;
518556 const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null;
519557 const settledCount = input.settledCount ?? 0;
520558 const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null;
521 const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length;
522559 const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;
560 const workingEnd = snoozedShelfHeaderIndex ?? snoozedEnd;
561 const activeEnd = workingShelfHeaderIndex ?? workingEnd;
523562 const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems];
524563 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 }
525574 if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {
526575 result.push({
527576 type: "v2-snoozed-shelf",
......@@ -580,6 +629,15 @@ export function buildThreadListV2Items(input: {
580629 readonly settledLimit?: number;
581630 /** Second-precise clock used for time-based classification. */
582631 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;
583641 /** Expands the snoozed shelf into rows. Collapsed is the default. */
584642 readonly snoozedShelfExpanded?: boolean;
585643 /** Expands the settled shelf into rows. Expanded is the default. */
......@@ -609,8 +667,10 @@ export function buildThreadListV2Items(input: {
609667 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
610668 : null;
611669
670 const workingShelfEnabled = input.workingShelfEnabled === true;
612671 const pinned: EnvironmentThreadShell[] = [];
613672 const active: EnvironmentThreadShell[] = [];
673 const working: EnvironmentThreadShell[] = [];
614674 const settled: EnvironmentThreadShell[] = [];
615675 const snoozed: EnvironmentThreadShell[] = [];
616676 let nextSnoozeWakeAt: string | null = null;
......@@ -656,17 +716,31 @@ export function buildThreadListV2Items(input: {
656716 settled.push(thread);
657717 } else if (thread.pinnedAt != null) {
658718 pinned.push(thread);
719 } else if (workingShelfEnabled && isThreadWorking(thread)) {
720 working.push(thread);
659721 } else {
660722 active.push(thread);
661723 }
662724 }
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);
665733 const orderedSnoozed = [...snoozed].sort(
666734 (left, right) =>
667735 parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),
668736 );
669737 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 );
670744 const visibleSnoozed =
671745 input.snoozedShelfExpanded === true
672746 ? orderedSnoozed
......@@ -711,6 +785,16 @@ export function buildThreadListV2Items(input: {
711785 isLast: false,
712786 });
713787 }
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 }
714798 const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null;
715799 for (const thread of visibleSnoozed) {
716800 items.push({
......@@ -738,6 +822,8 @@ export function buildThreadListV2Items(input: {
738822 return {
739823 items,
740824 hiddenSettledCount: orderedSettled.length - pagedSettled.length,
825 workingCount: orderedWorking.length,
826 workingShelfHeaderIndex,
741827 snoozedCount: orderedSnoozed.length,
742828 snoozedShelfHeaderIndex,
743829 settledCount: orderedSettled.length,
apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts+15
......@@ -17,10 +17,16 @@ export function useThreadListV2ShelfPreferences() {
1717 loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true;
1818 const settledShelfExpanded =
1919 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;
2024 const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded);
2125 const settledShelfExpandedRef = useRef(settledShelfExpanded);
26 const workingShelfExpandedRef = useRef(workingShelfExpanded);
2227 snoozedShelfExpandedRef.current = snoozedShelfExpanded;
2328 settledShelfExpandedRef.current = settledShelfExpanded;
29 workingShelfExpandedRef.current = workingShelfExpanded;
2430
2531 const toggleSnoozedShelf = useCallback(() => {
2632 if (!loaded) return;
......@@ -34,12 +40,21 @@ export function useThreadListV2ShelfPreferences() {
3440 settledShelfExpandedRef.current = expanded;
3541 savePreferences({ threadListSettledShelfExpanded: expanded });
3642 }, [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
3850 return {
3951 loaded,
4052 settledShelfExpanded,
4153 snoozedShelfExpanded,
54 workingShelfEnabled,
55 workingShelfExpanded,
4256 toggleSettledShelf,
4357 toggleSnoozedShelf,
58 toggleWorkingShelf,
4459 } as const;
4560}
apps/mobile/src/lib/storage.test.ts+9-1
......@@ -232,24 +232,32 @@ describe("mobile connection storage", () => {
232232 expect(fallback.updatedAt).toEqual(expect.any(Number));
233233 });
234234
235 it("persists thread list shelf expansion preferences", async () => {
235 it("persists thread list shelf preferences", async () => {
236236 await expect(
237237 savePreferencesPatch({
238238 threadListSettledShelfExpanded: false,
239239 threadListSnoozedShelfExpanded: true,
240 threadListWorkingShelfExpanded: true,
241 workingShelfEnabled: true,
240242 }),
241243 ).resolves.toEqual({
242244 threadListSettledShelfExpanded: false,
243245 threadListSnoozedShelfExpanded: true,
246 threadListWorkingShelfExpanded: true,
247 workingShelfEnabled: true,
244248 });
245249
246250 await expect(loadPreferences()).resolves.toEqual({
247251 threadListSettledShelfExpanded: false,
248252 threadListSnoozedShelfExpanded: true,
253 threadListWorkingShelfExpanded: true,
254 workingShelfEnabled: true,
249255 });
250256 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({
251257 threadListSettledShelfExpanded: false,
252258 threadListSnoozedShelfExpanded: true,
259 threadListWorkingShelfExpanded: true,
260 workingShelfEnabled: true,
253261 });
254262 });
255263
apps/mobile/src/persistence/mobile-preferences.ts+11
......@@ -41,6 +41,8 @@ export interface Preferences {
4141 readonly projectGroupingMode?: SidebarProjectGroupingMode;
4242 /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */
4343 readonly planModeEnabled?: boolean;
44 /** Device-local counterpart of web's `sidebarWorkingShelfEnabled` beta. */
45 readonly workingShelfEnabled?: boolean;
4446 /** Model favorites belong to this device, like the web client setting. */
4547 readonly modelFavorites?: ReadonlyArray<{
4648 readonly provider: ProviderInstanceId;
......@@ -49,6 +51,7 @@ export interface Preferences {
4951 /** Fresh keys reset both shelves to collapsed when users update. */
5052 readonly threadListSettledShelfExpanded?: boolean;
5153 readonly threadListSnoozedShelfExpanded?: boolean;
54 readonly threadListWorkingShelfExpanded?: boolean;
5255}
5356
5457export class MobilePreferencesLoadError extends Schema.TaggedError<MobilePreferencesLoadError>()(
......@@ -107,9 +110,11 @@ function sanitizePreferences(parsed: Preferences): Preferences {
107110 projectGroupingEnabled?: boolean;
108111 projectGroupingMode?: SidebarProjectGroupingMode;
109112 planModeEnabled?: boolean;
113 workingShelfEnabled?: boolean;
110114 modelFavorites?: Preferences["modelFavorites"];
111115 threadListSettledShelfExpanded?: boolean;
112116 threadListSnoozedShelfExpanded?: boolean;
117 threadListWorkingShelfExpanded?: boolean;
113118 } = {};
114119
115120 if (typeof parsed.liveActivitiesEnabled === "boolean") {
......@@ -180,6 +185,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
180185 if (typeof parsed.planModeEnabled === "boolean") {
181186 preferences.planModeEnabled = parsed.planModeEnabled;
182187 }
188 if (typeof parsed.workingShelfEnabled === "boolean") {
189 preferences.workingShelfEnabled = parsed.workingShelfEnabled;
190 }
183191 if (Array.isArray(parsed.modelFavorites)) {
184192 preferences.modelFavorites = parsed.modelFavorites.filter(
185193 (favorite) =>
......@@ -197,6 +205,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
197205 if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") {
198206 preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded;
199207 }
208 if (typeof parsed.threadListWorkingShelfExpanded === "boolean") {
209 preferences.threadListWorkingShelfExpanded = parsed.threadListWorkingShelfExpanded;
210 }
200211 return preferences;
201212}
202213
apps/web/src/components/Sidebar.logic.ts+5-45
......@@ -1008,21 +1008,6 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea
10081008 return status === "working";
10091009}
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
10261011/** First VALID timestamp wins: `a ?? b` falls through on null, but a present-
10271012 yet-malformed string must also fall through to the next candidate rather
10281013 than sink the row to the epoch. */
......@@ -1038,6 +1023,11 @@ export function firstValidTimestampMs(
10381023}
10391024
10401025export { 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
10421032// Pinned-reorder key math and the keyed sort live in client-runtime
10431033// (state/thread-sort) so web and mobile compute identical pinned orders.
......@@ -1119,36 +1109,6 @@ export function reduceSidebarProjectScopeMenuState(
11191109 }
11201110}
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
11521112/** The timestamp a working thread's elapsed label counts from: when its
11531113 current work started (request time until adoption). Background wakes do
11541114 not reset it. Malformed timestamps fall through to the next candidate. */
apps/web/src/components/Sidebar.tsx+5-36
......@@ -31,6 +31,7 @@ import {
3131 effectiveSnoozed,
3232 threadWokeAt,
3333} from "@t3tools/client-runtime/state/thread-settled";
34import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox";
3435import {
3536 resolveSettledThreadTimestamp,
3637 sortSettledThreads,
......@@ -289,35 +290,7 @@ const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded";
289290
290291// Working beta: when this client saw each thread leave the Working shelf.
291292// Module scope keeps the inbox order across routes that unmount the sidebar.
292let lastWorkingThreadKeys: ReadonlySet<string> | null = null;
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}
293const inboxReturns = createInboxReturnTracker();
321294
322295function compactSidebarTimeLabel(label: string): string {
323296 if (label === "just now") return "now";
......@@ -2669,7 +2642,7 @@ export default function Sidebar() {
26692642 // Subagent child threads live in the parent's Agents surface, not the
26702643 // sidebar roster (v2 models them as real threads with lineage).
26712644 const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys);
2672 observeInboxReturns(workingShelfEnabled ? threads : null);
2645 inboxReturns.observe(workingShelfEnabled ? threads : null);
26732646 const pinned: EnvironmentThreadShell[] = [];
26742647 const active: EnvironmentThreadShell[] = [];
26752648 const working: EnvironmentThreadShell[] = [];
......@@ -2736,11 +2709,7 @@ export default function Sidebar() {
27362709 // web and mobile from the same data.
27372710 const sortedPinned = sortPinnedThreadsForSidebar(pinned);
27382711 const sortedActive = workingShelfEnabled
2739 ? sortInboxThreadsByReturn(active, (thread) =>
2740 observedInboxReturns.get(
2741 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2742 ),
2743 )
2712 ? sortInboxThreadsByReturn(active, inboxReturns.returnedAt)
27442713 : sortThreadsForSidebar(active);
27452714 return {
27462715 pinnedThreads:
......@@ -3701,7 +3670,7 @@ export default function Sidebar() {
37013670 ...activeThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
37023671 applySidebarThreadDrop(thread, "active", dragState.occurredAt),
37033672 ],
3704 (candidate) => observedInboxReturns.get(key(candidate)),
3673 inboxReturns.returnedAt,
37053674 ).map(key);
37063675 }, [activeThreads, dragState, threadByKey, workingShelfEnabled]);
37073676 const sidebarSortingStrategy = useMemo(
docs/user/thread-sidebar.md+6-5
......@@ -111,13 +111,14 @@ open.
111111
112112### Fold working threads (beta)
113113
114On web and desktop, turn on **Settings → General → Working section (beta)** to move threads that
115are working or monitoring into a collapsed **Working** section at the bottom of the sidebar. A
116thread returns to the top of the active list when it finishes, fails, or needs an approval or
117answer. Pinned threads stay in the pinned section.
114Turn on **Settings → General → Working section (beta)** on web and desktop, or **Settings →
115Thread behavior → Working section** on iOS and Android, to move threads that are working or
116monitoring into a collapsed **Working** section below the active list. A thread returns to the top
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
119120While 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
122123## Settle finished work
123124
packages/client-runtime/package.json+4
......@@ -259,6 +259,10 @@
259259 "types": "./src/state/threadSort.ts",
260260 "default": "./src/state/threadSort.ts"
261261 },
262 "./state/thread-inbox": {
263 "types": "./src/state/threadInbox.ts",
264 "default": "./src/state/threadInbox.ts"
265 },
262266 "./state/thread-relationships": {
263267 "types": "./src/state/threadRelationships.ts",
264268 "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}