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(): {...@@ -308,7 +308,9 @@ export function useThreadListActions(): {
308 if (!canSnooze(thread, { now: new Date().toISOString() })) {308 if (!canSnooze(thread, { now: new Date().toISOString() })) {
309 Alert.alert(309 Alert.alert(
310 "Could not snooze thread",310 "Could not snooze thread",
311 thread.hasPendingApprovals || thread.hasPendingUserInput311 thread.hasPendingApprovals ||
312 thread.hasPendingUserInput ||
313 thread.pendingSubagentRequest != null
312 ? "This thread is waiting on you. Respond to the pending request before snoozing it."314 ? "This thread is waiting on you. Respond to the pending request before snoozing it."
313 : "This thread is still starting a turn. Try again once it's running.",315 : "This thread is still starting a turn. Try again once it's running.",
314 );316 );
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+6-8
...@@ -7,9 +7,10 @@ import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";...@@ -7,9 +7,10 @@ import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
7import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";7import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
8import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";8import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
9import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff";9import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff";
10import type {10import {
11 CodexFeedbackSubmission,11 childThreadInputsTitle,
12 EnvironmentThreadStatus,12 type CodexFeedbackSubmission,
13 type EnvironmentThreadStatus,
13} from "@t3tools/client-runtime/state/threads";14} from "@t3tools/client-runtime/state/threads";
14import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";15import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
15import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";16import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
...@@ -523,11 +524,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -523,11 +524,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
523 if (childInput && contentPresentationKind === "ready") {524 if (childInput && contentPresentationKind === "ready") {
524 return {525 return {
525 kind: "child-input",526 kind: "child-input",
526 label:527 label: childThreadInputsTitle(childThreadInputs),
527 childThreadInputs.length === 1528 accessibilityLabel: `Open ${childInput.hasPendingApprovals ? "approval" : "question"} from ${childInput.title}`,
528 ? "Subagent needs input"
529 : `${childThreadInputs.length} subagents need input`,
530 accessibilityLabel: `Open question from ${childInput.title}`,
531 onPress: () => {529 onPress: () => {
532 Keyboard.dismiss();530 Keyboard.dismiss();
533 navigation.navigate("Thread", {531 navigation.navigate("Thread", {
apps/mobile/src/features/threads/threadListV2.ts+13-4
...@@ -144,11 +144,17 @@ export function resolveThreadListV2SwipeActions(input: {...@@ -144,11 +144,17 @@ export function resolveThreadListV2SwipeActions(input: {
144export function resolveThreadListV2SnoozeGateExpiryMs(144export function resolveThreadListV2SnoozeGateExpiryMs(
145 thread: Pick<145 thread: Pick<
146 EnvironmentThreadShell,146 EnvironmentThreadShell,
147 "hasPendingApprovals" | "hasPendingUserInput" | "latestRun" | "latestUserMessageAt" | "runtime"147 | "hasPendingApprovals"
148 | "hasPendingUserInput"
149 | "pendingSubagentRequest"
150 | "latestRun"
151 | "latestUserMessageAt"
152 | "runtime"
148 >,153 >,
149 options: { readonly now: string },154 options: { readonly now: string },
150): number | null {155): number | null {
151 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return null;156 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return null;
157 if (thread.pendingSubagentRequest != null) return null;
152 if (!hasQueuedTurnStart(thread, options)) return null;158 if (!hasQueuedTurnStart(thread, options)) return null;
153 const messageAtMs = Date.parse(thread.latestUserMessageAt ?? "");159 const messageAtMs = Date.parse(thread.latestUserMessageAt ?? "");
154 if (Number.isNaN(messageAtMs)) return null;160 if (Number.isNaN(messageAtMs)) return null;
...@@ -183,12 +189,15 @@ export function threadHasUnseenCompletion(...@@ -183,12 +189,15 @@ export function threadHasUnseenCompletion(
183}189}
184190
185export function resolveThreadListV2Status(191export function resolveThreadListV2Status(
186 thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "runtime">,192 thread: Pick<
193 EnvironmentThreadShell,
194 "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" | "runtime"
195 >,
187): ThreadListV2Status {196): ThreadListV2Status {
188 if (thread.hasPendingApprovals) {197 if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
189 return "approval";198 return "approval";
190 }199 }
191 if (thread.hasPendingUserInput) {200 if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
192 return "input";201 return "input";
193 }202 }
194 if (203 if (
apps/web/src/components/ChatView.tsx+5-6
...@@ -95,6 +95,7 @@ import {...@@ -95,6 +95,7 @@ import {
95import { readPastedComposerContext } from "./composerInlineTokenPaste";95import { readPastedComposerContext } from "./composerInlineTokenPaste";
96import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";96import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";
97import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";97import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
98import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads";
98import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";99import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
99import {100import {
100 deriveProviderSubagentStatus,101 deriveProviderSubagentStatus,
...@@ -311,6 +312,7 @@ import {...@@ -311,6 +312,7 @@ import {
311 ChevronDownIcon,312 ChevronDownIcon,
312 DownloadIcon,313 DownloadIcon,
313 GitBranchIcon,314 GitBranchIcon,
315 ShieldQuestionIcon,
314 TargetIcon,316 TargetIcon,
315 WifiOffIcon,317 WifiOffIcon,
316} from "lucide-react";318} from "lucide-react";
...@@ -7465,11 +7467,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -7465,11 +7467,8 @@ export default function ChatView(props: ChatViewProps) {
7465 id: `child-input:${first.id}`,7467 id: `child-input:${first.id}`,
7466 variant: "info",7468 variant: "info",
7467 priority: "activity",7469 priority: "activity",
7468 icon: <MessageCircleIcon />,7470 icon: first.hasPendingApprovals ? <ShieldQuestionIcon /> : <MessageCircleIcon />,
7469 title:7471 title: childThreadInputsTitle(childThreadInputs),
7470 childThreadInputs.length === 1
7471 ? "Subagent needs input"
7472 : `${childThreadInputs.length} subagents need input`,
7473 description: childThreadInputs.map((child, index) => (7472 description: childThreadInputs.map((child, index) => (
7474 <Fragment key={child.id}>7473 <Fragment key={child.id}>
7475 {index > 0 ? " " : null}7474 {index > 0 ? " " : null}
...@@ -7483,7 +7482,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7483,7 +7482,7 @@ export default function ChatView(props: ChatViewProps) {
7483 )),7482 )),
7484 actions: (7483 actions: (
7485 <Button size="xs" variant="ghost" onClick={() => onOpenRelatedThread(first.id)}>7484 <Button size="xs" variant="ghost" onClick={() => onOpenRelatedThread(first.id)}>
7486 Open question7485 {first.hasPendingApprovals ? "Open approval" : "Open question"}
7487 </Button>7486 </Button>
7488 ),7487 ),
7489 };7488 };
apps/web/src/components/Sidebar.logic.ts+6-5
...@@ -652,6 +652,7 @@ type ThreadStatusInput = Pick<...@@ -652,6 +652,7 @@ type ThreadStatusInput = Pick<
652 | "hasActionableProposedPlan"652 | "hasActionableProposedPlan"
653 | "hasPendingApprovals"653 | "hasPendingApprovals"
654 | "hasPendingUserInput"654 | "hasPendingUserInput"
655 | "pendingSubagentRequest"
655 | "interactionMode"656 | "interactionMode"
656 | "latestRun"657 | "latestRun"
657 | "runtime"658 | "runtime"
...@@ -977,14 +978,14 @@ export function shouldRecedeSidebarThread(input: {...@@ -977,14 +978,14 @@ export function shouldRecedeSidebarThread(input: {
977978
978type SidebarThreadStatusInput = Pick<979type SidebarThreadStatusInput = Pick<
979 SidebarThreadSummary,980 SidebarThreadSummary,
980 "hasPendingApprovals" | "hasPendingUserInput" | "runtime"981 "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" | "runtime"
981>;982>;
982983
983export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus {984export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus {
984 if (thread.hasPendingApprovals) {985 if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
985 return "approval";986 return "approval";
986 }987 }
987 if (thread.hasPendingUserInput) {988 if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
988 return "input";989 return "input";
989 }990 }
990 if (991 if (
...@@ -1181,7 +1182,7 @@ export function resolveThreadStatusPill(input: {...@@ -1181,7 +1182,7 @@ export function resolveThreadStatusPill(input: {
1181}): ThreadStatusPill | null {1182}): ThreadStatusPill | null {
1182 const { thread } = input;1183 const { thread } = input;
11831184
1184 if (thread.hasPendingApprovals) {1185 if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
1185 return {1186 return {
1186 label: "Pending Approval",1187 label: "Pending Approval",
1187 colorClass: "text-amber-600 dark:text-amber-300/90",1188 colorClass: "text-amber-600 dark:text-amber-300/90",
...@@ -1190,7 +1191,7 @@ export function resolveThreadStatusPill(input: {...@@ -1190,7 +1191,7 @@ export function resolveThreadStatusPill(input: {
1190 };1191 };
1191 }1192 }
11921193
1193 if (thread.hasPendingUserInput) {1194 if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") {
1194 return {1195 return {
1195 label: "Awaiting Input",1196 label: "Awaiting Input",
1196 colorClass: "text-indigo-600 dark:text-indigo-300/90",1197 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(() => ({...@@ -21,6 +21,7 @@ const state = vi.hoisted(() => ({
21 limited: false,21 limited: false,
22 subagent: false,22 subagent: false,
23 background: [] as Array<{ taskId: string; kind: "command" | "monitor" }>,23 background: [] as Array<{ taskId: string; kind: "command" | "monitor" }>,
24 delegatedChild: null as "running" | "approval" | null,
24 add: vi.fn(25 add: vi.fn(
25 (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) =>26 (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) =>
26 "toast-1",27 "toast-1",
...@@ -87,10 +88,33 @@ function mockThreadShell() {...@@ -87,10 +88,33 @@ function mockThreadShell() {
87 };88 };
88}89}
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
90vi.mock("@effect/atom-react", () => ({114vi.mock("@effect/atom-react", () => ({
91 useAtomValue: () => ({115 useAtomValue: () => ({
92 status: state.live ? "live" : "disconnected",116 status: state.live ? "live" : "disconnected",
93 snapshot: Option.some({ threads: [mockThreadShell()] }),117 snapshot: Option.some({ threads: mockThreads() }),
94 }),118 }),
95}));119}));
96vi.mock("@tanstack/react-router", () => ({120vi.mock("@tanstack/react-router", () => ({
...@@ -154,7 +178,9 @@ beforeEach(() => {...@@ -154,7 +178,9 @@ beforeEach(() => {
154 limited: false,178 limited: false,
155 subagent: false,179 subagent: false,
156 background: [],180 background: [],
181 delegatedChild: null,
157 });182 });
183 stableParent = null;
158 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);184 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
159 vi.stubGlobal("window", new EventTarget());185 vi.stubGlobal("window", new EventTarget());
160 vi.stubGlobal("document", {186 vi.stubGlobal("document", {
...@@ -188,6 +214,19 @@ describe("thread notifications", () => {...@@ -188,6 +214,19 @@ describe("thread notifications", () => {
188 expect(state.notification).not.toHaveBeenCalled();214 expect(state.notification).not.toHaveBeenCalled();
189 });215 });
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
191 it("alerts once with system alerts off and opens the completed thread", async () => {230 it("alerts once with system alerts off and opens the completed thread", async () => {
192 await render();231 await render();
193 await complete();232 await complete();
apps/web/src/components/ThreadNotificationCoordinator.tsx+12-4
...@@ -1,4 +1,8 @@...@@ -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";
2import { useAtomValue } from "@effect/atom-react";6import { useAtomValue } from "@effect/atom-react";
3import { useNavigate, useParams } from "@tanstack/react-router";7import { useNavigate, useParams } from "@tanstack/react-router";
4import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";8import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
...@@ -90,6 +94,7 @@ export function ThreadNotificationCoordinator() {...@@ -90,6 +94,7 @@ export function ThreadNotificationCoordinator() {
9094
91interface NotificationState {95interface NotificationState {
92 readonly raw: OrchestrationV2ThreadShell;96 readonly raw: OrchestrationV2ThreadShell;
97 readonly subagentRequest: PendingSubagentRequest | null;
93 readonly attention: string | null;98 readonly attention: string | null;
94 readonly completion: number | null;99 readonly completion: number | null;
95}100}
...@@ -122,15 +127,18 @@ function EnvironmentNotifications({...@@ -122,15 +127,18 @@ function EnvironmentNotifications({
122 return;127 return;
123 }128 }
124 const next = new Map<ThreadId, NotificationState>();129 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);
125 for (const rawThread of threads) {132 for (const rawThread of threads) {
126 if (rawThread.lineage.relationshipToParent === "subagent") continue;133 if (rawThread.lineage.relationshipToParent === "subagent") continue;
127 const prior = previous.current.get(rawThread.id);134 const prior = previous.current.get(rawThread.id);
135 const subagentRequest = subagentRequests.get(rawThread.id) ?? null;
128 // The same object cannot produce a new notification.136 // The same object cannot produce a new notification.
129 if (prior?.raw === rawThread) {137 if (prior?.raw === rawThread && prior.subagentRequest === subagentRequest) {
130 next.set(rawThread.id, prior);138 next.set(rawThread.id, prior);
131 continue;139 continue;
132 }140 }
133 const thread = presentThreadShell(environmentId, rawThread);141 const thread = presentThreadShell(environmentId, rawThread, subagentRequest);
134 let status = resolveSidebarThreadStatus(thread);142 let status = resolveSidebarThreadStatus(thread);
135 if (status === "ready" && thread.latestRun?.status === "failed") status = "failed";143 if (status === "ready" && thread.latestRun?.status === "failed") status = "failed";
136 const attention =144 const attention =
...@@ -145,7 +153,7 @@ function EnvironmentNotifications({...@@ -145,7 +153,7 @@ function EnvironmentNotifications({
145 Number.isFinite(completedAt)153 Number.isFinite(completedAt)
146 ? completedAt154 ? completedAt
147 : (prior?.completion ?? null);155 : (prior?.completion ?? null);
148 next.set(thread.id, { raw: rawThread, attention, completion });156 next.set(thread.id, { raw: rawThread, subagentRequest, attention, completion });
149 if (!prior || thread.archivedAt !== null) continue;157 if (!prior || thread.archivedAt !== null) continue;
150 const kind =158 const kind =
151 attention && attention !== prior.attention159 attention && attention !== prior.attention
packages/client-runtime/src/state/models.ts+7
...@@ -109,6 +109,9 @@ export interface EnvironmentThreadShell {...@@ -109,6 +109,9 @@ export interface EnvironmentThreadShell {
109 readonly latestUserAuthoredMessageAt?: string | null;109 readonly latestUserAuthoredMessageAt?: string | null;
110 readonly hasPendingApprovals: boolean;110 readonly hasPendingApprovals: boolean;
111 readonly hasPendingUserInput: boolean;111 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;
112 readonly hasActionableProposedPlan: boolean;115 readonly hasActionableProposedPlan: boolean;
113 readonly pendingBackgroundTasks: ReadonlyArray<116 readonly pendingBackgroundTasks: ReadonlyArray<
114 NonNullable<OrchestrationV2ThreadShell["pendingBackgroundTasks"]>[number]117 NonNullable<OrchestrationV2ThreadShell["pendingBackgroundTasks"]>[number]
...@@ -207,9 +210,12 @@ export function scopeProject(...@@ -207,9 +210,12 @@ export function scopeProject(
207 return { ...project, environmentId };210 return { ...project, environmentId };
208}211}
209212
213export type PendingSubagentRequest = "approval" | "user_input";
214
210export function presentThreadShell(215export function presentThreadShell(
211 environmentId: EnvironmentId,216 environmentId: EnvironmentId,
212 thread: OrchestrationV2ThreadShell,217 thread: OrchestrationV2ThreadShell,
218 pendingSubagentRequest: PendingSubagentRequest | null = null,
213): EnvironmentThreadShell {219): EnvironmentThreadShell {
214 const updatedAt = iso(thread.updatedAt);220 const updatedAt = iso(thread.updatedAt);
215 const latestRun =221 const latestRun =
...@@ -259,6 +265,7 @@ export function presentThreadShell(...@@ -259,6 +265,7 @@ export function presentThreadShell(
259 thread.pendingRuntimeRequest.kind !== "user_input" &&265 thread.pendingRuntimeRequest.kind !== "user_input" &&
260 thread.pendingRuntimeRequest.kind !== "auth_refresh",266 thread.pendingRuntimeRequest.kind !== "auth_refresh",
261 hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input",267 hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input",
268 pendingSubagentRequest,
262 hasActionableProposedPlan: thread.hasActionableProposedPlan,269 hasActionableProposedPlan: thread.hasActionableProposedPlan,
263 pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [],270 pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [],
264 providerInstanceHistory: thread.providerInstanceHistory ?? [],271 providerInstanceHistory: thread.providerInstanceHistory ?? [],
packages/client-runtime/src/state/threadInbox.ts+4-1
...@@ -9,6 +9,7 @@ type WorkingThreadInput = Pick<...@@ -9,6 +9,7 @@ type WorkingThreadInput = Pick<
9 | "hasActionableProposedPlan"9 | "hasActionableProposedPlan"
10 | "hasPendingApprovals"10 | "hasPendingApprovals"
11 | "hasPendingUserInput"11 | "hasPendingUserInput"
12 | "pendingSubagentRequest"
12 | "interactionMode"13 | "interactionMode"
13 | "latestRun"14 | "latestRun"
14 | "runtime"15 | "runtime"
...@@ -16,9 +17,11 @@ type WorkingThreadInput = Pick<...@@ -16,9 +17,11 @@ type WorkingThreadInput = Pick<
1617
17/** Threads busy with work that does not need the user fold into the Working18/** 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 wake19 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. */
20export function isThreadWorking(thread: WorkingThreadInput): boolean {22export function isThreadWorking(thread: WorkingThreadInput): boolean {
21 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;23 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;
24 if (thread.pendingSubagentRequest != null) return false;
22 if (!threadRuntimeIsActive(thread.runtime) && thread.runtime?.status !== "idle") return false;25 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.26 // A plan prompt outranks lingering background work: the user has to act on it.
24 const run = thread.latestRun;27 const run = thread.latestRun;
packages/client-runtime/src/state/threadSettled.ts+15-9
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.1// @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels.
2import * as DateTime from "effect/DateTime";2import * as DateTime from "effect/DateTime";
33
4import type { PendingSubagentRequest } from "./models.ts";
5
4interface SettlementRunLike {6interface SettlementRunLike {
5 readonly turnId?: unknown;7 readonly turnId?: unknown;
6 readonly assistantMessageId?: unknown;8 readonly assistantMessageId?: unknown;
...@@ -90,18 +92,20 @@ export interface ThreadSnoozeShell extends QueuedThreadShell {...@@ -90,18 +92,20 @@ export interface ThreadSnoozeShell extends QueuedThreadShell {
90 readonly snoozedAt?: string | null;92 readonly snoozedAt?: string | null;
91 readonly hasPendingApprovals: boolean;93 readonly hasPendingApprovals: boolean;
92 readonly hasPendingUserInput: boolean;94 readonly hasPendingUserInput: boolean;
95 readonly pendingSubagentRequest?: PendingSubagentRequest | null;
93}96}
9497
95/**98/**
96 * A snoozed thread "raises its hand" when something happens that outranks99 * 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),100 * the user's snooze: the agent or one of its subagents is blocked on them
98 * the session failed, or a run completed after the snooze was set — the101 * (approval / user input), the session failed, or a run completed after the
99 * v1 taste of event-based snooze ("something happened" wakes early).102 * snooze was set — the v1 taste of event-based snooze ("something happened"
100 * Raising a hand never clears the server-side snooze fields; it only stops103 * wakes early). Raising a hand never clears the server-side snooze fields; it
101 * the thread from classifying as snoozed.104 * only stops the thread from classifying as snoozed.
102 */105 */
103export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean {106export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean {
104 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true;107 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true;
108 if (shell.pendingSubagentRequest != null) return true;
105 const runtime = shell.runtime ?? shell.session ?? null;109 const runtime = shell.runtime ?? shell.session ?? null;
106 const latestRun = shell.latestRun ?? shell.latestTurn ?? null;110 const latestRun = shell.latestRun ?? shell.latestTurn ?? null;
107 // Only a FRESH failure raises the hand: a thread snoozed while already111 // Only a FRESH failure raises the hand: a thread snoozed while already
...@@ -127,10 +131,10 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean...@@ -127,10 +131,10 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean
127}131}
128132
129/**133/**
130 * A thread may be snoozed unless the agent is blocked on the user: hiding a134 * A thread may be snoozed unless the agent or one of its subagents is blocked
131 * pending approval or user-input request defeats the request, and a queued135 * on the user: hiding a pending approval or user-input request defeats the
132 * turn start (a message no turn has adopted yet) is invisible pending work136 * request, and a queued turn start (a message no turn has adopted yet) is
133 * the same way it is for settle. A running session IS snoozable — snooze137 * invisible pending work the same way it is for settle. A running session IS snoozable — snooze
134 * only affects visibility, never the agent. Client-side twin of the server138 * only affects visibility, never the agent. Client-side twin of the server
135 * invariants so the UI can reject before a round trip.139 * invariants so the UI can reject before a round trip.
136 */140 */
...@@ -139,6 +143,7 @@ export function canSnooze(...@@ -139,6 +143,7 @@ export function canSnooze(
139 ThreadSnoozeShell,143 ThreadSnoozeShell,
140 | "hasPendingApprovals"144 | "hasPendingApprovals"
141 | "hasPendingUserInput"145 | "hasPendingUserInput"
146 | "pendingSubagentRequest"
142 | "latestUserMessageAt"147 | "latestUserMessageAt"
143 | "latestTurn"148 | "latestTurn"
144 | "latestRun"149 | "latestRun"
...@@ -148,6 +153,7 @@ export function canSnooze(...@@ -148,6 +153,7 @@ export function canSnooze(
148 options: { readonly now: string },153 options: { readonly now: string },
149): boolean {154): boolean {
150 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false;155 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false;
156 if (shell.pendingSubagentRequest != null) return false;
151 if (hasQueuedTurnStart(shell, options)) return false;157 if (hasQueuedTurnStart(shell, options)) return false;
152 return true;158 return true;
153}159}
packages/client-runtime/src/state/threadShell.test.ts+67-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 EnvironmentId,2 EnvironmentId,
3 ProjectId,3 ProjectId,
4 RunId,
4 RuntimeRequestId,5 RuntimeRequestId,
5 ThreadId,6 ThreadId,
6 type OrchestrationV2ShellSnapshot,7 type OrchestrationV2ShellSnapshot,
...@@ -12,7 +13,8 @@ import { describe, expect, it } from "vite-plus/test";...@@ -12,7 +13,8 @@ import { describe, expect, it } from "vite-plus/test";
12import { PrimaryConnectionTarget } from "../connection/model.ts";13import { PrimaryConnectionTarget } from "../connection/model.ts";
13import { v2Now, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";14import { v2Now, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
14import { applyShellStreamEvent } from "./shellReducer.ts";15import { applyShellStreamEvent } from "./shellReducer.ts";
15import { createEnvironmentThreadShellAtoms } from "./threadShell.ts";16import { isThreadWorking } from "./threadInbox.ts";
17import { childThreadInputsTitle, createEnvironmentThreadShellAtoms } from "./threadShell.ts";
1618
17const environmentId = EnvironmentId.make("environment-v2");19const environmentId = EnvironmentId.make("environment-v2");
18const remoteEnvironmentId = EnvironmentId.make("remote-environment-v2");20const remoteEnvironmentId = EnvironmentId.make("remote-environment-v2");
...@@ -151,6 +153,70 @@ describe("v2 thread shell lists", () => {...@@ -151,6 +153,70 @@ describe("v2 thread shell lists", () => {
151 registry.dispose();153 registry.dispose();
152 });154 });
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
154 it("leaves switched-off environments out of navigation", () => {220 it("leaves switched-off environments out of navigation", () => {
155 const { registry, threads, catalogValueAtom } = makeHarness([221 const { registry, threads, catalogValueAtom } = makeHarness([
156 environmentId,222 environmentId,
packages/client-runtime/src/state/threadShell.ts+106-20
...@@ -10,7 +10,7 @@ import type {...@@ -10,7 +10,7 @@ import type {
10import { isProviderNativeSubagentThread } from "@t3tools/contracts";10import { isProviderNativeSubagentThread } from "@t3tools/contracts";
11import { Atom } from "effect/reactivity";11import { Atom } from "effect/reactivity";
1212
13import type { EnvironmentThreadShell } from "./models.ts";13import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts";
14import { presentThreadShell } from "./models.ts";14import { presentThreadShell } from "./models.ts";
15import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts";15import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts";
16import {16import {
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25const EMPTY_THREADS: ReadonlyArray<OrchestrationV2ThreadShell> = Object.freeze([]);25const EMPTY_THREADS: ReadonlyArray<OrchestrationV2ThreadShell> = Object.freeze([]);
26const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);26const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);
27const EMPTY_THREAD_INDEX: ReadonlyMap<ThreadId, OrchestrationV2ThreadShell> = new Map();27const EMPTY_THREAD_INDEX: ReadonlyMap<ThreadId, OrchestrationV2ThreadShell> = new Map();
28const EMPTY_SUBAGENT_REQUESTS: ReadonlyMap<ThreadId, PendingSubagentRequest> = new Map();
28const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap<29const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap<
29 ProjectId,30 ProjectId,
30 ReadonlyArray<ScopedThreadRef>31 ReadonlyArray<ScopedThreadRef>
...@@ -44,6 +45,60 @@ const isChildRunLive = (...@@ -44,6 +45,60 @@ const isChildRunLive = (
44 thread.status === "running" ||45 thread.status === "running" ||
45 thread.status === "waiting";46 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
47export function createEnvironmentThreadShellAtoms(input: {102export function createEnvironmentThreadShellAtoms(input: {
48 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;103 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
49 readonly snapshotAtom: (104 readonly snapshotAtom: (
...@@ -54,18 +109,24 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -54,18 +109,24 @@ export function createEnvironmentThreadShellAtoms(input: {
54 // for every listed thread. Replaced source objects can be collected.109 // for every listed thread. Replaced source objects can be collected.
55 const scopedThreads = new WeakMap<110 const scopedThreads = new WeakMap<
56 OrchestrationV2ThreadShell,111 OrchestrationV2ThreadShell,
57 Map<EnvironmentId, EnvironmentThreadShell>112 Map<string, EnvironmentThreadShell>
58 >();113 >();
59 const scopedThread = (environmentId: EnvironmentId, thread: OrchestrationV2ThreadShell) => {114 const scopedThread = (
115 environmentId: EnvironmentId,
116 thread: OrchestrationV2ThreadShell,
117 subagentRequests: ReadonlyMap<ThreadId, PendingSubagentRequest>,
118 ) => {
60 let byEnvironment = scopedThreads.get(thread);119 let byEnvironment = scopedThreads.get(thread);
61 if (byEnvironment === undefined) {120 if (byEnvironment === undefined) {
62 byEnvironment = new Map();121 byEnvironment = new Map();
63 scopedThreads.set(thread, byEnvironment);122 scopedThreads.set(thread, byEnvironment);
64 }123 }
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);
66 if (value === undefined) {127 if (value === undefined) {
67 value = presentThreadShell(environmentId, thread);128 value = presentThreadShell(environmentId, thread, request);
68 byEnvironment.set(environmentId, value);129 byEnvironment.set(key, value);
69 }130 }
70 return value;131 return value;
71 };132 };
...@@ -87,6 +148,21 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -87,6 +148,21 @@ export function createEnvironmentThreadShellAtoms(input: {
87 }).pipe(Atom.withLabel(`environment-thread-index:${environmentId}`)),148 }).pipe(Atom.withLabel(`environment-thread-index:${environmentId}`)),
88 );149 );
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
90 const environmentThreadRefsAtom = Atom.family((environmentId: EnvironmentId) => {166 const environmentThreadRefsAtom = Atom.family((environmentId: EnvironmentId) => {
91 let previous: ReadonlyArray<ScopedThreadRef> = [];167 let previous: ReadonlyArray<ScopedThreadRef> = [];
92 return Atom.make((get) => {168 return Atom.make((get) => {
...@@ -149,12 +225,18 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -149,12 +225,18 @@ export function createEnvironmentThreadShellAtoms(input: {
149 const ref = parseThreadKey(key);225 const ref = parseThreadKey(key);
150 return Atom.make((get) => {226 return Atom.make((get) => {
151 const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null;227 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 );
153 }).pipe(Atom.withLabel(`environment-thread-shell:${key}`));235 }).pipe(Atom.withLabel(`environment-thread-shell:${key}`));
154 });236 });
155237
156 // Child questions are hidden with their sidebar rows. Read their shell summaries,238 // Child approvals and questions are hidden with their sidebar rows. Read their
157 // without subscribing to every child transcript.239 // shell summaries, without subscribing to every child transcript.
158 const childThreadInputsAtomFamily = Atom.family((key: string) => {240 const childThreadInputsAtomFamily = Atom.family((key: string) => {
159 const ref = parseThreadKey(key);241 const ref = parseThreadKey(key);
160 let previous: ReadonlyArray<EnvironmentThreadShell> = [];242 let previous: ReadonlyArray<EnvironmentThreadShell> = [];
...@@ -167,6 +249,7 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -167,6 +249,7 @@ export function createEnvironmentThreadShellAtoms(input: {
167 if (siblings) siblings.push(thread);249 if (siblings) siblings.push(thread);
168 else children.set(parent, [thread]);250 else children.set(parent, [thread]);
169 }251 }
252 const subagentRequests = get(pendingSubagentRequestsAtom(ref.environmentId));
170 const seen = new Set<ThreadId>([ref.threadId]);253 const seen = new Set<ThreadId>([ref.threadId]);
171 const pending = [ref.threadId];254 const pending = [ref.threadId];
172 const next: EnvironmentThreadShell[] = [];255 const next: EnvironmentThreadShell[] = [];
...@@ -175,20 +258,20 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -175,20 +258,20 @@ export function createEnvironmentThreadShellAtoms(input: {
175 if (seen.has(child.id)) continue;258 if (seen.has(child.id)) continue;
176 seen.add(child.id);259 seen.add(child.id);
177 pending.push(child.id);260 pending.push(child.id);
178 if (261 if (subagentRequest(child) !== null) {
179 child.pendingRuntimeRequest?.kind === "user_input" &&262 next.push(scopedThread(ref.environmentId, child, subagentRequests));
180 isChildRunLive(child) &&
181 !isProviderNativeSubagentThread(child)
182 ) {
183 next.push(scopedThread(ref.environmentId, child));
184 }263 }
185 }264 }
186 }265 }
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.
188 if (268 if (
189 previous.length === next.length &&269 previous.length === next.length &&
190 previous.every(270 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,
192 )275 )
193 ) {276 ) {
194 return previous;277 return previous;
...@@ -211,6 +294,7 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -211,6 +294,7 @@ export function createEnvironmentThreadShellAtoms(input: {
211 ) ?? EMPTY_SCOPED_THREAD_REFS;294 ) ?? EMPTY_SCOPED_THREAD_REFS;
212 if (refs.length === 0) continue;295 if (refs.length === 0) continue;
213 const threads = get(environmentThreadIndexAtom(projectRef.environmentId));296 const threads = get(environmentThreadIndexAtom(projectRef.environmentId));
297 const subagentRequests = get(pendingSubagentRequestsAtom(projectRef.environmentId));
214 for (const ref of refs) {298 for (const ref of refs) {
215 const key = threadKey(ref);299 const key = threadKey(ref);
216 if (seen.has(key)) {300 if (seen.has(key)) {
...@@ -219,7 +303,7 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -219,7 +303,7 @@ export function createEnvironmentThreadShellAtoms(input: {
219 seen.add(key);303 seen.add(key);
220 const thread = threads.get(ref.threadId);304 const thread = threads.get(ref.threadId);
221 if (thread !== undefined) {305 if (thread !== undefined) {
222 next.push(scopedThread(ref.environmentId, thread));306 next.push(scopedThread(ref.environmentId, thread, subagentRequests));
223 }307 }
224 }308 }
225 }309 }
...@@ -248,8 +332,9 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -248,8 +332,9 @@ export function createEnvironmentThreadShellAtoms(input: {
248 const threadShellsAtom = Atom.make((get) => {332 const threadShellsAtom = Atom.make((get) => {
249 const next: EnvironmentThreadShell[] = [];333 const next: EnvironmentThreadShell[] = [];
250 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {334 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
335 const subagentRequests = get(pendingSubagentRequestsAtom(environmentId));
251 for (const thread of get(environmentThreadsAtom(environmentId))) {336 for (const thread of get(environmentThreadsAtom(environmentId))) {
252 next.push(scopedThread(environmentId, thread));337 next.push(scopedThread(environmentId, thread, subagentRequests));
253 }338 }
254 }339 }
255 if (arrayElementsEqual(previousThreadShells, next)) {340 if (arrayElementsEqual(previousThreadShells, next)) {
...@@ -263,10 +348,11 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -263,10 +348,11 @@ export function createEnvironmentThreadShellAtoms(input: {
263 const navigationThreadShellsAtom = Atom.make((get) => {348 const navigationThreadShellsAtom = Atom.make((get) => {
264 const next: EnvironmentThreadShell[] = [];349 const next: EnvironmentThreadShell[] = [];
265 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {350 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
351 const subagentRequests = get(pendingSubagentRequestsAtom(environmentId));
266 for (const thread of get(environmentThreadsAtom(environmentId))) {352 for (const thread of get(environmentThreadsAtom(environmentId))) {
267 if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent")353 if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent")
268 continue;354 continue;
269 next.push(scopedThread(environmentId, thread));355 next.push(scopedThread(environmentId, thread, subagentRequests));
270 }356 }
271 }357 }
272 if (arrayElementsEqual(previousNavigationShells, next)) return previousNavigationShells;358 if (arrayElementsEqual(previousNavigationShells, next)) return previousNavigationShells;
packages/client-runtime/src/state/threadSnoozed.test.ts+6
...@@ -189,6 +189,12 @@ describe("canSnooze", () => {...@@ -189,6 +189,12 @@ describe("canSnooze", () => {
189 { now: NOW },189 { now: NOW },
190 ),190 ),
191 ).toBe(false);191 ).toBe(false);
192 expect(
193 canSnooze(
194 { ...makeShell({}), latestUserMessageAt: null, pendingSubagentRequest: "approval" },
195 { now: NOW },
196 ),
197 ).toBe(false);
192 });198 });
193199
194 it("refuses a queued turn start — same invisible-pending-work rule as settle", () => {200 it("refuses a queued turn start — same invisible-pending-work rule as settle", () => {