diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 39c5ac82362a4..6ae48f3e28385 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -86,6 +86,12 @@ interface StatusLabel { readonly className: string; readonly iconTintClassName: string; } +const INPUT_STATUS_LABEL: StatusLabel = { + label: "Input", + icon: "questionmark.bubble", + className: "text-adaptive-indigo-600-300", + iconTintClassName: "accent-adaptive-indigo-600-300", +}; const STATUS_LABEL_BY_STATUS: Partial> = { approval: { label: "Approval", @@ -93,12 +99,7 @@ const STATUS_LABEL_BY_STATUS: Partial> = className: "text-warning-foreground", iconTintClassName: "accent-warning-foreground", }, - input: { - label: "Input", - icon: "questionmark.bubble", - className: "text-adaptive-indigo-600-300", - iconTintClassName: "accent-adaptive-indigo-600-300", - }, + input: INPUT_STATUS_LABEL, request: { label: "Request", icon: "person.crop.circle", @@ -694,6 +695,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); const recede = shouldRecedeThreadRow({ status, selected }); const timeLabel = props.timeLabel; + const rowAccessibilityLabel = [ + thread.title, + ...(props.hasQueuedMessages ? ["messages queued to send"] : []), + ...(thread.hasPendingAsyncQuestion === true ? ["question waiting"] : []), + ].join(", "); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const handleAcknowledgeWoke = useCallback(() => { @@ -1131,6 +1137,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} + {thread.hasPendingAsyncQuestion === true ? ( + + ) : null} ) : ( { ]), ).toEqual(["Needs approval: Fix login redirect"]); }); + + it("announces an async question once, while the run works and after it ends", () => { + const asyncQuestion = (running: boolean) => + resolveBackgroundThreadRequest({ + hasPendingApprovals: false, + hasPendingUserInput: !running, + hasPendingAsyncQuestion: running, + pendingSubagentRequest: null, + source: { pendingRuntimeRequest: question("question-1") }, + }); + const asked = [{ threads: [login()] }, { threads: [login(asyncQuestion(true))] }]; + expect(speak(asked)).toEqual(["Needs your input: Fix login redirect"]); + expect(speak([...asked, { threads: [login(asyncQuestion(false))] }])).toEqual([ + "Needs your input: Fix login redirect", + ]); + }); }); diff --git a/apps/web/src/components/backgroundThreadAnnouncement.ts b/apps/web/src/components/backgroundThreadAnnouncement.ts index 10ca422d0f3cb..c08a5ab2d7178 100644 --- a/apps/web/src/components/backgroundThreadAnnouncement.ts +++ b/apps/web/src/components/backgroundThreadAnnouncement.ts @@ -21,7 +21,10 @@ export interface BackgroundThreadSnapshot { export function resolveBackgroundThreadRequest( thread: Pick< SidebarThreadSummary, - "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" + | "hasPendingApprovals" + | "hasPendingUserInput" + | "hasPendingAsyncQuestion" + | "pendingSubagentRequest" > & { readonly source: Pick; }, @@ -29,7 +32,9 @@ export function resolveBackgroundThreadRequest( const ownRequestId = thread.source.pendingRuntimeRequest?.id ?? null; if (thread.hasPendingApprovals) return { request: "approval", requestId: ownRequestId }; if (thread.pendingSubagentRequest === "approval") return { request: "approval", requestId: null }; - if (thread.hasPendingUserInput) return { request: "input", requestId: ownRequestId }; + if (thread.hasPendingUserInput || thread.hasPendingAsyncQuestion) { + return { request: "input", requestId: ownRequestId }; + } if (thread.pendingSubagentRequest === "user_input") return { request: "input", requestId: null }; return { request: null, requestId: null }; } diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index a111126342f4e..965097e486a2c 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -531,6 +531,21 @@ function TopStatusIcon(props: { icon: keyof typeof TOP_STATUS_ICONS; className: return ; } +const INPUT_STATUS = { + label: "Input", + icon: "input", + className: "text-indigo-600 dark:text-indigo-300", +} as const; + +function AsyncQuestionMarker(props: { className: string }) { + return ( + + ); +} + type SidebarThreadRowProps = { thread: SidebarThreadSummary; // "folder" is a one-line row inside a boring sidebar project folder; @@ -740,11 +755,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className: "text-warning-foreground", } : status === "input" - ? { - label: "Input", - icon: "input" as const, - className: "text-indigo-600 dark:text-indigo-300", - } + ? INPUT_STATUS : status === "request" ? { label: "Request", @@ -777,6 +788,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP } : null; const isWokeStatus = topStatus?.icon === "woke"; + const showsAsyncQuestion = thread.hasPendingAsyncQuestion === true && !isWokeStatus; const branchMismatch = resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", @@ -997,7 +1009,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const accessibility = resolveSidebarRowAccessibility({ title: thread.title, - statusLabel: topStatus?.label ?? null, + statusLabel: + topStatus && showsAsyncQuestion + ? `${topStatus.label}, question waiting` + : (topStatus?.label ?? null), projectDisplayName: props.projectDisplayName, isActive: props.isActive, }); @@ -1305,6 +1320,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP > {topStatus.label} + {showsAsyncQuestion ? : null} ) : ( @@ -1496,6 +1512,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null} + {showsAsyncQuestion ? : null} ) ) : ( diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index abc16b9a7df67..94780d2c34d3c 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -40,6 +40,7 @@ describe("V2 client presentation", () => { }; const active = presentThreadShell(environmentId, running); expect(active.hasPendingUserInput).toBe(false); + expect(active.hasPendingAsyncQuestion).toBe(true); expect(isThreadWorking(active)).toBe(true); expect(canSnooze(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); @@ -49,6 +50,7 @@ describe("V2 client presentation", () => { status: "completed", }); expect(completed.hasPendingUserInput).toBe(true); + expect(completed.hasPendingAsyncQuestion).toBe(false); expect(isThreadWorking(completed)).toBe(false); expect(effectiveSnoozed(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); }); @@ -68,6 +70,7 @@ describe("V2 client presentation", () => { }, }); expect(shell.hasPendingUserInput).toBe(true); + expect(shell.hasPendingAsyncQuestion).toBe(false); expect(isThreadWorking(shell)).toBe(false); } }); diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index ac40471a853ba..f1ee9d8731c53 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -158,6 +158,8 @@ export interface EnvironmentThreadShell { readonly latestUserAuthoredMessageAt?: string | null; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; + /** An async question the agent keeps working past; the thread stays Working. */ + readonly hasPendingAsyncQuestion?: boolean; readonly pendingSubagentRequest?: PendingSubagentRequest | null; /** Guest messages waiting for the owner to send or dismiss. */ readonly pendingGuestMessageCount?: number; @@ -282,6 +284,9 @@ export function presentThreadShell( runtime?.status === "preparing" || runtime?.status === "starting" || runtime?.status === "running"; + const hasPendingQuestion = thread.pendingRuntimeRequest?.kind === "user_input"; + const questionHoldsThread = + hasPendingQuestion && (thread.pendingRuntimeRequest?.blocking !== false || !runtimeIsWorking); return { environmentId, id: thread.id, @@ -315,9 +320,8 @@ export function presentThreadShell( thread.pendingRuntimeRequest !== null && thread.pendingRuntimeRequest.kind !== "user_input" && thread.pendingRuntimeRequest.kind !== "auth_refresh", - hasPendingUserInput: - thread.pendingRuntimeRequest?.kind === "user_input" && - (thread.pendingRuntimeRequest.blocking !== false || !runtimeIsWorking), + hasPendingUserInput: questionHoldsThread, + hasPendingAsyncQuestion: hasPendingQuestion && !questionHoldsThread, pendingSubagentRequest, pendingGuestMessageCount: thread.pendingGuestMessageCount ?? 0, hasActionableProposedPlan: thread.hasActionableProposedPlan,