From ed4ea1083d477b67304f50d5691ba5fc2b9496cd Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:31:57 -0300 Subject: [PATCH] fix(chat): surface pending subagent questions on parents (#16634) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../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 | 47 +++++++++++++++++-- apps/web/src/state/entities.ts | 6 +++ .../client-runtime/src/state/threadShell.ts | 46 ++++++++++++++++++ 7 files changed, 150 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 9229c117d8da20ea4d7fb918dca573fe21c1715b..a3d7a6f4e0c6a9eb3084c67c9136c9cb2f51d4c6 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"; @@ -400,6 +400,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const controlInsets = deriveBottomControlInsets(nativeMetrics); 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, @@ -507,6 +511,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.id, + }); + }, + }; + } 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 f21a83f09a177c5f13cce35f6b47a7bdaf2890bd..1fb87ce1c7251a80ab3c625140b8f1c0d407169b 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -160,15 +160,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 ? ( @@ -372,6 +373,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 0d3b33d582d5a25fbaeeb0d2d49d0341cd5d51b7..b8e42b0cb99ec38936ddd9891c52c510e15ab289 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -98,6 +98,10 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef) { + return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref)); +} + /** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */ export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { const titles = useAtomValue( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a80fc56c57e67347b7e4a01a5f81ceac9c8566e3..06a6bacaf48cf1345fd2d082b90c81a328d7c040 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -300,6 +300,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, + MessageCircleIcon, PaperclipIcon, ChevronDownIcon, DownloadIcon, @@ -427,6 +428,7 @@ import { useThreadStatus, useThreadHistory, useThreadShell, + useChildThreadInputs, useThreadRefs, useThreadVisibleTurnItems, waitForThreadShell, @@ -7414,6 +7416,42 @@ export default function ChatView(props: ChatViewProps) { }, [environmentId, navigate], ); + const childThreadInputs = useChildThreadInputs(activeThreadRef); + const childInputBannerItem = useMemo(() => { + const first = childThreadInputs[0]; + const parentAwaitingUser = + activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput; + if (!first || parentAwaitingUser) return null; + return { + id: `child-input:${first.id}`, + 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.id)}> + {child.title} + + + )), + actions: ( + + ), + }; + }, [ + 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. @@ -7774,9 +7812,11 @@ export default function ChatView(props: ChatViewProps) { : null; const composerBannerItems = useMemo(() => { const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; - const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter( - (item) => item !== null, - ); + const backgroundWorkItems = [ + childInputBannerItem, + goalBannerItem, + backgroundWorkBannerItem, + ].filter((item) => item !== null); // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; @@ -7847,6 +7887,7 @@ export default function ChatView(props: ChatViewProps) { handleRestoreThreadBranch, isRestoringThreadBranch, backgroundWorkBannerItem, + childInputBannerItem, goalBannerItem, localCheckoutBranchMismatch, projectCloneBannerItem, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index aa9b650fb5b50d61dc9b84d3caef1a740ea61111..ebf62f01be6fd22a1e74d8a2d8b8fc654002a80f 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -117,6 +117,12 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef | null) { + return useAtomValue( + ref === null ? EMPTY_THREAD_SHELLS_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 bc6d444f471affc0068febdd09bf56d475fa45fe..3cb54087ac14d233c9f8f66ae5d9e2eedf07c09f 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -7,6 +7,7 @@ import type { ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; +import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import type { EnvironmentThreadShell } from "./models.ts"; @@ -138,6 +139,50 @@ 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. + 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: EnvironmentThreadShell[] = []; + 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(scopedThread(ref.environmentId, child)); + } + } + } + // 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}`)); + }); + const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => { const projectRefs = parseProjectRefCollectionKey(key); let previous: ReadonlyArray = []; @@ -225,5 +270,6 @@ export function createEnvironmentThreadShellAtoms(input: { threadShellsForProjectRefsAtom: (refs: ReadonlyArray) => threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), + childThreadInputsAtom: (ref: ScopedThreadRef) => childThreadInputsAtomFamily(threadKey(ref)), }; } -- 2.54.0