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 @@
11import { useAtomValue } from "@effect/atom-react";
2import { useThreadReportedModelSelection } from "../../state/entities";
2import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities";
33import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
44import { useNavigation } from "@react-navigation/native";
55import type { WorktreeSetupCardProps } from "./worktree-setup-card";
......@@ -400,6 +400,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
400400 const controlInsets = deriveBottomControlInsets(nativeMetrics);
401401 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
402402 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
403 const childThreadInputs = useChildThreadInputs({
404 environmentId: props.environmentId,
405 threadId: props.selectedThread.id,
406 });
403407 const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null;
404408 const queuedCount = useThreadQueuedCount({
405409 environmentId: props.environmentId,
......@@ -507,6 +511,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
507511 if (threadSyncLabel !== null) {
508512 return { kind: "syncing", label: threadSyncLabel };
509513 }
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 }
510532 if (props.isCompacting && contentPresentationKind === "ready") {
511533 return { kind: "compacting" };
512534 }
apps/mobile/src/features/threads/floating-working-control.tsx+21-3
......@@ -160,15 +160,16 @@ export function FloatingWorkingControl(props: {
160160 hasAgents ||
161161 hasQueue ||
162162 (props.devicePreview !== null && props.browserPreview !== null);
163 // The queue, agents, and reconnect labels have separate tap targets.
164 const statusInteractive = props.status?.kind === "connection";
163 // The queue, agents, questions, and reconnect labels have separate tap targets.
164 const statusInteractive =
165 props.status?.kind === "connection" || props.status?.kind === "child-input";
165166 const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview;
166167 // The host stays centered on the capsule, but its measurement constraint
167168 // comes from the overlay, independent of the capsule's current width.
168169 const statusContent =
169170 props.status !== null ? (
170171 <View
171 pointerEvents={props.status.kind === "connection" ? "box-none" : "none"}
172 pointerEvents={statusInteractive ? "box-none" : "none"}
172173 className="h-11 items-center justify-center"
173174 >
174175 <Animated.View className="h-11" style={capsuleSizerStyle} />
......@@ -372,6 +373,23 @@ function FloatingStatusLabel(props: {
372373 if (props.status.kind === "compacting") {
373374 return <CompactingLabel key="compacting" onLayout={props.onLayout} />;
374375 }
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 }
375393 if (props.status.kind === "connection") {
376394 return (
377395 <StatusLabelRow
apps/mobile/src/features/threads/floating-working-status.ts+6
......@@ -9,6 +9,12 @@ export type FloatingWorkingStatus =
99 | { readonly kind: "working"; readonly startedAt: string }
1010 | { readonly kind: "syncing"; readonly label: string }
1111 | { readonly kind: "compacting" }
12 | {
13 readonly kind: "child-input";
14 readonly label: string;
15 readonly accessibilityLabel: string;
16 readonly onPress: () => void;
17 }
1218 // The turn settled while background work it started still runs. `waiting`
1319 // is false when only commands remain, such as a dev server: the agent is done.
1420 | {
apps/mobile/src/state/entities.ts+4
......@@ -98,6 +98,10 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh
9898 );
9999}
100100
101export function useChildThreadInputs(ref: ScopedThreadRef) {
102 return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref));
103}
104
101105/** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */
102106export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string {
103107 const titles = useAtomValue(
apps/web/src/components/ChatView.tsx+44-3
......@@ -300,6 +300,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
300300import {
301301 AlarmClockIcon,
302302 CheckCircle2Icon,
303 MessageCircleIcon,
303304 PaperclipIcon,
304305 ChevronDownIcon,
305306 DownloadIcon,
......@@ -427,6 +428,7 @@ import {
427428 useThreadStatus,
428429 useThreadHistory,
429430 useThreadShell,
431 useChildThreadInputs,
430432 useThreadRefs,
431433 useThreadVisibleTurnItems,
432434 waitForThreadShell,
......@@ -7414,6 +7416,42 @@ export default function ChatView(props: ChatViewProps) {
74147416 },
74157417 [environmentId, navigate],
74167418 );
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
74187456 // Commands such as /compact and /goal clear run as their own turn. The draft
74197457 // and its attachments stay local.
......@@ -7774,9 +7812,11 @@ export default function ChatView(props: ChatViewProps) {
77747812 : null;
77757813 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
77767814 const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner];
7777 const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter(
7778 (item) => item !== null,
7779 );
7815 const backgroundWorkItems = [
7816 childInputBannerItem,
7817 goalBannerItem,
7818 backgroundWorkBannerItem,
7819 ].filter((item) => item !== null);
77807820 // The user asked for this one, so it leads the notice tier instead of trailing it.
77817821 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];
77827822 const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem];
......@@ -7847,6 +7887,7 @@ export default function ChatView(props: ChatViewProps) {
78477887 handleRestoreThreadBranch,
78487888 isRestoringThreadBranch,
78497889 backgroundWorkBannerItem,
7890 childInputBannerItem,
78507891 goalBannerItem,
78517892 localCheckoutBranchMismatch,
78527893 projectCloneBannerItem,
apps/web/src/state/entities.ts+6
......@@ -117,6 +117,12 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh
117117 );
118118}
119119
120export function useChildThreadInputs(ref: ScopedThreadRef | null) {
121 return useAtomValue(
122 ref === null ? EMPTY_THREAD_SHELLS_ATOM : environmentThreadShells.childThreadInputsAtom(ref),
123 );
124}
125
120126export function useThreadProjection(ref: ScopedThreadRef | null): EnvironmentThread | null {
121127 return useAtomValue(
122128 ref === null ? EMPTY_THREAD_PROJECTION_ATOM : environmentThreadDetails.threadAtom(ref),
packages/client-runtime/src/state/threadShell.ts+46
......@@ -7,6 +7,7 @@ import type {
77 ScopedThreadRef,
88 ThreadId,
99} from "@t3tools/contracts";
10import { isProviderNativeSubagentThread } from "@t3tools/contracts";
1011import { Atom } from "effect/reactivity";
1112
1213import type { EnvironmentThreadShell } from "./models.ts";
......@@ -138,6 +139,50 @@ export function createEnvironmentThreadShellAtoms(input: {
138139 }).pipe(Atom.withLabel(`environment-thread-shell:${key}`));
139140 });
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
141186 const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => {
142187 const projectRefs = parseProjectRefCollectionKey(key);
143188 let previous: ReadonlyArray<EnvironmentThreadShell> = [];
......@@ -225,5 +270,6 @@ export function createEnvironmentThreadShellAtoms(input: {
225270 threadShellsForProjectRefsAtom: (refs: ReadonlyArray<ScopedProjectRef>) =>
226271 threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)),
227272 threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)),
273 childThreadInputsAtom: (ref: ScopedThreadRef) => childThreadInputsAtomFamily(threadKey(ref)),
228274 };
229275}