From 8d846660cecfc69ef4e192322fced29fa07f374c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 15:03:59 -0700 Subject: [PATCH] feat(mobile): beta Working section hides busy threads until they need you (#15346) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/src/features/home/HomeScreen.tsx | 39 ++++- .../settings/SettingsThreadsRouteScreen.tsx | 30 ++++ .../threads/ThreadArrangementSheet.tsx | 18 ++- .../threads/ThreadNavigationSidebar.tsx | 39 ++++- .../features/threads/thread-list-v2-items.tsx | 12 +- .../src/features/threads/threadListV2.test.ts | 144 ++++++++++++++++++ .../src/features/threads/threadListV2.ts | 96 +++++++++++- .../use-thread-list-v2-shelf-preferences.ts | 15 ++ apps/mobile/src/lib/storage.test.ts | 10 +- .../src/persistence/mobile-preferences.ts | 11 ++ apps/web/src/components/Sidebar.logic.ts | 50 +----- apps/web/src/components/Sidebar.tsx | 41 +---- docs/user/thread-sidebar.md | 11 +- packages/client-runtime/package.json | 4 + .../src/state/threadInbox.test.ts | 59 +++++++ .../client-runtime/src/state/threadInbox.ts | 111 ++++++++++++++ 16 files changed, 587 insertions(+), 103 deletions(-) create mode 100644 packages/client-runtime/src/state/threadInbox.test.ts create mode 100644 packages/client-runtime/src/state/threadInbox.ts 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)), + }; +} -- 2.54.0