authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 15:53:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 15:53:19-07:00
logf4df79477709185c490627a3fd43af96674ae778
treed26b763fc78b6d802f0bedf656f10b1a6f104e27
parent22af947e0d353c9e89c6db2a268836baa71bece5
signature Signed by PGP key B5690EEEBB952194

fix(clients): a thread comes back when its subagent needs approval (#19630)

A delegated subagent waiting on an approval or question now shows on its parent thread: Working section, status badge, notifications, snooze, and the composer banner on web and mobile. Takes over #19614. Co-authored-by: lovelycodemonkey <ghassemimahyad@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

13 files changed, 290 insertions(+), 60 deletions(-)

apps/mobile/src/features/home/useThreadListActions.ts+3-1
......@@ -308,7 +308,9 @@ export function useThreadListActions(): {
308308 if (!canSnooze(thread, { now: new Date().toISOString() })) {
309309 Alert.alert(
310310 "Could not snooze thread",
311 thread.hasPendingApprovals || thread.hasPendingUserInput
311 thread.hasPendingApprovals ||
312 thread.hasPendingUserInput ||
313 thread.pendingSubagentRequest != null
312314 ? "This thread is waiting on you. Respond to the pending request before snoozing it."
313315 : "This thread is still starting a turn. Try again once it's running.",
314316 );
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+6-8
......@@ -7,9 +7,10 @@ import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
77import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
88import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
99import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff";
10import type {
11 CodexFeedbackSubmission,
12 EnvironmentThreadStatus,
10import {
11 childThreadInputsTitle,
12 type CodexFeedbackSubmission,
13 type EnvironmentThreadStatus,
1314} from "@t3tools/client-runtime/state/threads";
1415import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
1516import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
......@@ -523,11 +524,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
523524 if (childInput && contentPresentationKind === "ready") {
524525 return {
525526 kind: "child-input",
526 label:
527 childThreadInputs.length === 1
528 ? "Subagent needs input"
529 : `${childThreadInputs.length} subagents need input`,
530 accessibilityLabel: `Open question from ${childInput.title}`,
527 label: childThreadInputsTitle(childThreadInputs),
528 accessibilityLabel: `Open ${childInput.hasPendingApprovals ? "approval" : "question"} from ${childInput.title}`,
531529 onPress: () => {
532530 Keyboard.dismiss();
533531 navigation.navigate("Thread", {
apps/mobile/src/features/threads/threadListV2.ts+13-4
......@@ -144,11 +144,17 @@ export function resolveThreadListV2SwipeActions(input: {
144144export function resolveThreadListV2SnoozeGateExpiryMs(
145145 thread: Pick<
146146 EnvironmentThreadShell,
147 "hasPendingApprovals" | "hasPendingUserInput" | "latestRun" | "latestUserMessageAt" | "runtime"
147 | "hasPendingApprovals"
148 | "hasPendingUserInput"
149 | "pendingSubagentRequest"
150 | "latestRun"
151 | "latestUserMessageAt"
152 | "runtime"
148153 >,
149154 options: { readonly now: string },
150155): number | null {
151156 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return null;
157 if (thread.pendingSubagentRequest != null) return null;
152158 if (!hasQueuedTurnStart(thread, options)) return null;
153159 const messageAtMs = Date.parse(thread.latestUserMessageAt ?? "");
154160 if (Number.isNaN(messageAtMs)) return null;
......@@ -183,12 +189,15 @@ export function threadHasUnseenCompletion(
183189}
184190
185191export function resolveThreadListV2Status(
186 thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "runtime">,
192 thread: Pick<
193 EnvironmentThreadShell,
194 "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" | "runtime"
195 >,
187196): ThreadListV2Status {
188 if (thread.hasPendingApprovals) {
197 if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
189198 return "approval";
190199 }
191 if (thread.hasPendingUserInput) {
200 if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
192201 return "input";
193202 }
194203 if (
apps/web/src/components/ChatView.tsx+5-6
......@@ -95,6 +95,7 @@ import {
9595import { readPastedComposerContext } from "./composerInlineTokenPaste";
9696import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";
9797import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
98import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads";
9899import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
99100import {
100101 deriveProviderSubagentStatus,
......@@ -311,6 +312,7 @@ import {
311312 ChevronDownIcon,
312313 DownloadIcon,
313314 GitBranchIcon,
315 ShieldQuestionIcon,
314316 TargetIcon,
315317 WifiOffIcon,
316318} from "lucide-react";
......@@ -7465,11 +7467,8 @@ export default function ChatView(props: ChatViewProps) {
74657467 id: `child-input:${first.id}`,
74667468 variant: "info",
74677469 priority: "activity",
7468 icon: <MessageCircleIcon />,
7469 title:
7470 childThreadInputs.length === 1
7471 ? "Subagent needs input"
7472 : `${childThreadInputs.length} subagents need input`,
7470 icon: first.hasPendingApprovals ? <ShieldQuestionIcon /> : <MessageCircleIcon />,
7471 title: childThreadInputsTitle(childThreadInputs),
74737472 description: childThreadInputs.map((child, index) => (
74747473 <Fragment key={child.id}>
74757474 {index > 0 ? " " : null}
......@@ -7483,7 +7482,7 @@ export default function ChatView(props: ChatViewProps) {
74837482 )),
74847483 actions: (
74857484 <Button size="xs" variant="ghost" onClick={() => onOpenRelatedThread(first.id)}>
7486 Open question
7485 {first.hasPendingApprovals ? "Open approval" : "Open question"}
74877486 </Button>
74887487 ),
74897488 };
apps/web/src/components/Sidebar.logic.ts+6-5
......@@ -652,6 +652,7 @@ type ThreadStatusInput = Pick<
652652 | "hasActionableProposedPlan"
653653 | "hasPendingApprovals"
654654 | "hasPendingUserInput"
655 | "pendingSubagentRequest"
655656 | "interactionMode"
656657 | "latestRun"
657658 | "runtime"
......@@ -977,14 +978,14 @@ export function shouldRecedeSidebarThread(input: {
977978
978979type SidebarThreadStatusInput = Pick<
979980 SidebarThreadSummary,
980 "hasPendingApprovals" | "hasPendingUserInput" | "runtime"
981 "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" | "runtime"
981982>;
982983
983984export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus {
984 if (thread.hasPendingApprovals) {
985 if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
985986 return "approval";
986987 }
987 if (thread.hasPendingUserInput) {
988 if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
988989 return "input";
989990 }
990991 if (
......@@ -1181,7 +1182,7 @@ export function resolveThreadStatusPill(input: {
11811182}): ThreadStatusPill | null {
11821183 const { thread } = input;
11831184
1184 if (thread.hasPendingApprovals) {
1185 if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
11851186 return {
11861187 label: "Pending Approval",
11871188 colorClass: "text-amber-600 dark:text-amber-300/90",
......@@ -1190,7 +1191,7 @@ export function resolveThreadStatusPill(input: {
11901191 };
11911192 }
11921193
1193 if (thread.hasPendingUserInput) {
1194 if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
11941195 return {
11951196 label: "Awaiting Input",
11961197 colorClass: "text-indigo-600 dark:text-indigo-300/90",
apps/web/src/components/ThreadNotificationCoordinator.test.tsx+40-1
......@@ -21,6 +21,7 @@ const state = vi.hoisted(() => ({
2121 limited: false,
2222 subagent: false,
2323 background: [] as Array<{ taskId: string; kind: "command" | "monitor" }>,
24 delegatedChild: null as "running" | "approval" | null,
2425 add: vi.fn(
2526 (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) =>
2627 "toast-1",
......@@ -87,10 +88,33 @@ function mockThreadShell() {
8788 };
8889}
8990
91// A delegated child changes while its parent's shell object stays the same.
92let stableParent: ReturnType<typeof mockThreadShell> | null = null;
93function mockThreads() {
94 if (state.delegatedChild === null) return [mockThreadShell()];
95 stableParent ??= mockThreadShell();
96 const child = {
97 ...mockThreadShell(),
98 id: "child-1",
99 title: "Review the login form",
100 creationSource: "mcp",
101 lineage: {
102 rootThreadId: "thread-1",
103 parentThreadId: "thread-1",
104 relationshipToParent: "subagent",
105 },
106 pendingRuntimeRequest:
107 state.delegatedChild === "approval"
108 ? { id: "request-2", kind: "command", createdAt: SHELL_NOW }
109 : null,
110 };
111 return [stableParent, child];
112}
113
90114vi.mock("@effect/atom-react", () => ({
91115 useAtomValue: () => ({
92116 status: state.live ? "live" : "disconnected",
93 snapshot: Option.some({ threads: [mockThreadShell()] }),
117 snapshot: Option.some({ threads: mockThreads() }),
94118 }),
95119}));
96120vi.mock("@tanstack/react-router", () => ({
......@@ -154,7 +178,9 @@ beforeEach(() => {
154178 limited: false,
155179 subagent: false,
156180 background: [],
181 delegatedChild: null,
157182 });
183 stableParent = null;
158184 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
159185 vi.stubGlobal("window", new EventTarget());
160186 vi.stubGlobal("document", {
......@@ -188,6 +214,19 @@ describe("thread notifications", () => {
188214 expect(state.notification).not.toHaveBeenCalled();
189215 });
190216
217 it("alerts on the parent when its delegated subagent asks for approval", async () => {
218 state.mode = "notifications-and-sound";
219 state.delegatedChild = "running";
220 await render();
221 state.delegatedChild = "approval";
222 await render();
223 await render();
224 expect(state.add).toHaveBeenCalledTimes(1);
225 expect(state.add).toHaveBeenLastCalledWith(
226 expect.objectContaining({ title: "Approval needed", description: "Fix the login form" }),
227 );
228 });
229
191230 it("alerts once with system alerts off and opens the completed thread", async () => {
192231 await render();
193232 await complete();
apps/web/src/components/ThreadNotificationCoordinator.tsx+12-4
......@@ -1,4 +1,8 @@
1import { presentThreadShell } from "@t3tools/client-runtime/state/models";
1import {
2 type PendingSubagentRequest,
3 presentThreadShell,
4} from "@t3tools/client-runtime/state/models";
5import { pendingSubagentRequestsByThread } from "@t3tools/client-runtime/state/threads";
26import { useAtomValue } from "@effect/atom-react";
37import { useNavigate, useParams } from "@tanstack/react-router";
48import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
......@@ -90,6 +94,7 @@ export function ThreadNotificationCoordinator() {
9094
9195interface NotificationState {
9296 readonly raw: OrchestrationV2ThreadShell;
97 readonly subagentRequest: PendingSubagentRequest | null;
9398 readonly attention: string | null;
9499 readonly completion: number | null;
95100}
......@@ -122,15 +127,18 @@ function EnvironmentNotifications({
122127 return;
123128 }
124129 const next = new Map<ThreadId, NotificationState>();
130 // Subagents are skipped below, so their requests notify through the thread above them.
131 const subagentRequests = pendingSubagentRequestsByThread(threads);
125132 for (const rawThread of threads) {
126133 if (rawThread.lineage.relationshipToParent === "subagent") continue;
127134 const prior = previous.current.get(rawThread.id);
135 const subagentRequest = subagentRequests.get(rawThread.id) ?? null;
128136 // The same object cannot produce a new notification.
129 if (prior?.raw === rawThread) {
137 if (prior?.raw === rawThread && prior.subagentRequest === subagentRequest) {
130138 next.set(rawThread.id, prior);
131139 continue;
132140 }
133 const thread = presentThreadShell(environmentId, rawThread);
141 const thread = presentThreadShell(environmentId, rawThread, subagentRequest);
134142 let status = resolveSidebarThreadStatus(thread);
135143 if (status === "ready" && thread.latestRun?.status === "failed") status = "failed";
136144 const attention =
......@@ -145,7 +153,7 @@ function EnvironmentNotifications({
145153 Number.isFinite(completedAt)
146154 ? completedAt
147155 : (prior?.completion ?? null);
148 next.set(thread.id, { raw: rawThread, attention, completion });
156 next.set(thread.id, { raw: rawThread, subagentRequest, attention, completion });
149157 if (!prior || thread.archivedAt !== null) continue;
150158 const kind =
151159 attention && attention !== prior.attention
packages/client-runtime/src/state/models.ts+7
......@@ -109,6 +109,9 @@ export interface EnvironmentThreadShell {
109109 readonly latestUserAuthoredMessageAt?: string | null;
110110 readonly hasPendingApprovals: boolean;
111111 readonly hasPendingUserInput: boolean;
112 /** What a live subagent below this thread waits on the user for. Subagent
113 rows are hidden, so the threads above them carry it. */
114 readonly pendingSubagentRequest?: PendingSubagentRequest | null;
112115 readonly hasActionableProposedPlan: boolean;
113116 readonly pendingBackgroundTasks: ReadonlyArray<
114117 NonNullable<OrchestrationV2ThreadShell["pendingBackgroundTasks"]>[number]
......@@ -207,9 +210,12 @@ export function scopeProject(
207210 return { ...project, environmentId };
208211}
209212
213export type PendingSubagentRequest = "approval" | "user_input";
214
210215export function presentThreadShell(
211216 environmentId: EnvironmentId,
212217 thread: OrchestrationV2ThreadShell,
218 pendingSubagentRequest: PendingSubagentRequest | null = null,
213219): EnvironmentThreadShell {
214220 const updatedAt = iso(thread.updatedAt);
215221 const latestRun =
......@@ -259,6 +265,7 @@ export function presentThreadShell(
259265 thread.pendingRuntimeRequest.kind !== "user_input" &&
260266 thread.pendingRuntimeRequest.kind !== "auth_refresh",
261267 hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input",
268 pendingSubagentRequest,
262269 hasActionableProposedPlan: thread.hasActionableProposedPlan,
263270 pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [],
264271 providerInstanceHistory: thread.providerInstanceHistory ?? [],
packages/client-runtime/src/state/threadInbox.ts+4-1
......@@ -9,6 +9,7 @@ type WorkingThreadInput = Pick<
99 | "hasActionableProposedPlan"
1010 | "hasPendingApprovals"
1111 | "hasPendingUserInput"
12 | "pendingSubagentRequest"
1213 | "interactionMode"
1314 | "latestRun"
1415 | "runtime"
......@@ -16,9 +17,11 @@ type WorkingThreadInput = Pick<
1617
1718/** Threads busy with work that does not need the user fold into the Working
1819 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. */
20 it. Approvals, questions (its own or a subagent's), plan prompts, and
21 failures stay in the inbox. */
2022export function isThreadWorking(thread: WorkingThreadInput): boolean {
2123 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;
24 if (thread.pendingSubagentRequest != null) return false;
2225 if (!threadRuntimeIsActive(thread.runtime) && thread.runtime?.status !== "idle") return false;
2326 // A plan prompt outranks lingering background work: the user has to act on it.
2427 const run = thread.latestRun;
packages/client-runtime/src/state/threadSettled.ts+15-9
......@@ -1,6 +1,8 @@
11// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.
22import * as DateTime from "effect/DateTime";
33
4import type { PendingSubagentRequest } from "./models.ts";
5
46interface SettlementRunLike {
57 readonly turnId?: unknown;
68 readonly assistantMessageId?: unknown;
......@@ -90,18 +92,20 @@ export interface ThreadSnoozeShell extends QueuedThreadShell {
9092 readonly snoozedAt?: string | null;
9193 readonly hasPendingApprovals: boolean;
9294 readonly hasPendingUserInput: boolean;
95 readonly pendingSubagentRequest?: PendingSubagentRequest | null;
9396}
9497
9598/**
9699 * A snoozed thread "raises its hand" when something happens that outranks
97 * the user's snooze: the agent is blocked on them (approval / user input),
98 * the session failed, or a run completed after the snooze was set — the
99 * v1 taste of event-based snooze ("something happened" wakes early).
100 * Raising a hand never clears the server-side snooze fields; it only stops
101 * the thread from classifying as snoozed.
100 * the user's snooze: the agent or one of its subagents is blocked on them
101 * (approval / user input), the session failed, or a run completed after the
102 * snooze was set — the v1 taste of event-based snooze ("something happened"
103 * wakes early). Raising a hand never clears the server-side snooze fields; it
104 * only stops the thread from classifying as snoozed.
102105 */
103106export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean {
104107 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true;
108 if (shell.pendingSubagentRequest != null) return true;
105109 const runtime = shell.runtime ?? shell.session ?? null;
106110 const latestRun = shell.latestRun ?? shell.latestTurn ?? null;
107111 // Only a FRESH failure raises the hand: a thread snoozed while already
......@@ -127,10 +131,10 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean
127131}
128132
129133/**
130 * A thread may be snoozed unless the agent is blocked on the user: hiding a
131 * pending approval or user-input request defeats the request, and a queued
132 * turn start (a message no turn has adopted yet) is invisible pending work
133 * the same way it is for settle. A running session IS snoozable — snooze
134 * A thread may be snoozed unless the agent or one of its subagents is blocked
135 * on the user: hiding a pending approval or user-input request defeats the
136 * request, and a queued turn start (a message no turn has adopted yet) is
137 * invisible pending work the same way it is for settle. A running session IS snoozable — snooze
134138 * only affects visibility, never the agent. Client-side twin of the server
135139 * invariants so the UI can reject before a round trip.
136140 */
......@@ -139,6 +143,7 @@ export function canSnooze(
139143 ThreadSnoozeShell,
140144 | "hasPendingApprovals"
141145 | "hasPendingUserInput"
146 | "pendingSubagentRequest"
142147 | "latestUserMessageAt"
143148 | "latestTurn"
144149 | "latestRun"
......@@ -148,6 +153,7 @@ export function canSnooze(
148153 options: { readonly now: string },
149154): boolean {
150155 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false;
156 if (shell.pendingSubagentRequest != null) return false;
151157 if (hasQueuedTurnStart(shell, options)) return false;
152158 return true;
153159}
packages/client-runtime/src/state/threadShell.test.ts+67-1
......@@ -1,6 +1,7 @@
11import {
22 EnvironmentId,
33 ProjectId,
4 RunId,
45 RuntimeRequestId,
56 ThreadId,
67 type OrchestrationV2ShellSnapshot,
......@@ -12,7 +13,8 @@ import { describe, expect, it } from "vite-plus/test";
1213import { PrimaryConnectionTarget } from "../connection/model.ts";
1314import { v2Now, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
1415import { applyShellStreamEvent } from "./shellReducer.ts";
15import { createEnvironmentThreadShellAtoms } from "./threadShell.ts";
16import { isThreadWorking } from "./threadInbox.ts";
17import { childThreadInputsTitle, createEnvironmentThreadShellAtoms } from "./threadShell.ts";
1618
1719const environmentId = EnvironmentId.make("environment-v2");
1820const remoteEnvironmentId = EnvironmentId.make("remote-environment-v2");
......@@ -151,6 +153,70 @@ describe("v2 thread shell lists", () => {
151153 registry.dispose();
152154 });
153155
156 it("brings a parent back from Working while a nested subagent waits for approval", () => {
157 const { registry, threads, snapshotAtom } = makeHarness();
158 const child = {
159 ...v2ThreadShell,
160 id: ThreadId.make("review"),
161 creationSource: "mcp" as const,
162 status: "running" as const,
163 activityRunStatus: "running" as const,
164 lineage: {
165 ...v2ThreadShell.lineage,
166 parentThreadId: v2ThreadShell.id,
167 relationshipToParent: "subagent" as const,
168 },
169 };
170 const grandchild = {
171 ...child,
172 id: ThreadId.make("review-helper"),
173 lineage: { ...child.lineage, parentThreadId: child.id },
174 };
175 // The parent's turn ended; the delegated review keeps it in Working.
176 const root = {
177 ...v2ThreadShell,
178 latestRunId: RunId.make("root-run"),
179 status: "completed" as const,
180 pendingBackgroundTasks: [
181 { taskId: "review", kind: "subagent" as const, childThreadId: child.id },
182 ],
183 };
184 const approval = {
185 id: RuntimeRequestId.make("cat-file"),
186 kind: "command" as const,
187 createdAt: v2Now,
188 };
189 const parent = threads.threadShellAtom({ environmentId, threadId: root.id });
190 const inputs = threads.childThreadInputsAtom({ environmentId, threadId: root.id });
191 const disposeParent = registry.mount(parent);
192 const disposeInputs = registry.mount(inputs);
193 registry.set(snapshotAtom(environmentId), {
194 ...v2ShellSnapshot,
195 threads: [root, child, grandchild],
196 });
197 expect(isThreadWorking(registry.get(parent)!)).toBe(true);
198
199 registry.set(snapshotAtom(environmentId), {
200 ...v2ShellSnapshot,
201 threads: [root, child, { ...grandchild, pendingRuntimeRequest: approval }],
202 });
203 expect(registry.get(parent)?.pendingSubagentRequest).toBe("approval");
204 expect(isThreadWorking(registry.get(parent)!)).toBe(false);
205 const asking = registry.get(inputs);
206 expect(asking.map((thread) => thread.id)).toEqual([grandchild.id]);
207 expect(childThreadInputsTitle(asking)).toBe("Subagent needs approval");
208
209 registry.set(snapshotAtom(environmentId), {
210 ...v2ShellSnapshot,
211 threads: [root, child, grandchild],
212 });
213 expect(registry.get(parent)?.pendingSubagentRequest).toBeNull();
214 expect(isThreadWorking(registry.get(parent)!)).toBe(true);
215 disposeInputs();
216 disposeParent();
217 registry.dispose();
218 });
219
154220 it("leaves switched-off environments out of navigation", () => {
155221 const { registry, threads, catalogValueAtom } = makeHarness([
156222 environmentId,
packages/client-runtime/src/state/threadShell.ts+106-20
......@@ -10,7 +10,7 @@ import type {
1010import { isProviderNativeSubagentThread } from "@t3tools/contracts";
1111import { Atom } from "effect/reactivity";
1212
13import type { EnvironmentThreadShell } from "./models.ts";
13import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts";
1414import { presentThreadShell } from "./models.ts";
1515import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts";
1616import {
......@@ -25,6 +25,7 @@ import {
2525const EMPTY_THREADS: ReadonlyArray<OrchestrationV2ThreadShell> = Object.freeze([]);
2626const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);
2727const EMPTY_THREAD_INDEX: ReadonlyMap<ThreadId, OrchestrationV2ThreadShell> = new Map();
28const EMPTY_SUBAGENT_REQUESTS: ReadonlyMap<ThreadId, PendingSubagentRequest> = new Map();
2829const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap<
2930 ProjectId,
3031 ReadonlyArray<ScopedThreadRef>
......@@ -44,6 +45,60 @@ const isChildRunLive = (
4445 thread.status === "running" ||
4546 thread.status === "waiting";
4647
48/** What a subagent's live run waits on the user for. Provider-native
49 subagents ask on their parent, so they are skipped. */
50function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentRequest | null {
51 const kind = thread.pendingRuntimeRequest?.kind;
52 if (
53 kind === undefined ||
54 kind === "auth_refresh" ||
55 thread.lineage.relationshipToParent !== "subagent" ||
56 isProviderNativeSubagentThread(thread) ||
57 !isChildRunLive(thread)
58 ) {
59 return null;
60 }
61 return kind === "user_input" ? "user_input" : "approval";
62}
63
64/**
65 * Threads above a subagent that waits on the user, by what it waits on. Every
66 * thread up to the visible one carries it, because subagent rows are hidden.
67 * An approval outranks a question.
68 */
69export function pendingSubagentRequestsByThread(
70 threads: ReadonlyArray<OrchestrationV2ThreadShell>,
71): ReadonlyMap<ThreadId, PendingSubagentRequest> {
72 const requests = new Map<ThreadId, PendingSubagentRequest>();
73 let byId: ReadonlyMap<ThreadId, OrchestrationV2ThreadShell> | undefined;
74 for (const child of threads) {
75 const request = subagentRequest(child);
76 if (request === null) continue;
77 byId ??= new Map(threads.map((thread) => [thread.id, thread] as const));
78 const seen = new Set<ThreadId>([child.id]);
79 let current: OrchestrationV2ThreadShell | undefined = child;
80 while (current?.lineage.relationshipToParent === "subagent") {
81 const parentId: ThreadId | null = current.lineage.parentThreadId;
82 if (parentId === null || seen.has(parentId)) break;
83 seen.add(parentId);
84 if (requests.get(parentId) !== "approval") requests.set(parentId, request);
85 current = byId.get(parentId);
86 }
87 }
88 return requests.size === 0 ? EMPTY_SUBAGENT_REQUESTS : requests;
89}
90
91/** Banner title for the subagents `childThreadInputsAtom` lists. */
92export function childThreadInputsTitle(
93 children: ReadonlyArray<Pick<EnvironmentThreadShell, "hasPendingApprovals">>,
94): string {
95 const approvals = children.filter((child) => child.hasPendingApprovals).length;
96 const need = approvals === 0 ? "input" : approvals === children.length ? "approval" : "you";
97 return children.length === 1
98 ? `Subagent needs ${need}`
99 : `${children.length} subagents need ${need}`;
100}
101
47102export function createEnvironmentThreadShellAtoms(input: {
48103 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
49104 readonly snapshotAtom: (
......@@ -54,18 +109,24 @@ export function createEnvironmentThreadShellAtoms(input: {
54109 // for every listed thread. Replaced source objects can be collected.
55110 const scopedThreads = new WeakMap<
56111 OrchestrationV2ThreadShell,
57 Map<EnvironmentId, EnvironmentThreadShell>
112 Map<string, EnvironmentThreadShell>
58113 >();
59 const scopedThread = (environmentId: EnvironmentId, thread: OrchestrationV2ThreadShell) => {
114 const scopedThread = (
115 environmentId: EnvironmentId,
116 thread: OrchestrationV2ThreadShell,
117 subagentRequests: ReadonlyMap<ThreadId, PendingSubagentRequest>,
118 ) => {
60119 let byEnvironment = scopedThreads.get(thread);
61120 if (byEnvironment === undefined) {
62121 byEnvironment = new Map();
63122 scopedThreads.set(thread, byEnvironment);
64123 }
65 let value = byEnvironment.get(environmentId);
124 const request = subagentRequests.get(thread.id) ?? null;
125 const key = request === null ? environmentId : `${environmentId}:${request}`;
126 let value = byEnvironment.get(key);
66127 if (value === undefined) {
67 value = presentThreadShell(environmentId, thread);
68 byEnvironment.set(environmentId, value);
128 value = presentThreadShell(environmentId, thread, request);
129 byEnvironment.set(key, value);
69130 }
70131 return value;
71132 };
......@@ -87,6 +148,21 @@ export function createEnvironmentThreadShellAtoms(input: {
87148 }).pipe(Atom.withLabel(`environment-thread-index:${environmentId}`)),
88149 );
89150
151 const pendingSubagentRequestsAtom = Atom.family((environmentId: EnvironmentId) => {
152 let previous = EMPTY_SUBAGENT_REQUESTS;
153 return Atom.make((get) => {
154 const next = pendingSubagentRequestsByThread(get(environmentThreadsAtom(environmentId)));
155 if (
156 next.size === previous.size &&
157 [...next].every(([threadId, request]) => previous.get(threadId) === request)
158 ) {
159 return previous;
160 }
161 previous = next;
162 return next;
163 }).pipe(Atom.withLabel(`environment-pending-subagent-requests:${environmentId}`));
164 });
165
90166 const environmentThreadRefsAtom = Atom.family((environmentId: EnvironmentId) => {
91167 let previous: ReadonlyArray<ScopedThreadRef> = [];
92168 return Atom.make((get) => {
......@@ -149,12 +225,18 @@ export function createEnvironmentThreadShellAtoms(input: {
149225 const ref = parseThreadKey(key);
150226 return Atom.make((get) => {
151227 const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null;
152 return source === null ? null : scopedThread(ref.environmentId, source);
228 return source === null
229 ? null
230 : scopedThread(
231 ref.environmentId,
232 source,
233 get(pendingSubagentRequestsAtom(ref.environmentId)),
234 );
153235 }).pipe(Atom.withLabel(`environment-thread-shell:${key}`));
154236 });
155237
156 // Child questions are hidden with their sidebar rows. Read their shell summaries,
157 // without subscribing to every child transcript.
238 // Child approvals and questions are hidden with their sidebar rows. Read their
239 // shell summaries, without subscribing to every child transcript.
158240 const childThreadInputsAtomFamily = Atom.family((key: string) => {
159241 const ref = parseThreadKey(key);
160242 let previous: ReadonlyArray<EnvironmentThreadShell> = [];
......@@ -167,6 +249,7 @@ export function createEnvironmentThreadShellAtoms(input: {
167249 if (siblings) siblings.push(thread);
168250 else children.set(parent, [thread]);
169251 }
252 const subagentRequests = get(pendingSubagentRequestsAtom(ref.environmentId));
170253 const seen = new Set<ThreadId>([ref.threadId]);
171254 const pending = [ref.threadId];
172255 const next: EnvironmentThreadShell[] = [];
......@@ -175,20 +258,20 @@ export function createEnvironmentThreadShellAtoms(input: {
175258 if (seen.has(child.id)) continue;
176259 seen.add(child.id);
177260 pending.push(child.id);
178 if (
179 child.pendingRuntimeRequest?.kind === "user_input" &&
180 isChildRunLive(child) &&
181 !isProviderNativeSubagentThread(child)
182 ) {
183 next.push(scopedThread(ref.environmentId, child));
261 if (subagentRequest(child) !== null) {
262 next.push(scopedThread(ref.environmentId, child, subagentRequests));
184263 }
185264 }
186265 }
187 // Indicators read only ids and titles, so other shell updates keep the old array.
266 // Indicators read only ids, titles and the request kind, so other shell
267 // updates keep the old array.
188268 if (
189269 previous.length === next.length &&
190270 previous.every(
191 (thread, index) => thread.id === next[index]?.id && thread.title === next[index]?.title,
271 (thread, index) =>
272 thread.id === next[index]?.id &&
273 thread.title === next[index]?.title &&
274 thread.hasPendingApprovals === next[index]?.hasPendingApprovals,
192275 )
193276 ) {
194277 return previous;
......@@ -211,6 +294,7 @@ export function createEnvironmentThreadShellAtoms(input: {
211294 ) ?? EMPTY_SCOPED_THREAD_REFS;
212295 if (refs.length === 0) continue;
213296 const threads = get(environmentThreadIndexAtom(projectRef.environmentId));
297 const subagentRequests = get(pendingSubagentRequestsAtom(projectRef.environmentId));
214298 for (const ref of refs) {
215299 const key = threadKey(ref);
216300 if (seen.has(key)) {
......@@ -219,7 +303,7 @@ export function createEnvironmentThreadShellAtoms(input: {
219303 seen.add(key);
220304 const thread = threads.get(ref.threadId);
221305 if (thread !== undefined) {
222 next.push(scopedThread(ref.environmentId, thread));
306 next.push(scopedThread(ref.environmentId, thread, subagentRequests));
223307 }
224308 }
225309 }
......@@ -248,8 +332,9 @@ export function createEnvironmentThreadShellAtoms(input: {
248332 const threadShellsAtom = Atom.make((get) => {
249333 const next: EnvironmentThreadShell[] = [];
250334 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
335 const subagentRequests = get(pendingSubagentRequestsAtom(environmentId));
251336 for (const thread of get(environmentThreadsAtom(environmentId))) {
252 next.push(scopedThread(environmentId, thread));
337 next.push(scopedThread(environmentId, thread, subagentRequests));
253338 }
254339 }
255340 if (arrayElementsEqual(previousThreadShells, next)) {
......@@ -263,10 +348,11 @@ export function createEnvironmentThreadShellAtoms(input: {
263348 const navigationThreadShellsAtom = Atom.make((get) => {
264349 const next: EnvironmentThreadShell[] = [];
265350 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
351 const subagentRequests = get(pendingSubagentRequestsAtom(environmentId));
266352 for (const thread of get(environmentThreadsAtom(environmentId))) {
267353 if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent")
268354 continue;
269 next.push(scopedThread(environmentId, thread));
355 next.push(scopedThread(environmentId, thread, subagentRequests));
270356 }
271357 }
272358 if (arrayElementsEqual(previousNavigationShells, next)) return previousNavigationShells;
packages/client-runtime/src/state/threadSnoozed.test.ts+6
......@@ -189,6 +189,12 @@ describe("canSnooze", () => {
189189 { now: NOW },
190190 ),
191191 ).toBe(false);
192 expect(
193 canSnooze(
194 { ...makeShell({}), latestUserMessageAt: null, pendingSubagentRequest: "approval" },
195 { now: NOW },
196 ),
197 ).toBe(false);
192198 });
193199
194200 it("refuses a queued turn start — same invisible-pending-work rule as settle", () => {