From f4df79477709185c490627a3fd43af96674ae778 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 15:53:19 -0700 Subject: [PATCH] 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 Co-authored-by: Claude Opus 5.5 --- .../src/features/home/useThreadListActions.ts | 4 +- .../features/threads/ThreadDetailScreen.tsx | 14 +- .../src/features/threads/threadListV2.ts | 17 ++- apps/web/src/components/ChatView.tsx | 11 +- apps/web/src/components/Sidebar.logic.ts | 11 +- .../ThreadNotificationCoordinator.test.tsx | 41 +++++- .../ThreadNotificationCoordinator.tsx | 16 ++- packages/client-runtime/src/state/models.ts | 7 + .../client-runtime/src/state/threadInbox.ts | 5 +- .../client-runtime/src/state/threadSettled.ts | 24 ++-- .../src/state/threadShell.test.ts | 68 +++++++++- .../client-runtime/src/state/threadShell.ts | 126 +++++++++++++++--- .../src/state/threadSnoozed.test.ts | 6 + 13 files changed, 290 insertions(+), 60 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 964c12a7189f2b1fb17021d42ffa4ce0e342d0ea..8e5db2faf99d839f10cd355dc3348f357e304690 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -308,7 +308,9 @@ export function useThreadListActions(): { if (!canSnooze(thread, { now: new Date().toISOString() })) { Alert.alert( "Could not snooze thread", - thread.hasPendingApprovals || thread.hasPendingUserInput + thread.hasPendingApprovals || + thread.hasPendingUserInput || + thread.pendingSubagentRequest != null ? "This thread is waiting on you. Respond to the pending request before snoozing it." : "This thread is still starting a turn. Try again once it's running.", ); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 4b7919ef6a985974425ef11d52e5b9f94173efe2..1707debb95eecd952211f233bd74b361680726f5 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -7,9 +7,10 @@ import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; -import type { - CodexFeedbackSubmission, - EnvironmentThreadStatus, +import { + childThreadInputsTitle, + type CodexFeedbackSubmission, + type EnvironmentThreadStatus, } from "@t3tools/client-runtime/state/threads"; import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; @@ -523,11 +524,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (childInput && contentPresentationKind === "ready") { return { kind: "child-input", - label: - childThreadInputs.length === 1 - ? "Subagent needs input" - : `${childThreadInputs.length} subagents need input`, - accessibilityLabel: `Open question from ${childInput.title}`, + label: childThreadInputsTitle(childThreadInputs), + accessibilityLabel: `Open ${childInput.hasPendingApprovals ? "approval" : "question"} from ${childInput.title}`, onPress: () => { Keyboard.dismiss(); navigation.navigate("Thread", { diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index b5aee8fb4b2254af0340571576d2985be3adbc2e..4024af9582715bd2f674355fd9cefece47924373 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -144,11 +144,17 @@ export function resolveThreadListV2SwipeActions(input: { export function resolveThreadListV2SnoozeGateExpiryMs( thread: Pick< EnvironmentThreadShell, - "hasPendingApprovals" | "hasPendingUserInput" | "latestRun" | "latestUserMessageAt" | "runtime" + | "hasPendingApprovals" + | "hasPendingUserInput" + | "pendingSubagentRequest" + | "latestRun" + | "latestUserMessageAt" + | "runtime" >, options: { readonly now: string }, ): number | null { if (thread.hasPendingApprovals || thread.hasPendingUserInput) return null; + if (thread.pendingSubagentRequest != null) return null; if (!hasQueuedTurnStart(thread, options)) return null; const messageAtMs = Date.parse(thread.latestUserMessageAt ?? ""); if (Number.isNaN(messageAtMs)) return null; @@ -183,12 +189,15 @@ export function threadHasUnseenCompletion( } export function resolveThreadListV2Status( - thread: Pick, + thread: Pick< + EnvironmentThreadShell, + "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" | "runtime" + >, ): ThreadListV2Status { - if (thread.hasPendingApprovals) { + if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return "approval"; } - if (thread.hasPendingUserInput) { + if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") { return "input"; } if ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ae891d75193ea69b3ecc58b86c8c66512564d430..b0d1804feffaa0dca180608709a6a598d1aa3c1f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -95,6 +95,7 @@ import { import { readPastedComposerContext } from "./composerInlineTokenPaste"; import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; +import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads"; import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; import { deriveProviderSubagentStatus, @@ -311,6 +312,7 @@ import { ChevronDownIcon, DownloadIcon, GitBranchIcon, + ShieldQuestionIcon, TargetIcon, WifiOffIcon, } from "lucide-react"; @@ -7465,11 +7467,8 @@ export default function ChatView(props: ChatViewProps) { id: `child-input:${first.id}`, variant: "info", priority: "activity", - icon: , - title: - childThreadInputs.length === 1 - ? "Subagent needs input" - : `${childThreadInputs.length} subagents need input`, + icon: first.hasPendingApprovals ? : , + title: childThreadInputsTitle(childThreadInputs), description: childThreadInputs.map((child, index) => ( {index > 0 ? " " : null} @@ -7483,7 +7482,7 @@ export default function ChatView(props: ChatViewProps) { )), actions: ( ), }; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 3fe1881332a53b585e597b17f068e0d855c8ef78..bc86619108305dc87550d4e81e37b6e411f99bc3 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -652,6 +652,7 @@ type ThreadStatusInput = Pick< | "hasActionableProposedPlan" | "hasPendingApprovals" | "hasPendingUserInput" + | "pendingSubagentRequest" | "interactionMode" | "latestRun" | "runtime" @@ -977,14 +978,14 @@ export function shouldRecedeSidebarThread(input: { type SidebarThreadStatusInput = Pick< SidebarThreadSummary, - "hasPendingApprovals" | "hasPendingUserInput" | "runtime" + "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" | "runtime" >; export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { - if (thread.hasPendingApprovals) { + if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return "approval"; } - if (thread.hasPendingUserInput) { + if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") { return "input"; } if ( @@ -1181,7 +1182,7 @@ export function resolveThreadStatusPill(input: { }): ThreadStatusPill | null { const { thread } = input; - if (thread.hasPendingApprovals) { + if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return { label: "Pending Approval", colorClass: "text-amber-600 dark:text-amber-300/90", @@ -1190,7 +1191,7 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.hasPendingUserInput) { + if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") { return { label: "Awaiting Input", colorClass: "text-indigo-600 dark:text-indigo-300/90", diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 36709813ef0e8920cd48da8d3a871927f0a2d6e5..89ad8c722125d2e7b48aba5c7120ad167d41ef6f 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -21,6 +21,7 @@ const state = vi.hoisted(() => ({ limited: false, subagent: false, background: [] as Array<{ taskId: string; kind: "command" | "monitor" }>, + delegatedChild: null as "running" | "approval" | null, add: vi.fn( (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) => "toast-1", @@ -87,10 +88,33 @@ function mockThreadShell() { }; } +// A delegated child changes while its parent's shell object stays the same. +let stableParent: ReturnType | null = null; +function mockThreads() { + if (state.delegatedChild === null) return [mockThreadShell()]; + stableParent ??= mockThreadShell(); + const child = { + ...mockThreadShell(), + id: "child-1", + title: "Review the login form", + creationSource: "mcp", + lineage: { + rootThreadId: "thread-1", + parentThreadId: "thread-1", + relationshipToParent: "subagent", + }, + pendingRuntimeRequest: + state.delegatedChild === "approval" + ? { id: "request-2", kind: "command", createdAt: SHELL_NOW } + : null, + }; + return [stableParent, child]; +} + vi.mock("@effect/atom-react", () => ({ useAtomValue: () => ({ status: state.live ? "live" : "disconnected", - snapshot: Option.some({ threads: [mockThreadShell()] }), + snapshot: Option.some({ threads: mockThreads() }), }), })); vi.mock("@tanstack/react-router", () => ({ @@ -154,7 +178,9 @@ beforeEach(() => { limited: false, subagent: false, background: [], + delegatedChild: null, }); + stableParent = null; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("window", new EventTarget()); vi.stubGlobal("document", { @@ -188,6 +214,19 @@ describe("thread notifications", () => { expect(state.notification).not.toHaveBeenCalled(); }); + it("alerts on the parent when its delegated subagent asks for approval", async () => { + state.mode = "notifications-and-sound"; + state.delegatedChild = "running"; + await render(); + state.delegatedChild = "approval"; + await render(); + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.add).toHaveBeenLastCalledWith( + expect.objectContaining({ title: "Approval needed", description: "Fix the login form" }), + ); + }); + it("alerts once with system alerts off and opens the completed thread", async () => { await render(); await complete(); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 509483d3bccd61b36dd74abf69085b407e8064b8..a74690ea8cd08c5c3052039817697fc9c29617a8 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -1,4 +1,8 @@ -import { presentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + type PendingSubagentRequest, + presentThreadShell, +} from "@t3tools/client-runtime/state/models"; +import { pendingSubagentRequestsByThread } from "@t3tools/client-runtime/state/threads"; import { useAtomValue } from "@effect/atom-react"; import { useNavigate, useParams } from "@tanstack/react-router"; import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; @@ -90,6 +94,7 @@ export function ThreadNotificationCoordinator() { interface NotificationState { readonly raw: OrchestrationV2ThreadShell; + readonly subagentRequest: PendingSubagentRequest | null; readonly attention: string | null; readonly completion: number | null; } @@ -122,15 +127,18 @@ function EnvironmentNotifications({ return; } const next = new Map(); + // Subagents are skipped below, so their requests notify through the thread above them. + const subagentRequests = pendingSubagentRequestsByThread(threads); for (const rawThread of threads) { if (rawThread.lineage.relationshipToParent === "subagent") continue; const prior = previous.current.get(rawThread.id); + const subagentRequest = subagentRequests.get(rawThread.id) ?? null; // The same object cannot produce a new notification. - if (prior?.raw === rawThread) { + if (prior?.raw === rawThread && prior.subagentRequest === subagentRequest) { next.set(rawThread.id, prior); continue; } - const thread = presentThreadShell(environmentId, rawThread); + const thread = presentThreadShell(environmentId, rawThread, subagentRequest); let status = resolveSidebarThreadStatus(thread); if (status === "ready" && thread.latestRun?.status === "failed") status = "failed"; const attention = @@ -145,7 +153,7 @@ function EnvironmentNotifications({ Number.isFinite(completedAt) ? completedAt : (prior?.completion ?? null); - next.set(thread.id, { raw: rawThread, attention, completion }); + next.set(thread.id, { raw: rawThread, subagentRequest, attention, completion }); if (!prior || thread.archivedAt !== null) continue; const kind = attention && attention !== prior.attention diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 68c9cfe4fdc5359c15fd09774d590b41b203bb20..f5d6df4f001e6d276159b5ff8393049090264df7 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -109,6 +109,9 @@ export interface EnvironmentThreadShell { readonly latestUserAuthoredMessageAt?: string | null; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; + /** What a live subagent below this thread waits on the user for. Subagent + rows are hidden, so the threads above them carry it. */ + readonly pendingSubagentRequest?: PendingSubagentRequest | null; readonly hasActionableProposedPlan: boolean; readonly pendingBackgroundTasks: ReadonlyArray< NonNullable[number] @@ -207,9 +210,12 @@ export function scopeProject( return { ...project, environmentId }; } +export type PendingSubagentRequest = "approval" | "user_input"; + export function presentThreadShell( environmentId: EnvironmentId, thread: OrchestrationV2ThreadShell, + pendingSubagentRequest: PendingSubagentRequest | null = null, ): EnvironmentThreadShell { const updatedAt = iso(thread.updatedAt); const latestRun = @@ -259,6 +265,7 @@ export function presentThreadShell( thread.pendingRuntimeRequest.kind !== "user_input" && thread.pendingRuntimeRequest.kind !== "auth_refresh", hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input", + pendingSubagentRequest, hasActionableProposedPlan: thread.hasActionableProposedPlan, pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [], providerInstanceHistory: thread.providerInstanceHistory ?? [], diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 0a7ed0918afe639231490ca0a56c27571679ed7b..8f73ea0504d094080fc6e081ddf0ca9848d6c4e0 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -9,6 +9,7 @@ type WorkingThreadInput = Pick< | "hasActionableProposedPlan" | "hasPendingApprovals" | "hasPendingUserInput" + | "pendingSubagentRequest" | "interactionMode" | "latestRun" | "runtime" @@ -16,9 +17,11 @@ type WorkingThreadInput = Pick< /** 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. */ + it. Approvals, questions (its own or a subagent's), plan prompts, and + failures stay in the inbox. */ export function isThreadWorking(thread: WorkingThreadInput): boolean { if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; + if (thread.pendingSubagentRequest != null) 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; diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 104414a9201d7cd53f296b063b487b25b71763f2..887b1b79b19a592f6c03889996cf450e032036a3 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,6 +1,8 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. import * as DateTime from "effect/DateTime"; +import type { PendingSubagentRequest } from "./models.ts"; + interface SettlementRunLike { readonly turnId?: unknown; readonly assistantMessageId?: unknown; @@ -90,18 +92,20 @@ export interface ThreadSnoozeShell extends QueuedThreadShell { readonly snoozedAt?: string | null; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; + readonly pendingSubagentRequest?: PendingSubagentRequest | null; } /** * A snoozed thread "raises its hand" when something happens that outranks - * the user's snooze: the agent is blocked on them (approval / user input), - * the session failed, or a run completed after the snooze was set — the - * v1 taste of event-based snooze ("something happened" wakes early). - * Raising a hand never clears the server-side snooze fields; it only stops - * the thread from classifying as snoozed. + * the user's snooze: the agent or one of its subagents is blocked on them + * (approval / user input), the session failed, or a run completed after the + * snooze was set — the v1 taste of event-based snooze ("something happened" + * wakes early). Raising a hand never clears the server-side snooze fields; it + * only stops the thread from classifying as snoozed. */ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; + if (shell.pendingSubagentRequest != null) return true; const runtime = shell.runtime ?? shell.session ?? null; const latestRun = shell.latestRun ?? shell.latestTurn ?? null; // Only a FRESH failure raises the hand: a thread snoozed while already @@ -127,10 +131,10 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean } /** - * A thread may be snoozed unless the agent is blocked on the user: hiding a - * pending approval or user-input request defeats the request, and a queued - * turn start (a message no turn has adopted yet) is invisible pending work - * the same way it is for settle. A running session IS snoozable — snooze + * A thread may be snoozed unless the agent or one of its subagents is blocked + * on the user: hiding a pending approval or user-input request defeats the + * request, and a queued turn start (a message no turn has adopted yet) is + * invisible pending work the same way it is for settle. A running session IS snoozable — snooze * only affects visibility, never the agent. Client-side twin of the server * invariants so the UI can reject before a round trip. */ @@ -139,6 +143,7 @@ export function canSnooze( ThreadSnoozeShell, | "hasPendingApprovals" | "hasPendingUserInput" + | "pendingSubagentRequest" | "latestUserMessageAt" | "latestTurn" | "latestRun" @@ -148,6 +153,7 @@ export function canSnooze( options: { readonly now: string }, ): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; + if (shell.pendingSubagentRequest != null) return false; if (hasQueuedTurnStart(shell, options)) return false; return true; } diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index cd8ba0e383adc2a95018d729a415bbe0fc943275..9f2e15fda48d2e55d5e47c581a3ab0ec4ec39c11 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId, ProjectId, + RunId, RuntimeRequestId, ThreadId, type OrchestrationV2ShellSnapshot, @@ -12,7 +13,8 @@ import { describe, expect, it } from "vite-plus/test"; import { PrimaryConnectionTarget } from "../connection/model.ts"; import { v2Now, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; -import { createEnvironmentThreadShellAtoms } from "./threadShell.ts"; +import { isThreadWorking } from "./threadInbox.ts"; +import { childThreadInputsTitle, createEnvironmentThreadShellAtoms } from "./threadShell.ts"; const environmentId = EnvironmentId.make("environment-v2"); const remoteEnvironmentId = EnvironmentId.make("remote-environment-v2"); @@ -151,6 +153,70 @@ describe("v2 thread shell lists", () => { registry.dispose(); }); + it("brings a parent back from Working while a nested subagent waits for approval", () => { + const { registry, threads, snapshotAtom } = makeHarness(); + const child = { + ...v2ThreadShell, + id: ThreadId.make("review"), + creationSource: "mcp" as const, + status: "running" as const, + activityRunStatus: "running" as const, + lineage: { + ...v2ThreadShell.lineage, + parentThreadId: v2ThreadShell.id, + relationshipToParent: "subagent" as const, + }, + }; + const grandchild = { + ...child, + id: ThreadId.make("review-helper"), + lineage: { ...child.lineage, parentThreadId: child.id }, + }; + // The parent's turn ended; the delegated review keeps it in Working. + const root = { + ...v2ThreadShell, + latestRunId: RunId.make("root-run"), + status: "completed" as const, + pendingBackgroundTasks: [ + { taskId: "review", kind: "subagent" as const, childThreadId: child.id }, + ], + }; + const approval = { + id: RuntimeRequestId.make("cat-file"), + kind: "command" as const, + createdAt: v2Now, + }; + const parent = threads.threadShellAtom({ environmentId, threadId: root.id }); + const inputs = threads.childThreadInputsAtom({ environmentId, threadId: root.id }); + const disposeParent = registry.mount(parent); + const disposeInputs = registry.mount(inputs); + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + threads: [root, child, grandchild], + }); + expect(isThreadWorking(registry.get(parent)!)).toBe(true); + + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + threads: [root, child, { ...grandchild, pendingRuntimeRequest: approval }], + }); + expect(registry.get(parent)?.pendingSubagentRequest).toBe("approval"); + expect(isThreadWorking(registry.get(parent)!)).toBe(false); + const asking = registry.get(inputs); + expect(asking.map((thread) => thread.id)).toEqual([grandchild.id]); + expect(childThreadInputsTitle(asking)).toBe("Subagent needs approval"); + + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + threads: [root, child, grandchild], + }); + expect(registry.get(parent)?.pendingSubagentRequest).toBeNull(); + expect(isThreadWorking(registry.get(parent)!)).toBe(true); + disposeInputs(); + disposeParent(); + registry.dispose(); + }); + it("leaves switched-off environments out of navigation", () => { const { registry, threads, catalogValueAtom } = makeHarness([ environmentId, diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 20d47aadba0715736367d1ee621c0749ee4ff688..a4315f2f8162c03f32910cf3c51f6b62e2c65f21 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -10,7 +10,7 @@ import type { import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; -import type { EnvironmentThreadShell } from "./models.ts"; +import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts"; import { presentThreadShell } from "./models.ts"; import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; import { @@ -25,6 +25,7 @@ import { const EMPTY_THREADS: ReadonlyArray = Object.freeze([]); const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray = Object.freeze([]); const EMPTY_THREAD_INDEX: ReadonlyMap = new Map(); +const EMPTY_SUBAGENT_REQUESTS: ReadonlyMap = new Map(); const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< ProjectId, ReadonlyArray @@ -44,6 +45,60 @@ const isChildRunLive = ( thread.status === "running" || thread.status === "waiting"; +/** What a subagent's live run waits on the user for. Provider-native + subagents ask on their parent, so they are skipped. */ +function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentRequest | null { + const kind = thread.pendingRuntimeRequest?.kind; + if ( + kind === undefined || + kind === "auth_refresh" || + thread.lineage.relationshipToParent !== "subagent" || + isProviderNativeSubagentThread(thread) || + !isChildRunLive(thread) + ) { + return null; + } + return kind === "user_input" ? "user_input" : "approval"; +} + +/** + * Threads above a subagent that waits on the user, by what it waits on. Every + * thread up to the visible one carries it, because subagent rows are hidden. + * An approval outranks a question. + */ +export function pendingSubagentRequestsByThread( + threads: ReadonlyArray, +): ReadonlyMap { + const requests = new Map(); + let byId: ReadonlyMap | undefined; + for (const child of threads) { + const request = subagentRequest(child); + if (request === null) continue; + byId ??= new Map(threads.map((thread) => [thread.id, thread] as const)); + const seen = new Set([child.id]); + let current: OrchestrationV2ThreadShell | undefined = child; + while (current?.lineage.relationshipToParent === "subagent") { + const parentId: ThreadId | null = current.lineage.parentThreadId; + if (parentId === null || seen.has(parentId)) break; + seen.add(parentId); + if (requests.get(parentId) !== "approval") requests.set(parentId, request); + current = byId.get(parentId); + } + } + return requests.size === 0 ? EMPTY_SUBAGENT_REQUESTS : requests; +} + +/** Banner title for the subagents `childThreadInputsAtom` lists. */ +export function childThreadInputsTitle( + children: ReadonlyArray>, +): string { + const approvals = children.filter((child) => child.hasPendingApprovals).length; + const need = approvals === 0 ? "input" : approvals === children.length ? "approval" : "you"; + return children.length === 1 + ? `Subagent needs ${need}` + : `${children.length} subagents need ${need}`; +} + export function createEnvironmentThreadShellAtoms(input: { readonly catalogValueAtom: Atom.Atom; readonly snapshotAtom: ( @@ -54,18 +109,24 @@ export function createEnvironmentThreadShellAtoms(input: { // for every listed thread. Replaced source objects can be collected. const scopedThreads = new WeakMap< OrchestrationV2ThreadShell, - Map + Map >(); - const scopedThread = (environmentId: EnvironmentId, thread: OrchestrationV2ThreadShell) => { + const scopedThread = ( + environmentId: EnvironmentId, + thread: OrchestrationV2ThreadShell, + subagentRequests: ReadonlyMap, + ) => { let byEnvironment = scopedThreads.get(thread); if (byEnvironment === undefined) { byEnvironment = new Map(); scopedThreads.set(thread, byEnvironment); } - let value = byEnvironment.get(environmentId); + const request = subagentRequests.get(thread.id) ?? null; + const key = request === null ? environmentId : `${environmentId}:${request}`; + let value = byEnvironment.get(key); if (value === undefined) { - value = presentThreadShell(environmentId, thread); - byEnvironment.set(environmentId, value); + value = presentThreadShell(environmentId, thread, request); + byEnvironment.set(key, value); } return value; }; @@ -87,6 +148,21 @@ export function createEnvironmentThreadShellAtoms(input: { }).pipe(Atom.withLabel(`environment-thread-index:${environmentId}`)), ); + const pendingSubagentRequestsAtom = Atom.family((environmentId: EnvironmentId) => { + let previous = EMPTY_SUBAGENT_REQUESTS; + return Atom.make((get) => { + const next = pendingSubagentRequestsByThread(get(environmentThreadsAtom(environmentId))); + if ( + next.size === previous.size && + [...next].every(([threadId, request]) => previous.get(threadId) === request) + ) { + return previous; + } + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-pending-subagent-requests:${environmentId}`)); + }); + const environmentThreadRefsAtom = Atom.family((environmentId: EnvironmentId) => { let previous: ReadonlyArray = []; return Atom.make((get) => { @@ -149,12 +225,18 @@ export function createEnvironmentThreadShellAtoms(input: { const ref = parseThreadKey(key); return Atom.make((get) => { const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null; - return source === null ? null : scopedThread(ref.environmentId, source); + return source === null + ? null + : scopedThread( + ref.environmentId, + source, + get(pendingSubagentRequestsAtom(ref.environmentId)), + ); }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); }); - // Child questions are hidden with their sidebar rows. Read their shell summaries, - // without subscribing to every child transcript. + // Child approvals and questions are hidden with their sidebar rows. Read their + // shell summaries, without subscribing to every child transcript. const childThreadInputsAtomFamily = Atom.family((key: string) => { const ref = parseThreadKey(key); let previous: ReadonlyArray = []; @@ -167,6 +249,7 @@ export function createEnvironmentThreadShellAtoms(input: { if (siblings) siblings.push(thread); else children.set(parent, [thread]); } + const subagentRequests = get(pendingSubagentRequestsAtom(ref.environmentId)); const seen = new Set([ref.threadId]); const pending = [ref.threadId]; const next: EnvironmentThreadShell[] = []; @@ -175,20 +258,20 @@ export function createEnvironmentThreadShellAtoms(input: { if (seen.has(child.id)) continue; seen.add(child.id); pending.push(child.id); - if ( - child.pendingRuntimeRequest?.kind === "user_input" && - isChildRunLive(child) && - !isProviderNativeSubagentThread(child) - ) { - next.push(scopedThread(ref.environmentId, child)); + if (subagentRequest(child) !== null) { + next.push(scopedThread(ref.environmentId, child, subagentRequests)); } } } - // Indicators read only ids and titles, so other shell updates keep the old array. + // Indicators read only ids, titles and the request kind, so other shell + // updates keep the old array. if ( previous.length === next.length && previous.every( - (thread, index) => thread.id === next[index]?.id && thread.title === next[index]?.title, + (thread, index) => + thread.id === next[index]?.id && + thread.title === next[index]?.title && + thread.hasPendingApprovals === next[index]?.hasPendingApprovals, ) ) { return previous; @@ -211,6 +294,7 @@ export function createEnvironmentThreadShellAtoms(input: { ) ?? EMPTY_SCOPED_THREAD_REFS; if (refs.length === 0) continue; const threads = get(environmentThreadIndexAtom(projectRef.environmentId)); + const subagentRequests = get(pendingSubagentRequestsAtom(projectRef.environmentId)); for (const ref of refs) { const key = threadKey(ref); if (seen.has(key)) { @@ -219,7 +303,7 @@ export function createEnvironmentThreadShellAtoms(input: { seen.add(key); const thread = threads.get(ref.threadId); if (thread !== undefined) { - next.push(scopedThread(ref.environmentId, thread)); + next.push(scopedThread(ref.environmentId, thread, subagentRequests)); } } } @@ -248,8 +332,9 @@ export function createEnvironmentThreadShellAtoms(input: { const threadShellsAtom = Atom.make((get) => { const next: EnvironmentThreadShell[] = []; for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { + const subagentRequests = get(pendingSubagentRequestsAtom(environmentId)); for (const thread of get(environmentThreadsAtom(environmentId))) { - next.push(scopedThread(environmentId, thread)); + next.push(scopedThread(environmentId, thread, subagentRequests)); } } if (arrayElementsEqual(previousThreadShells, next)) { @@ -263,10 +348,11 @@ export function createEnvironmentThreadShellAtoms(input: { const navigationThreadShellsAtom = Atom.make((get) => { const next: EnvironmentThreadShell[] = []; for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { + const subagentRequests = get(pendingSubagentRequestsAtom(environmentId)); for (const thread of get(environmentThreadsAtom(environmentId))) { if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") continue; - next.push(scopedThread(environmentId, thread)); + next.push(scopedThread(environmentId, thread, subagentRequests)); } } if (arrayElementsEqual(previousNavigationShells, next)) return previousNavigationShells; diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 793d5a4a3a959e764b82562a975ea54bb10f75c4..b53301f3cd5effae65d7b37d43ec7bdb5ae9fbb2 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -189,6 +189,12 @@ describe("canSnooze", () => { { now: NOW }, ), ).toBe(false); + expect( + canSnooze( + { ...makeShell({}), latestUserMessageAt: null, pendingSubagentRequest: "approval" }, + { now: NOW }, + ), + ).toBe(false); }); it("refuses a queued turn start — same invisible-pending-work rule as settle", () => { -- 2.54.0