From 57f43636557540680c1a6eb7790ab4b7473a6543 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:55:10 +0000 Subject: [PATCH 1/5] fix(chat): surface pending subagent questions on parents --- .../features/threads/ThreadDetailScreen.tsx | 24 +++++++- .../threads/floating-working-control.tsx | 24 +++++++- .../threads/floating-working-status.ts | 6 ++ apps/mobile/src/state/entities.ts | 4 ++ apps/web/src/components/ChatView.tsx | 33 +++++++++- apps/web/src/state/entities.ts | 11 ++++ .../client-runtime/src/state/threadShell.ts | 60 +++++++++++++++++++ 7 files changed, 157 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 1d3beebe9247..2f5800bb7a77 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useThreadReportedModelSelection } from "../../state/entities"; +import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; import { useNavigation } from "@react-navigation/native"; import type { WorktreeSetupCardProps } from "./worktree-setup-card"; @@ -389,6 +389,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const childThreadInputs = useChildThreadInputs({ + environmentId: props.environmentId, + threadId: props.selectedThread.id, + }); const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; const queuedCount = useThreadQueuedCount({ environmentId: props.environmentId, @@ -496,6 +500,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (threadSyncLabel !== null) { return { kind: "syncing", label: threadSyncLabel }; } + const childInput = childThreadInputs[0]; + if (childInput && contentPresentationKind === "ready") { + return { + kind: "child-input", + label: + childThreadInputs.length === 1 + ? "Subagent needs input" + : `${childThreadInputs.length} subagents need input`, + accessibilityLabel: `Open question from ${childInput.title}`, + onPress: () => { + Keyboard.dismiss(); + navigation.navigate("Thread", { + environmentId: props.environmentId, + threadId: childInput.threadId, + }); + }, + }; + } if (props.isCompacting && contentPresentationKind === "ready") { return { kind: "compacting" }; } diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 2e032eb8ba5e..4bec4f727e91 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -159,15 +159,16 @@ export function FloatingWorkingControl(props: { hasAgents || hasQueue || (props.devicePreview !== null && props.browserPreview !== null); - // The queue, agents, and reconnect labels have separate tap targets. - const statusInteractive = props.status?.kind === "connection"; + // The queue, agents, questions, and reconnect labels have separate tap targets. + const statusInteractive = + props.status?.kind === "connection" || props.status?.kind === "child-input"; const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview; // The host stays centered on the capsule, but its measurement constraint // comes from the overlay, independent of the capsule's current width. const statusContent = props.status !== null ? ( @@ -371,6 +372,23 @@ function FloatingStatusLabel(props: { if (props.status.kind === "compacting") { return ; } + if (props.status.kind === "child-input") { + return ( + + + + {props.status.label} + + + ); + } if (props.status.kind === "connection") { return ( void; + } // The turn settled while background work it started still runs. `waiting` // is false when only commands remain, such as a dev server: the agent is done. | { diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 94ab662c7291..434710d1d807 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -76,6 +76,10 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef) { + return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref)); +} + export function useEnvironmentServerConfig( environmentId: EnvironmentId | null, ): ServerConfig | null { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 89b74c063da4..1257fec113df 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -292,6 +292,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, + MessageCircleIcon, PaperclipIcon, ChevronDownIcon, DownloadIcon, @@ -417,6 +418,7 @@ import { useThreadStatus, useThreadHistory, useThreadShell, + useChildThreadInputs, useThreadRefs, useThreadVisibleTurnItems, waitForThreadShell, @@ -7204,6 +7206,34 @@ export default function ChatView(props: ChatViewProps) { }, [environmentId, navigate], ); + const childThreadInputs = useChildThreadInputs(activeThreadRef); + const childInputBannerItem = useMemo(() => { + const first = childThreadInputs[0]; + if (!first || activePendingApproval || activePendingUserInput) return null; + return { + id: `child-input:${first.requestId}`, + variant: "info", + priority: "activity", + icon: , + title: + childThreadInputs.length === 1 + ? "Subagent needs input" + : `${childThreadInputs.length} subagents need input`, + description: childThreadInputs.map((child, index) => ( + + {index > 0 ? ", " : null} + onOpenRelatedThread(child.threadId)}> + {child.title} + + + )), + actions: ( + + ), + }; + }, [childThreadInputs, activePendingApproval, activePendingUserInput, onOpenRelatedThread]); // Commands such as /compact and /goal clear run as their own turn. The draft // and its attachments stay local. @@ -7595,7 +7625,7 @@ export default function ChatView(props: ChatViewProps) { : null; const composerBannerItems = useMemo(() => { const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; - const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter( + const backgroundWorkItems = [childInputBannerItem, goalBannerItem, backgroundWorkBannerItem].filter( (item) => item !== null, ); const resumeCompactionItems = @@ -7676,6 +7706,7 @@ export default function ChatView(props: ChatViewProps) { handleRestoreThreadBranch, isRestoringThreadBranch, backgroundWorkBannerItem, + childInputBannerItem, goalBannerItem, localCheckoutBranchMismatch, parkedThreadBannerItem, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index aa9b650fb5b5..fe02e0504587 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -37,6 +37,9 @@ const EMPTY_THREAD_SHELLS_ATOM = Atom.make const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-shell:empty"), ); +const EMPTY_CHILD_THREAD_INPUTS_ATOM = Atom.make< + Atom.Type> +>([]); const EMPTY_THREAD_PROJECTION_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-projection:empty"), ); @@ -117,6 +120,14 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef | null) { + return useAtomValue( + ref === null + ? EMPTY_CHILD_THREAD_INPUTS_ATOM + : environmentThreadShells.childThreadInputsAtom(ref), + ); +} + export function useThreadProjection(ref: ScopedThreadRef | null): EnvironmentThread | null { return useAtomValue( ref === null ? EMPTY_THREAD_PROJECTION_ATOM : environmentThreadDetails.threadAtom(ref), diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index a59fbb2375d3..5b2cd410d8f7 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -3,10 +3,12 @@ import type { OrchestrationV2ShellSnapshot, OrchestrationV2ThreadShell, ProjectId, + RuntimeRequestId, ScopedProjectRef, ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; +import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import type { EnvironmentThreadShell } from "./models.ts"; @@ -24,6 +26,11 @@ import { const EMPTY_THREADS: ReadonlyArray = Object.freeze([]); const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray = Object.freeze([]); const EMPTY_THREAD_INDEX: ReadonlyMap = new Map(); +type ChildThreadInput = { + readonly threadId: ThreadId; + readonly title: string; + readonly requestId: RuntimeRequestId; +}; const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< ProjectId, ReadonlyArray @@ -138,6 +145,58 @@ export function createEnvironmentThreadShellAtoms(input: { }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); }); + // Child questions are hidden with their sidebar rows. Read their shell summaries, + // without subscribing to every child transcript or repainting on unrelated activity. + const childThreadInputsAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const children = new Map(); + for (const thread of get(environmentThreadsAtom(ref.environmentId))) { + const parent = thread.lineage.parentThreadId; + if (parent === null || thread.lineage.relationshipToParent !== "subagent") continue; + const siblings = children.get(parent); + if (siblings) siblings.push(thread); + else children.set(parent, [thread]); + } + const seen = new Set([ref.threadId]); + const pending = [ref.threadId]; + const next: ChildThreadInput[] = []; + for (const parent of pending) { + for (const child of children.get(parent) ?? []) { + if (seen.has(child.id)) continue; + seen.add(child.id); + pending.push(child.id); + if ( + child.pendingRuntimeRequest?.kind === "user_input" && + !isProviderNativeSubagentThread(child) + ) { + next.push({ + threadId: child.id, + title: scopedThread(ref.environmentId, child).title, + requestId: child.pendingRuntimeRequest.id, + }); + } + } + } + if ( + previous.length === next.length && + next.every((child, index) => { + const old = previous[index]; + return ( + old?.threadId === child.threadId && + old.title === child.title && + old.requestId === child.requestId + ); + }) + ) { + return previous; + } + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-child-thread-inputs:${key}`)); + }); + const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => { const projectRefs = parseProjectRefCollectionKey(key); let previous: ReadonlyArray = []; @@ -225,5 +284,6 @@ export function createEnvironmentThreadShellAtoms(input: { threadShellsForProjectRefsAtom: (refs: ReadonlyArray) => threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), + childThreadInputsAtom: (ref: ScopedThreadRef) => childThreadInputsAtomFamily(threadKey(ref)), }; } From 004739b99397f2f4834dffe01aa41986d36b5b15 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:09:16 +0000 Subject: [PATCH 2/5] style(web): format child attention banner list --- apps/web/src/components/ChatView.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1257fec113df..0cd235830dc5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7625,9 +7625,11 @@ export default function ChatView(props: ChatViewProps) { : null; const composerBannerItems = useMemo(() => { const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; - const backgroundWorkItems = [childInputBannerItem, goalBannerItem, backgroundWorkBannerItem].filter( - (item) => item !== null, - ); + const backgroundWorkItems = [ + childInputBannerItem, + goalBannerItem, + backgroundWorkBannerItem, + ].filter((item) => item !== null); const resumeCompactionItems = resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; From 662bbcebc8f7420455df726ebe71889d0b4b8502 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:17:36 +0000 Subject: [PATCH 3/5] refactor(clients): child question selector returns thread shells --- .../features/threads/ThreadDetailScreen.tsx | 2 +- apps/web/src/components/ChatView.tsx | 8 ++--- apps/web/src/state/entities.ts | 7 +--- .../client-runtime/src/state/threadShell.ts | 32 +++---------------- 4 files changed, 11 insertions(+), 38 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 2b4c0ef66c33..f7632f6be25e 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -515,7 +515,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread Keyboard.dismiss(); navigation.navigate("Thread", { environmentId: props.environmentId, - threadId: childInput.threadId, + threadId: childInput.id, }); }, }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index eb41b78ddb24..9566c35dd588 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7422,7 +7422,7 @@ export default function ChatView(props: ChatViewProps) { const first = childThreadInputs[0]; if (!first || activePendingApproval || activePendingUserInput) return null; return { - id: `child-input:${first.requestId}`, + id: `child-input:${first.id}`, variant: "info", priority: "activity", icon: , @@ -7431,15 +7431,15 @@ export default function ChatView(props: ChatViewProps) { ? "Subagent needs input" : `${childThreadInputs.length} subagents need input`, description: childThreadInputs.map((child, index) => ( - + {index > 0 ? ", " : null} - onOpenRelatedThread(child.threadId)}> + onOpenRelatedThread(child.id)}> {child.title} )), actions: ( - ), diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index fe02e0504587..ebf62f01be6f 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -37,9 +37,6 @@ const EMPTY_THREAD_SHELLS_ATOM = Atom.make const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-shell:empty"), ); -const EMPTY_CHILD_THREAD_INPUTS_ATOM = Atom.make< - Atom.Type> ->([]); const EMPTY_THREAD_PROJECTION_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-projection:empty"), ); @@ -122,9 +119,7 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh export function useChildThreadInputs(ref: ScopedThreadRef | null) { return useAtomValue( - ref === null - ? EMPTY_CHILD_THREAD_INPUTS_ATOM - : environmentThreadShells.childThreadInputsAtom(ref), + ref === null ? EMPTY_THREAD_SHELLS_ATOM : environmentThreadShells.childThreadInputsAtom(ref), ); } diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 73a017f69f41..07d1354b179c 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -3,7 +3,6 @@ import type { OrchestrationV2ShellSnapshot, OrchestrationV2ThreadShell, ProjectId, - RuntimeRequestId, ScopedProjectRef, ScopedThreadRef, ThreadId, @@ -26,11 +25,6 @@ import { const EMPTY_THREADS: ReadonlyArray = Object.freeze([]); const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray = Object.freeze([]); const EMPTY_THREAD_INDEX: ReadonlyMap = new Map(); -type ChildThreadInput = { - readonly threadId: ThreadId; - readonly title: string; - readonly requestId: RuntimeRequestId; -}; const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< ProjectId, ReadonlyArray @@ -146,10 +140,10 @@ export function createEnvironmentThreadShellAtoms(input: { }); // Child questions are hidden with their sidebar rows. Read their shell summaries, - // without subscribing to every child transcript or repainting on unrelated activity. + // without subscribing to every child transcript. const childThreadInputsAtomFamily = Atom.family((key: string) => { const ref = parseThreadKey(key); - let previous: ReadonlyArray = []; + let previous: ReadonlyArray = []; return Atom.make((get) => { const children = new Map(); for (const thread of get(environmentThreadsAtom(ref.environmentId))) { @@ -161,7 +155,7 @@ export function createEnvironmentThreadShellAtoms(input: { } const seen = new Set([ref.threadId]); const pending = [ref.threadId]; - const next: ChildThreadInput[] = []; + const next: EnvironmentThreadShell[] = []; for (const parent of pending) { for (const child of children.get(parent) ?? []) { if (seen.has(child.id)) continue; @@ -171,27 +165,11 @@ export function createEnvironmentThreadShellAtoms(input: { child.pendingRuntimeRequest?.kind === "user_input" && !isProviderNativeSubagentThread(child) ) { - next.push({ - threadId: child.id, - title: scopedThread(ref.environmentId, child).title, - requestId: child.pendingRuntimeRequest.id, - }); + next.push(scopedThread(ref.environmentId, child)); } } } - if ( - previous.length === next.length && - next.every((child, index) => { - const old = previous[index]; - return ( - old?.threadId === child.threadId && - old.title === child.title && - old.requestId === child.requestId - ); - }) - ) { - return previous; - } + if (arrayElementsEqual(previous, next)) return previous; previous = next; return next; }).pipe(Atom.withLabel(`environment-child-thread-inputs:${key}`)); From f402dc8c3701d9cd32c31dc3786b1f7ef090d65b Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:22:18 +0000 Subject: [PATCH 4/5] perf(clients): child question indicators ignore unrelated shell updates --- packages/client-runtime/src/state/threadShell.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 07d1354b179c..3cb54087ac14 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -169,7 +169,15 @@ export function createEnvironmentThreadShellAtoms(input: { } } } - if (arrayElementsEqual(previous, next)) return previous; + // Indicators read only ids and titles, so other shell updates keep the old array. + if ( + previous.length === next.length && + previous.every( + (thread, index) => thread.id === next[index]?.id && thread.title === next[index]?.title, + ) + ) { + return previous; + } previous = next; return next; }).pipe(Atom.withLabel(`environment-child-thread-inputs:${key}`)); From d541e6d29ae867e6ab5b037d422ed28f8dde0ca8 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:50:25 +0000 Subject: [PATCH 5/5] fix(web): parent secret requests take precedence over child questions --- apps/web/src/components/ChatView.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9566c35dd588..d4eba0d57e48 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7420,7 +7420,9 @@ export default function ChatView(props: ChatViewProps) { const childThreadInputs = useChildThreadInputs(activeThreadRef); const childInputBannerItem = useMemo(() => { const first = childThreadInputs[0]; - if (!first || activePendingApproval || activePendingUserInput) return null; + const parentAwaitingUser = + activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput; + if (!first || parentAwaitingUser) return null; return { id: `child-input:${first.id}`, variant: "info", @@ -7444,7 +7446,13 @@ export default function ChatView(props: ChatViewProps) { ), }; - }, [childThreadInputs, activePendingApproval, activePendingUserInput, onOpenRelatedThread]); + }, [ + childThreadInputs, + activePendingApproval, + activePendingUserInput, + activeThreadShell?.hasPendingUserInput, + onOpenRelatedThread, + ]); // Commands such as /compact and /goal clear run as their own turn. The draft // and its attachments stay local.