| 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,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.hasPendingUserInput | 311 | 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"; |
| 7 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | 7 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 8 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 8 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 9 | import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; | 9 | import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; |
| 10 | import type { | 10 | import { |
| 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"; |
| 14 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; | 15 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; |
| 15 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; | 16 | import { 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 === 1 | 528 | 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: { |
| 144 | export function resolveThreadListV2SnoozeGateExpiryMs( | 144 | export 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 | } |
| 184 | 190 | ||
| 185 | export function resolveThreadListV2Status( | 191 | export 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 { |
| 95 | import { readPastedComposerContext } from "./composerInlineTokenPaste"; | 95 | import { readPastedComposerContext } from "./composerInlineTokenPaste"; |
| 96 | import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; | 96 | import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; |
| 97 | import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; | 97 | import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; |
| 98 | import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads"; | ||
| 98 | import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; | 99 | import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; |
| 99 | import { | 100 | import { |
| 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 question | 7485 | {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: { |
| 977 | 978 | ||
| 978 | type SidebarThreadStatusInput = Pick< | 979 | type SidebarThreadStatusInput = Pick< |
| 979 | SidebarThreadSummary, | 980 | SidebarThreadSummary, |
| 980 | "hasPendingApprovals" | "hasPendingUserInput" | "runtime" | 981 | "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" | "runtime" |
| 981 | >; | 982 | >; |
| 982 | 983 | ||
| 983 | export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { | 984 | export 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; |
| 1183 | 1184 | ||
| 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 | } |
| 1192 | 1193 | ||
| 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 | } |
| 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 | vi.mock("@effect/atom-react", () => ({ | 114 | vi.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 | })); |
| 96 | vi.mock("@tanstack/react-router", () => ({ | 120 | vi.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 | }); |
| 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 | 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 @@ |
| 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 | import { useAtomValue } from "@effect/atom-react"; | 6 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { useNavigate, useParams } from "@tanstack/react-router"; | 7 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 4 | import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; | 8 | import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; |
| ... | @@ -90,6 +94,7 @@ export function ThreadNotificationCoordinator() { | ... | @@ -90,6 +94,7 @@ export function ThreadNotificationCoordinator() { |
| 90 | 94 | ||
| 91 | interface NotificationState { | 95 | interface 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 | ? completedAt | 154 | ? 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.attention | 159 | 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 | } |
| 209 | 212 | ||
| 213 | export type PendingSubagentRequest = "approval" | "user_input"; | ||
| 214 | |||
| 210 | export function presentThreadShell( | 215 | export 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< |
| 16 | 17 | ||
| 17 | /** Threads busy with work that does not need the user fold into the Working | 18 | /** 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 wake | 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 | export function isThreadWorking(thread: WorkingThreadInput): boolean { | 22 | export 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. |
| 2 | import * as DateTime from "effect/DateTime"; | 2 | import * as DateTime from "effect/DateTime"; |
| 3 | 3 | ||
| 4 | import type { PendingSubagentRequest } from "./models.ts"; | ||
| 5 | |||
| 4 | interface SettlementRunLike { | 6 | interface 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 | } |
| 94 | 97 | ||
| 95 | /** | 98 | /** |
| 96 | * A snoozed thread "raises its hand" when something happens that outranks | 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), | 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 — the | 101 | * (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 stops | 103 | * 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 | */ |
| 103 | export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { | 106 | export 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 already | 111 | // 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 | } |
| 128 | 132 | ||
| 129 | /** | 133 | /** |
| 130 | * A thread may be snoozed unless the agent is blocked on the user: hiding a | 134 | * 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 queued | 135 | * 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 work | 136 | * 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 — snooze | 137 | * 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 server | 138 | * 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 12 | import { PrimaryConnectionTarget } from "../connection/model.ts"; | 13 | import { PrimaryConnectionTarget } from "../connection/model.ts"; |
| 13 | import { v2Now, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; | 14 | import { v2Now, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; |
| 14 | import { applyShellStreamEvent } from "./shellReducer.ts"; | 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 | const environmentId = EnvironmentId.make("environment-v2"); | 19 | const environmentId = EnvironmentId.make("environment-v2"); |
| 18 | const remoteEnvironmentId = EnvironmentId.make("remote-environment-v2"); | 20 | const 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 | }); |
| 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 | 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 { |
| 10 | import { isProviderNativeSubagentThread } from "@t3tools/contracts"; | 10 | import { isProviderNativeSubagentThread } from "@t3tools/contracts"; |
| 11 | import { Atom } from "effect/reactivity"; | 11 | import { Atom } from "effect/reactivity"; |
| 12 | 12 | ||
| 13 | import type { EnvironmentThreadShell } from "./models.ts"; | 13 | import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts"; |
| 14 | import { presentThreadShell } from "./models.ts"; | 14 | import { presentThreadShell } from "./models.ts"; |
| 15 | import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; | 15 | import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; |
| 16 | import { | 16 | import { |
| ... | @@ -25,6 +25,7 @@ import { | ... | @@ -25,6 +25,7 @@ import { |
| 25 | const EMPTY_THREADS: ReadonlyArray<OrchestrationV2ThreadShell> = Object.freeze([]); | 25 | const EMPTY_THREADS: ReadonlyArray<OrchestrationV2ThreadShell> = Object.freeze([]); |
| 26 | const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); | 26 | const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); |
| 27 | const EMPTY_THREAD_INDEX: ReadonlyMap<ThreadId, OrchestrationV2ThreadShell> = new Map(); | 27 | const EMPTY_THREAD_INDEX: ReadonlyMap<ThreadId, OrchestrationV2ThreadShell> = new Map(); |
| 28 | const EMPTY_SUBAGENT_REQUESTS: ReadonlyMap<ThreadId, PendingSubagentRequest> = new Map(); | ||
| 28 | const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< | 29 | const 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"; |
| 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 | export function createEnvironmentThreadShellAtoms(input: { | 102 | export 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 | ); |
| 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 | 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 | }); |
| 155 | 237 | ||
| 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 | }); |
| 193 | 199 | ||
| 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", () => { |