authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-09 23:31:57-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 23:31:57-03:00
loged4ea1083d477b67304f50d5691ba5fc2b9496cd
treefe327eb15dd3b8a6522ed87a0cb6ec4fb84e55c4
parent318201caa520525517ef2bee8f4bbeedb2fec1d7
signature Signed by PGP key B5690EEEBB952194

fix(chat): surface pending subagent questions on parents (#16634)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

7 files changed, 150 insertions(+), 7 deletions(-)

apps/mobile/src/features/threads/ThreadDetailScreen.tsx+23-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useThreadReportedModelSelection } from "../../state/entities";2import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities";
3import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";3import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
4import { useNavigation } from "@react-navigation/native";4import { useNavigation } from "@react-navigation/native";
5import type { WorktreeSetupCardProps } from "./worktree-setup-card";5import type { WorktreeSetupCardProps } from "./worktree-setup-card";
...@@ -400,6 +400,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -400,6 +400,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
400 const controlInsets = deriveBottomControlInsets(nativeMetrics);400 const controlInsets = deriveBottomControlInsets(nativeMetrics);
401 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;401 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
402 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);402 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
403 const childThreadInputs = useChildThreadInputs({
404 environmentId: props.environmentId,
405 threadId: props.selectedThread.id,
406 });
403 const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null;407 const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null;
404 const queuedCount = useThreadQueuedCount({408 const queuedCount = useThreadQueuedCount({
405 environmentId: props.environmentId,409 environmentId: props.environmentId,
...@@ -507,6 +511,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -507,6 +511,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
507 if (threadSyncLabel !== null) {511 if (threadSyncLabel !== null) {
508 return { kind: "syncing", label: threadSyncLabel };512 return { kind: "syncing", label: threadSyncLabel };
509 }513 }
514 const childInput = childThreadInputs[0];
515 if (childInput && contentPresentationKind === "ready") {
516 return {
517 kind: "child-input",
518 label:
519 childThreadInputs.length === 1
520 ? "Subagent needs input"
521 : `${childThreadInputs.length} subagents need input`,
522 accessibilityLabel: `Open question from ${childInput.title}`,
523 onPress: () => {
524 Keyboard.dismiss();
525 navigation.navigate("Thread", {
526 environmentId: props.environmentId,
527 threadId: childInput.id,
528 });
529 },
530 };
531 }
510 if (props.isCompacting && contentPresentationKind === "ready") {532 if (props.isCompacting && contentPresentationKind === "ready") {
511 return { kind: "compacting" };533 return { kind: "compacting" };
512 }534 }
apps/mobile/src/features/threads/floating-working-control.tsx+21-3
...@@ -160,15 +160,16 @@ export function FloatingWorkingControl(props: {...@@ -160,15 +160,16 @@ export function FloatingWorkingControl(props: {
160 hasAgents ||160 hasAgents ||
161 hasQueue ||161 hasQueue ||
162 (props.devicePreview !== null && props.browserPreview !== null);162 (props.devicePreview !== null && props.browserPreview !== null);
163 // The queue, agents, and reconnect labels have separate tap targets.163 // The queue, agents, questions, and reconnect labels have separate tap targets.
164 const statusInteractive = props.status?.kind === "connection";164 const statusInteractive =
165 props.status?.kind === "connection" || props.status?.kind === "child-input";
165 const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview;166 const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview;
166 // The host stays centered on the capsule, but its measurement constraint167 // The host stays centered on the capsule, but its measurement constraint
167 // comes from the overlay, independent of the capsule's current width.168 // comes from the overlay, independent of the capsule's current width.
168 const statusContent =169 const statusContent =
169 props.status !== null ? (170 props.status !== null ? (
170 <View171 <View
171 pointerEvents={props.status.kind === "connection" ? "box-none" : "none"}172 pointerEvents={statusInteractive ? "box-none" : "none"}
172 className="h-11 items-center justify-center"173 className="h-11 items-center justify-center"
173 >174 >
174 <Animated.View className="h-11" style={capsuleSizerStyle} />175 <Animated.View className="h-11" style={capsuleSizerStyle} />
...@@ -372,6 +373,23 @@ function FloatingStatusLabel(props: {...@@ -372,6 +373,23 @@ function FloatingStatusLabel(props: {
372 if (props.status.kind === "compacting") {373 if (props.status.kind === "compacting") {
373 return <CompactingLabel key="compacting" onLayout={props.onLayout} />;374 return <CompactingLabel key="compacting" onLayout={props.onLayout} />;
374 }375 }
376 if (props.status.kind === "child-input") {
377 return (
378 <StatusLabelRow
379 key="child-input"
380 accessibilityLabel={props.status.accessibilityLabel}
381 accessibilityRole="button"
382 className="gap-2"
383 onLayout={props.onLayout}
384 onPress={props.status.onPress}
385 >
386 <SymbolView name="text.bubble" size={13} tintColorClassName="foreground" />
387 <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
388 {props.status.label}
389 </Text>
390 </StatusLabelRow>
391 );
392 }
375 if (props.status.kind === "connection") {393 if (props.status.kind === "connection") {
376 return (394 return (
377 <StatusLabelRow395 <StatusLabelRow
apps/mobile/src/features/threads/floating-working-status.ts+6
...@@ -9,6 +9,12 @@ export type FloatingWorkingStatus =...@@ -9,6 +9,12 @@ export type FloatingWorkingStatus =
9 | { readonly kind: "working"; readonly startedAt: string }9 | { readonly kind: "working"; readonly startedAt: string }
10 | { readonly kind: "syncing"; readonly label: string }10 | { readonly kind: "syncing"; readonly label: string }
11 | { readonly kind: "compacting" }11 | { readonly kind: "compacting" }
12 | {
13 readonly kind: "child-input";
14 readonly label: string;
15 readonly accessibilityLabel: string;
16 readonly onPress: () => void;
17 }
12 // The turn settled while background work it started still runs. `waiting`18 // The turn settled while background work it started still runs. `waiting`
13 // is false when only commands remain, such as a dev server: the agent is done.19 // is false when only commands remain, such as a dev server: the agent is done.
14 | {20 | {
apps/mobile/src/state/entities.ts+4
...@@ -98,6 +98,10 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh...@@ -98,6 +98,10 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh
98 );98 );
99}99}
100100
101export function useChildThreadInputs(ref: ScopedThreadRef) {
102 return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref));
103}
104
101/** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */105/** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */
102export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string {106export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string {
103 const titles = useAtomValue(107 const titles = useAtomValue(
apps/web/src/components/ChatView.tsx+44-3
...@@ -300,6 +300,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer";...@@ -300,6 +300,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
300import {300import {
301 AlarmClockIcon,301 AlarmClockIcon,
302 CheckCircle2Icon,302 CheckCircle2Icon,
303 MessageCircleIcon,
303 PaperclipIcon,304 PaperclipIcon,
304 ChevronDownIcon,305 ChevronDownIcon,
305 DownloadIcon,306 DownloadIcon,
...@@ -427,6 +428,7 @@ import {...@@ -427,6 +428,7 @@ import {
427 useThreadStatus,428 useThreadStatus,
428 useThreadHistory,429 useThreadHistory,
429 useThreadShell,430 useThreadShell,
431 useChildThreadInputs,
430 useThreadRefs,432 useThreadRefs,
431 useThreadVisibleTurnItems,433 useThreadVisibleTurnItems,
432 waitForThreadShell,434 waitForThreadShell,
...@@ -7414,6 +7416,42 @@ export default function ChatView(props: ChatViewProps) {...@@ -7414,6 +7416,42 @@ export default function ChatView(props: ChatViewProps) {
7414 },7416 },
7415 [environmentId, navigate],7417 [environmentId, navigate],
7416 );7418 );
7419 const childThreadInputs = useChildThreadInputs(activeThreadRef);
7420 const childInputBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
7421 const first = childThreadInputs[0];
7422 const parentAwaitingUser =
7423 activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput;
7424 if (!first || parentAwaitingUser) return null;
7425 return {
7426 id: `child-input:${first.id}`,
7427 variant: "info",
7428 priority: "activity",
7429 icon: <MessageCircleIcon />,
7430 title:
7431 childThreadInputs.length === 1
7432 ? "Subagent needs input"
7433 : `${childThreadInputs.length} subagents need input`,
7434 description: childThreadInputs.map((child, index) => (
7435 <Fragment key={child.id}>
7436 {index > 0 ? ", " : null}
7437 <InlineButton tone="muted" onClick={() => onOpenRelatedThread(child.id)}>
7438 {child.title}
7439 </InlineButton>
7440 </Fragment>
7441 )),
7442 actions: (
7443 <Button size="xs" variant="ghost" onClick={() => onOpenRelatedThread(first.id)}>
7444 Open question
7445 </Button>
7446 ),
7447 };
7448 }, [
7449 childThreadInputs,
7450 activePendingApproval,
7451 activePendingUserInput,
7452 activeThreadShell?.hasPendingUserInput,
7453 onOpenRelatedThread,
7454 ]);
74177455
7418 // Commands such as /compact and /goal clear run as their own turn. The draft7456 // Commands such as /compact and /goal clear run as their own turn. The draft
7419 // and its attachments stay local.7457 // and its attachments stay local.
...@@ -7774,9 +7812,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -7774,9 +7812,11 @@ export default function ChatView(props: ChatViewProps) {
7774 : null;7812 : null;
7775 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {7813 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
7776 const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner];7814 const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner];
7777 const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter(7815 const backgroundWorkItems = [
7778 (item) => item !== null,7816 childInputBannerItem,
7779 );7817 goalBannerItem,
7818 backgroundWorkBannerItem,
7819 ].filter((item) => item !== null);
7780 // The user asked for this one, so it leads the notice tier instead of trailing it.7820 // The user asked for this one, so it leads the notice tier instead of trailing it.
7781 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];7821 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];
7782 const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem];7822 const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem];
...@@ -7847,6 +7887,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7847,6 +7887,7 @@ export default function ChatView(props: ChatViewProps) {
7847 handleRestoreThreadBranch,7887 handleRestoreThreadBranch,
7848 isRestoringThreadBranch,7888 isRestoringThreadBranch,
7849 backgroundWorkBannerItem,7889 backgroundWorkBannerItem,
7890 childInputBannerItem,
7850 goalBannerItem,7891 goalBannerItem,
7851 localCheckoutBranchMismatch,7892 localCheckoutBranchMismatch,
7852 projectCloneBannerItem,7893 projectCloneBannerItem,
apps/web/src/state/entities.ts+6
...@@ -117,6 +117,12 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh...@@ -117,6 +117,12 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh
117 );117 );
118}118}
119119
120export function useChildThreadInputs(ref: ScopedThreadRef | null) {
121 return useAtomValue(
122 ref === null ? EMPTY_THREAD_SHELLS_ATOM : environmentThreadShells.childThreadInputsAtom(ref),
123 );
124}
125
120export function useThreadProjection(ref: ScopedThreadRef | null): EnvironmentThread | null {126export function useThreadProjection(ref: ScopedThreadRef | null): EnvironmentThread | null {
121 return useAtomValue(127 return useAtomValue(
122 ref === null ? EMPTY_THREAD_PROJECTION_ATOM : environmentThreadDetails.threadAtom(ref),128 ref === null ? EMPTY_THREAD_PROJECTION_ATOM : environmentThreadDetails.threadAtom(ref),
packages/client-runtime/src/state/threadShell.ts+46
...@@ -7,6 +7,7 @@ import type {...@@ -7,6 +7,7 @@ import type {
7 ScopedThreadRef,7 ScopedThreadRef,
8 ThreadId,8 ThreadId,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { isProviderNativeSubagentThread } from "@t3tools/contracts";
10import { Atom } from "effect/reactivity";11import { Atom } from "effect/reactivity";
1112
12import type { EnvironmentThreadShell } from "./models.ts";13import type { EnvironmentThreadShell } from "./models.ts";
...@@ -138,6 +139,50 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -138,6 +139,50 @@ export function createEnvironmentThreadShellAtoms(input: {
138 }).pipe(Atom.withLabel(`environment-thread-shell:${key}`));139 }).pipe(Atom.withLabel(`environment-thread-shell:${key}`));
139 });140 });
140141
142 // Child questions are hidden with their sidebar rows. Read their shell summaries,
143 // without subscribing to every child transcript.
144 const childThreadInputsAtomFamily = Atom.family((key: string) => {
145 const ref = parseThreadKey(key);
146 let previous: ReadonlyArray<EnvironmentThreadShell> = [];
147 return Atom.make((get) => {
148 const children = new Map<ThreadId, OrchestrationV2ThreadShell[]>();
149 for (const thread of get(environmentThreadsAtom(ref.environmentId))) {
150 const parent = thread.lineage.parentThreadId;
151 if (parent === null || thread.lineage.relationshipToParent !== "subagent") continue;
152 const siblings = children.get(parent);
153 if (siblings) siblings.push(thread);
154 else children.set(parent, [thread]);
155 }
156 const seen = new Set<ThreadId>([ref.threadId]);
157 const pending = [ref.threadId];
158 const next: EnvironmentThreadShell[] = [];
159 for (const parent of pending) {
160 for (const child of children.get(parent) ?? []) {
161 if (seen.has(child.id)) continue;
162 seen.add(child.id);
163 pending.push(child.id);
164 if (
165 child.pendingRuntimeRequest?.kind === "user_input" &&
166 !isProviderNativeSubagentThread(child)
167 ) {
168 next.push(scopedThread(ref.environmentId, child));
169 }
170 }
171 }
172 // Indicators read only ids and titles, so other shell updates keep the old array.
173 if (
174 previous.length === next.length &&
175 previous.every(
176 (thread, index) => thread.id === next[index]?.id && thread.title === next[index]?.title,
177 )
178 ) {
179 return previous;
180 }
181 previous = next;
182 return next;
183 }).pipe(Atom.withLabel(`environment-child-thread-inputs:${key}`));
184 });
185
141 const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => {186 const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => {
142 const projectRefs = parseProjectRefCollectionKey(key);187 const projectRefs = parseProjectRefCollectionKey(key);
143 let previous: ReadonlyArray<EnvironmentThreadShell> = [];188 let previous: ReadonlyArray<EnvironmentThreadShell> = [];
...@@ -225,5 +270,6 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -225,5 +270,6 @@ export function createEnvironmentThreadShellAtoms(input: {
225 threadShellsForProjectRefsAtom: (refs: ReadonlyArray<ScopedProjectRef>) =>270 threadShellsForProjectRefsAtom: (refs: ReadonlyArray<ScopedProjectRef>) =>
226 threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)),271 threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)),
227 threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)),272 threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)),
273 childThreadInputsAtom: (ref: ScopedThreadRef) => childThreadInputsAtomFamily(threadKey(ref)),
228 };274 };
229}275}