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