| author | |
| committer | |
| log | ed4ea1083d477b67304f50d5691ba5fc2b9496cd |
| tree | fe327eb15dd3b8a6522ed87a0cb6ec4fb84e55c4 |
| parent | 318201caa520525517ef2bee8f4bbeedb2fec1d7 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useThreadReportedModelSelection } from "../../state/entities"; | |
| 2 | import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; | |
| 3 | 3 | import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; |
| 4 | 4 | import { useNavigation } from "@react-navigation/native"; |
| 5 | 5 | import type { WorktreeSetupCardProps } from "./worktree-setup-card"; |
| ... | ... | @@ -400,6 +400,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 400 | 400 | const controlInsets = deriveBottomControlInsets(nativeMetrics); |
| 401 | 401 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| 402 | 402 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 403 | const childThreadInputs = useChildThreadInputs({ | |
| 404 | environmentId: props.environmentId, | |
| 405 | threadId: props.selectedThread.id, | |
| 406 | }); | |
| 403 | 407 | const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; |
| 404 | 408 | const queuedCount = useThreadQueuedCount({ |
| 405 | 409 | environmentId: props.environmentId, |
| ... | ... | @@ -507,6 +511,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 507 | 511 | if (threadSyncLabel !== null) { |
| 508 | 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 | 532 | if (props.isCompacting && contentPresentationKind === "ready") { |
| 511 | 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 | 160 | hasAgents || |
| 161 | 161 | hasQueue || |
| 162 | 162 | (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"; | |
| 165 | 166 | const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview; |
| 166 | 167 | // The host stays centered on the capsule, but its measurement constraint |
| 167 | 168 | // comes from the overlay, independent of the capsule's current width. |
| 168 | 169 | const statusContent = |
| 169 | 170 | props.status !== null ? ( |
| 170 | 171 | <View |
| 171 | pointerEvents={props.status.kind === "connection" ? "box-none" : "none"} | |
| 172 | pointerEvents={statusInteractive ? "box-none" : "none"} | |
| 172 | 173 | className="h-11 items-center justify-center" |
| 173 | 174 | > |
| 174 | 175 | <Animated.View className="h-11" style={capsuleSizerStyle} /> |
| ... | ... | @@ -372,6 +373,23 @@ function FloatingStatusLabel(props: { |
| 372 | 373 | if (props.status.kind === "compacting") { |
| 373 | 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 | 393 | if (props.status.kind === "connection") { |
| 376 | 394 | return ( |
| 377 | 395 | <StatusLabelRow |
apps/mobile/src/features/threads/floating-working-status.ts+6| ... | ... | @@ -9,6 +9,12 @@ export type FloatingWorkingStatus = |
| 9 | 9 | | { readonly kind: "working"; readonly startedAt: string } |
| 10 | 10 | | { readonly kind: "syncing"; readonly label: string } |
| 11 | 11 | | { readonly kind: "compacting" } |
| 12 | | { | |
| 13 | readonly kind: "child-input"; | |
| 14 | readonly label: string; | |
| 15 | readonly accessibilityLabel: string; | |
| 16 | readonly onPress: () => void; | |
| 17 | } | |
| 12 | 18 | // The turn settled while background work it started still runs. `waiting` |
| 13 | 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 | 98 | ); |
| 99 | 99 | } |
| 100 | 100 | |
| 101 | export function useChildThreadInputs(ref: ScopedThreadRef) { | |
| 102 | return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref)); | |
| 103 | } | |
| 104 | ||
| 101 | 105 | /** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */ |
| 102 | 106 | export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { |
| 103 | 107 | const titles = useAtomValue( |
apps/web/src/components/ChatView.tsx+44-3| ... | ... | @@ -300,6 +300,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 300 | 300 | import { |
| 301 | 301 | AlarmClockIcon, |
| 302 | 302 | CheckCircle2Icon, |
| 303 | MessageCircleIcon, | |
| 303 | 304 | PaperclipIcon, |
| 304 | 305 | ChevronDownIcon, |
| 305 | 306 | DownloadIcon, |
| ... | ... | @@ -427,6 +428,7 @@ import { |
| 427 | 428 | useThreadStatus, |
| 428 | 429 | useThreadHistory, |
| 429 | 430 | useThreadShell, |
| 431 | useChildThreadInputs, | |
| 430 | 432 | useThreadRefs, |
| 431 | 433 | useThreadVisibleTurnItems, |
| 432 | 434 | waitForThreadShell, |
| ... | ... | @@ -7414,6 +7416,42 @@ export default function ChatView(props: ChatViewProps) { |
| 7414 | 7416 | }, |
| 7415 | 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 | ]); | |
| 7417 | 7455 | |
| 7418 | 7456 | // Commands such as /compact and /goal clear run as their own turn. The draft |
| 7419 | 7457 | // and its attachments stay local. |
| ... | ... | @@ -7774,9 +7812,11 @@ export default function ChatView(props: ChatViewProps) { |
| 7774 | 7812 | : null; |
| 7775 | 7813 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 7776 | 7814 | 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); | |
| 7780 | 7820 | // The user asked for this one, so it leads the notice tier instead of trailing it. |
| 7781 | 7821 | const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; |
| 7782 | 7822 | const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; |
| ... | ... | @@ -7847,6 +7887,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7847 | 7887 | handleRestoreThreadBranch, |
| 7848 | 7888 | isRestoringThreadBranch, |
| 7849 | 7889 | backgroundWorkBannerItem, |
| 7890 | childInputBannerItem, | |
| 7850 | 7891 | goalBannerItem, |
| 7851 | 7892 | localCheckoutBranchMismatch, |
| 7852 | 7893 | projectCloneBannerItem, |
apps/web/src/state/entities.ts+6| ... | ... | @@ -117,6 +117,12 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh |
| 117 | 117 | ); |
| 118 | 118 | } |
| 119 | 119 | |
| 120 | export function useChildThreadInputs(ref: ScopedThreadRef | null) { | |
| 121 | return useAtomValue( | |
| 122 | ref === null ? EMPTY_THREAD_SHELLS_ATOM : environmentThreadShells.childThreadInputsAtom(ref), | |
| 123 | ); | |
| 124 | } | |
| 125 | ||
| 120 | 126 | export function useThreadProjection(ref: ScopedThreadRef | null): EnvironmentThread | null { |
| 121 | 127 | return useAtomValue( |
| 122 | 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 | 7 | ScopedThreadRef, |
| 8 | 8 | ThreadId, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | import { isProviderNativeSubagentThread } from "@t3tools/contracts"; | |
| 10 | 11 | import { Atom } from "effect/reactivity"; |
| 11 | 12 | |
| 12 | 13 | import type { EnvironmentThreadShell } from "./models.ts"; |
| ... | ... | @@ -138,6 +139,50 @@ export function createEnvironmentThreadShellAtoms(input: { |
| 138 | 139 | }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); |
| 139 | 140 | }); |
| 140 | 141 | |
| 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 | 186 | const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => { |
| 142 | 187 | const projectRefs = parseProjectRefCollectionKey(key); |
| 143 | 188 | let previous: ReadonlyArray<EnvironmentThreadShell> = []; |
| ... | ... | @@ -225,5 +270,6 @@ export function createEnvironmentThreadShellAtoms(input: { |
| 225 | 270 | threadShellsForProjectRefsAtom: (refs: ReadonlyArray<ScopedProjectRef>) => |
| 226 | 271 | threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), |
| 227 | 272 | threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), |
| 273 | childThreadInputsAtom: (ref: ScopedThreadRef) => childThreadInputsAtomFamily(threadKey(ref)), | |
| 228 | 274 | }; |
| 229 | 275 | } |