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 | "pendingSubagentRequest"
13 | "interactionMode"
14 | "limitRecovery"
15 | "latestRun"
16 | "runtime"
17>;
18
19/** Threads busy with work that does not need the user fold into the Working
20 section: a running run, or one stopped with background work that will wake
21 it, including scheduled usage-limit recovery. Approvals, questions (its
22 own or a subagent's), plan prompts, and other failures stay in the inbox. */
23export function isThreadWorking(thread: WorkingThreadInput): boolean {
24 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;
25 if (thread.pendingSubagentRequest != null) return false;
26 const recovery = thread.limitRecovery;
27 const resumingAtUsageReset =
28 thread.runtime?.status === "failed" &&
29 thread.runtime.lastErrorClass === "usage_limit" &&
30 recovery?.autoResume === true &&
31 recovery.runId === thread.latestRun?.runId &&
32 recovery.resetAt === thread.runtime.usageLimitResetAt;
33 if (
34 !threadRuntimeIsActive(thread.runtime) &&
35 thread.runtime?.status !== "idle" &&
36 !resumingAtUsageReset
37 )
38 return false;
39 // A plan prompt outranks lingering background work: the user has to act on it.
40 const run = thread.latestRun;
41 const runSettled =
42 run !== null &&
43 run.status !== "preparing" &&
44 run.status !== "queued" &&
45 run.status !== "starting" &&
46 run.status !== "running" &&
47 run.status !== "waiting" &&
48 thread.runtime?.activeRunId !== run.runId;
49 return !(thread.interactionMode === "plan" && thread.hasActionableProposedPlan && runSettled);
50}
51
52type InboxThreadInput = Pick<
53 EnvironmentThreadShell,
54 "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestRun"
55>;
56
57/** The inbox lists threads newest first by when each last came back to the
58 user, so a thread that leaves the Working section lands on top.
59 `observedReturnAt` adds returns the server does not stamp, such as an
60 approval request mid-turn or background work ending. */
61export function sortInboxThreadsByReturn<T extends InboxThreadInput>(
62 threads: readonly T[],
63 observedReturnAt?: (thread: T) => number | undefined,
64): T[] {
65 const timestamps = new Map(
66 threads.map((thread) => [
67 thread,
68 Math.max(
69 toSortableTimestamp(thread.createdAt) ?? 0,
70 toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0,
71 toSortableTimestamp(thread.latestRun?.requestedAt ?? undefined) ?? 0,
72 toSortableTimestamp(thread.latestRun?.completedAt ?? undefined) ?? 0,
73 observedReturnAt?.(thread) ?? 0,
74 ),
75 ]),
76 );
77 return sortNewestFirst(threads, timestamps);
78}
79
80type WorkingSortInput = Pick<
81 EnvironmentThreadShell,
82 "id" | "environmentId" | "createdAt" | "latestRun" | "latestUserAuthoredMessageAt"
83>;
84
85/** The Working section lists threads newest first by the last message the
86 user sent. Runs ending and wakes (background results, delegated results,
87 PR watches) do not move a row, so the order stays put while agents finish
88 and resume. Servers without the authored stamp fall back to the latest
89 run's request time. */
90export function sortWorkingThreadsBySend<T extends WorkingSortInput>(threads: readonly T[]): T[] {
91 const timestamps = new Map(
92 threads.map((thread) => [
93 thread,
94 Math.max(
95 toSortableTimestamp(thread.createdAt) ?? 0,
96 toSortableTimestamp(
97 (thread.latestUserAuthoredMessageAt === undefined
98 ? thread.latestRun?.requestedAt
99 : thread.latestUserAuthoredMessageAt) ?? undefined,
100 ) ?? 0,
101 ),
102 ]),
103 );
104 return sortNewestFirst(threads, timestamps);
105}
106
107function sortNewestFirst<T extends Pick<EnvironmentThreadShell, "id" | "environmentId">>(
108 threads: readonly T[],
109 timestamps: ReadonlyMap<T, number>,
110): T[] {
111 return [...threads].sort(
112 (left, right) =>
113 timestamps.get(right)! - timestamps.get(left)! ||
114 left.id.localeCompare(right.id) ||
115 left.environmentId.localeCompare(right.environmentId),
116 );
117}
118
119/**
120 * Remembers when this client saw each thread leave the Working section. Keep
121 * one at module scope so the inbox order survives routes that unmount the
122 * list. Call `observe` with every thread shell on each list rebuild, or with
123 * null to reset while the beta is off. The first call only takes a baseline,
124 * so mounting never reshuffles the inbox.
125 */
126export function createInboxReturnTracker() {
127 const keyOf = (thread: Pick<EnvironmentThreadShell, "environmentId" | "id">) =>
128 `${thread.environmentId}:${thread.id}`;
129 let lastWorkingKeys: ReadonlySet<string> | null = null;
130 const returns = new Map<string, number>();
131 return {
132 observe(threads: ReadonlyArray<WorkingThreadInput & InboxThreadInput> | null): void {
133 if (threads === null) {
134 lastWorkingKeys = null;
135 returns.clear();
136 return;
137 }
138 const working = new Set<string>();
139 const present = new Set<string>();
140 for (const thread of threads) {
141 const key = keyOf(thread);
142 present.add(key);
143 if (isThreadWorking(thread)) working.add(key);
144 }
145 // Drop deleted threads so the map stays bounded by the live thread list.
146 for (const key of returns.keys()) {
147 if (!present.has(key)) returns.delete(key);
148 }
149 // The moment this client saw the change is the data; there is no
150 // server stamp to read instead.
151 // @effect-diagnostics-next-line globalDate:off
152 const at = Date.now();
153 for (const key of lastWorkingKeys ?? []) {
154 if (present.has(key) && !working.has(key)) returns.set(key, at);
155 }
156 lastWorkingKeys = working;
157 },
158 returnedAt: (thread: Pick<EnvironmentThreadShell, "environmentId" | "id">) =>
159 returns.get(keyOf(thread)),
160 };
161}