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 5b0c1fd2f5284..fd4601e4e278c 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -76,7 +76,12 @@ import { type ThreadListV2Status, } from "./threadListV2"; import { QueuedMessageIcon } from "./queued-message-icon"; -import { shouldRecedeThreadRow } from "./thread-row-emphasis"; +import { + hasThreadRowAsyncQuestion, + shouldRecedeThreadRow, + threadRowAccessibilityLabel, + threadRowStatusText, +} from "./thread-row-emphasis"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; @@ -88,6 +93,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", @@ -95,12 +106,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", @@ -687,6 +693,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected); const status = resolveThreadListV2Status(thread); + const hasAsyncQuestion = hasThreadRowAsyncQuestion({ + status, + hasPendingAsyncQuestion: thread.hasPendingAsyncQuestion, + }); const isUnread = status === "ready" && threadHasUnseenCompletion(thread); const workingLabel = STATUS_LABEL_BY_STATUS[status]; const showsWoke = status === "ready" && props.wokeAt !== null; @@ -694,7 +704,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { (status === "working" && workingLabel !== undefined && thread.goal?.status === "active" ? { ...workingLabel, label: "Goal" } : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); - const recede = shouldRecedeThreadRow({ status, selected }); + const recede = shouldRecedeThreadRow({ status, selected, hasAsyncQuestion }); const timeLabel = props.timeLabel; const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); @@ -1125,6 +1135,31 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : statusLabel ? ( + {hasAsyncQuestion ? ( + <> + + + {INPUT_STATUS_LABEL.label} + + + ) : null} - {statusLabel.label} + {threadRowStatusText({ hasAsyncQuestion, label: statusLabel.label })} {status === "working" ? ( ) : status === "waiting" ? ( @@ -1291,6 +1326,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { cardContent ); + const rowAccessibilityLabel = threadRowAccessibilityLabel({ + title: thread.title, + hasAsyncQuestion, + statusLabel: statusLabel?.label ?? null, + hasQueuedMessages: props.hasQueuedMessages === true, + }); + const rowContent = (close: () => void) => variant === "card" ? ( { + it("shows Input only while the run keeps working", () => { + expect(hasThreadRowAsyncQuestion({ status: "working", hasPendingAsyncQuestion: true })).toBe( + true, + ); + expect(hasThreadRowAsyncQuestion({ status: "working", hasPendingAsyncQuestion: false })).toBe( + false, + ); + expect(hasThreadRowAsyncQuestion({ status: "working" })).toBe(false); + expect(hasThreadRowAsyncQuestion({ status: "input", hasPendingAsyncQuestion: true })).toBe( + false, + ); + }); + + it("keeps full emphasis instead of receding like other working rows", () => { + expect(shouldRecedeThreadRow({ status: "working", selected: false })).toBe(true); + expect( + shouldRecedeThreadRow({ status: "working", selected: false, hasAsyncQuestion: true }), + ).toBe(false); + }); + + it("leaves Working unsaid beside Input but keeps Goal", () => { + expect(threadRowStatusText({ hasAsyncQuestion: true, label: "Working" })).toBeNull(); + expect(threadRowStatusText({ hasAsyncQuestion: true, label: "Goal" })).toBe("Goal"); + expect(threadRowStatusText({ hasAsyncQuestion: false, label: "Working" })).toBe("Working"); + }); + + it("announces the question with the title and queued messages", () => { + const label = (statusLabel: string | null, hasAsyncQuestion = true) => + threadRowAccessibilityLabel({ + title: "Refactor auth", + hasAsyncQuestion, + statusLabel, + hasQueuedMessages: true, + }); + expect(label("Working")).toBe("Refactor auth, Input, still working, messages queued to send"); + expect(label("Goal")).toBe("Refactor auth, Input, Goal, messages queued to send"); + expect(label("Working", false)).toBe("Refactor auth, messages queued to send"); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-row-emphasis.ts b/apps/mobile/src/features/threads/thread-row-emphasis.ts index d78e5f0a14b75..a83a4e512a3e4 100644 --- a/apps/mobile/src/features/threads/thread-row-emphasis.ts +++ b/apps/mobile/src/features/threads/thread-row-emphasis.ts @@ -3,6 +3,47 @@ import type { ThreadListV2Status } from "./threadListV2"; export function shouldRecedeThreadRow(input: { readonly status: ThreadListV2Status; readonly selected: boolean; + /** An open async question keeps the row at full emphasis while its run works. */ + readonly hasAsyncQuestion?: boolean; }): boolean { - return !input.selected && (input.status === "working" || input.status === "waiting"); + return ( + !input.selected && + input.hasAsyncQuestion !== true && + (input.status === "working" || input.status === "waiting") + ); +} + +/** Whether the row shows the Input marker for an async question its run keeps working through. */ +export function hasThreadRowAsyncQuestion(input: { + readonly status: ThreadListV2Status; + readonly hasPendingAsyncQuestion?: boolean | undefined; +}): boolean { + return input.status === "working" && input.hasPendingAsyncQuestion === true; +} + +/** Beside Input the spinner and timer already say Working; any other label, such as Goal, stays. */ +export function threadRowStatusText(input: { + readonly hasAsyncQuestion: boolean; + readonly label: string; +}): string | null { + return input.hasAsyncQuestion && input.label === "Working" ? null : input.label; +} + +export function threadRowAccessibilityLabel(input: { + readonly title: string; + readonly hasAsyncQuestion: boolean; + readonly statusLabel: string | null; + readonly hasQueuedMessages: boolean; +}): string { + const statusText = + input.statusLabel === null + ? null + : threadRowStatusText({ hasAsyncQuestion: input.hasAsyncQuestion, label: input.statusLabel }); + return [ + input.title, + input.hasAsyncQuestion ? `Input, ${statusText ?? "still working"}` : null, + input.hasQueuedMessages ? "messages queued to send" : null, + ] + .filter(Boolean) + .join(", "); } diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e4fa68a65201b..2de5edc82903d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -16,6 +16,7 @@ import { resolveProjectStatusIndicator, resolveSidebarSweepKeys, resolveSidebarThreadStatus, + resolveAsyncQuestionPill, resolveThreadStatusPill, resolveWorkingStartedAt, searchSidebarThreads, @@ -458,6 +459,36 @@ describe("resolveThreadStatusPill", () => { }); }); + it("pairs Working with Awaiting Input while an async question waits", () => { + const asking = { ...baseThread, hasPendingAsyncQuestion: true }; + const working = resolveThreadStatusPill({ thread: asking }); + expect(working).toMatchObject({ label: "Working" }); + expect(resolveAsyncQuestionPill(asking, working)).toMatchObject({ label: "Awaiting Input" }); + + const approval = resolveThreadStatusPill({ + thread: { ...asking, pendingSubagentRequest: "approval" }, + }); + expect(resolveAsyncQuestionPill(asking, approval)).toBeNull(); + + const connectingThread = { + ...asking, + runtime: { ...baseThread.runtime, status: "starting" as const }, + }; + const connecting = resolveThreadStatusPill({ thread: connectingThread }); + expect(connecting).toMatchObject({ label: "Connecting" }); + expect(resolveAsyncQuestionPill(connectingThread, connecting)).toMatchObject({ + label: "Awaiting Input", + }); + + const guest = resolveThreadStatusPill({ thread: { ...asking, pendingGuestMessageCount: 1 } }); + expect(guest).toMatchObject({ label: "Guest Request" }); + expect(resolveAsyncQuestionPill(asking, guest)).toBeNull(); + + const notAsking = { ...baseThread, hasPendingAsyncQuestion: false }; + expect(resolveAsyncQuestionPill(notAsking, working)).toBeNull(); + expect(resolveAsyncQuestionPill({}, working)).toBeNull(); + }); + it("shows waiting for an idle thread with pending background tasks", () => { expect( resolveThreadStatusPill({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index efd8244f8c2b2..8a9c5ceecaf49 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1070,6 +1070,24 @@ export function resolveWorkingStartedAt( return resolveThreadWorkingStartedAt(thread); } +const AWAITING_INPUT_PILL: ThreadStatusPill = { + label: "Awaiting Input", + colorClass: "text-indigo-600 dark:text-indigo-300/90", + dotClass: "bg-indigo-500 dark:bg-indigo-300/90", + pulse: false, +}; + +/** Shown beside Working while an async question waits, which the status pill alone would hide. */ +export function resolveAsyncQuestionPill( + thread: Pick, + statusPill: ThreadStatusPill | null, +): ThreadStatusPill | null { + return thread.hasPendingAsyncQuestion === true && + (statusPill?.label === "Working" || statusPill?.label === "Connecting") + ? AWAITING_INPUT_PILL + : null; +} + export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; }): ThreadStatusPill | null { @@ -1085,12 +1103,7 @@ export function resolveThreadStatusPill(input: { } if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") { - return { - label: "Awaiting Input", - colorClass: "text-indigo-600 dark:text-indigo-300/90", - dotClass: "bg-indigo-500 dark:bg-indigo-300/90", - pulse: false, - }; + return AWAITING_INPUT_PILL; } if ((thread.pendingGuestMessageCount ?? 0) > 0) { diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 0af0e063bdea3..511e5ea12a47b 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -42,6 +42,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { pullRequestMatchesSearchQuery, resolveThreadLastVisitedAt, + resolveAsyncQuestionPill, resolveThreadStatusPill, type ThreadStatusPill, useRetainedValue, @@ -704,6 +705,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar lastVisitedAt, }, }); + const asyncQuestionStatus = resolveAsyncQuestionPill(thread, threadStatus); const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); const pendingLink = @@ -741,6 +743,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar aria-label={`${changeRequestTerminologyForUrl(pendingLink.url).shortLabel} #${pendingLink.number}, status pending`} /> ) : null} + {asyncQuestionStatus ? : null} {threadStatus ? : null} ); diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index a111126342f4e..0532ad023f6d0 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -159,6 +159,12 @@ function JumpHintBadge(props: { label: string }) { ); } +const INPUT_STATUS = { + label: "Input", + icon: "input", + className: "text-indigo-600 dark:text-indigo-300", +} as const; + function WorkingDuration(props: { startedAt: string | null }) { const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; if (Number.isNaN(startedMs)) return null; @@ -707,19 +713,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarThreadStatus(thread); + const hasAsyncQuestion = status === "working" && thread.hasPendingAsyncQuestion === true; const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && thread.settledOverride !== "settled"; - const shouldRecede = shouldRecedeSidebarThread({ - status, - isUnread, - isWoke, - isActive: props.isActive, - isSelected, - }); + const shouldRecede = + !hasAsyncQuestion && + shouldRecedeSidebarThread({ + status, + isUnread, + isWoke, + isActive: props.isActive, + isSelected, + }); const topStatus = status === "working" ? { @@ -740,11 +749,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", @@ -995,9 +1000,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null; + // Beside an async question's Input, the spinner and timer already say Working; Goal says more. + const topStatusLabel = + hasAsyncQuestion && topStatus?.label === "Working" ? null : (topStatus?.label ?? null); const accessibility = resolveSidebarRowAccessibility({ title: thread.title, - statusLabel: topStatus?.label ?? null, + statusLabel: hasAsyncQuestion ? `Input, ${topStatusLabel ?? "still working"}` : topStatusLabel, projectDisplayName: props.projectDisplayName, isActive: props.isActive, }); @@ -1028,7 +1036,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground", shouldRecede ? "text-secondary-label" - : props.isActive || isWoke || status === "input" || status === "request" + : props.isActive || + isWoke || + status === "input" || + status === "request" || + hasAsyncQuestion ? "text-foreground" : isUnread ? "text-muted-foreground" @@ -1303,6 +1315,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP topStatus.className, )} > + {hasAsyncQuestion ? ( + + + {INPUT_STATUS.label} + + ) : null} {topStatus.label} @@ -1482,11 +1502,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP topStatus.className, )} > + {hasAsyncQuestion ? ( + + + {INPUT_STATUS.label} + + ) : null} {/* Not a live region: a collapsed sidebar is inert, so BackgroundThreadAnnouncer and the open thread's timeline speak status changes instead. */} - {topStatus.label} + {topStatusLabel} {status === "working" ? ( diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index abc16b9a7df67..05930d15b0e99 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -22,7 +22,7 @@ import { canSnooze, effectiveSnoozed } from "./threadSettled.ts"; const environmentId = EnvironmentId.make("environment-v2"); describe("V2 client presentation", () => { - it("keeps async questions in Working until the run completes", () => { + it("keeps async questions in Working, flagged as open, until the run completes", () => { const runId = RunId.make("run-async-question"); const running = { ...v2ThreadShell, @@ -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..08bebd53fc7c1 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 unanswered async question while the run keeps working; the thread still reads as Working. */ + readonly hasPendingAsyncQuestion?: boolean; readonly pendingSubagentRequest?: PendingSubagentRequest | null; /** Guest messages waiting for the owner to send or dismiss. */ readonly pendingGuestMessageCount?: number; @@ -318,6 +320,10 @@ export function presentThreadShell( hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input" && (thread.pendingRuntimeRequest.blocking !== false || !runtimeIsWorking), + hasPendingAsyncQuestion: + thread.pendingRuntimeRequest?.kind === "user_input" && + thread.pendingRuntimeRequest.blocking === false && + runtimeIsWorking, pendingSubagentRequest, pendingGuestMessageCount: thread.pendingGuestMessageCount ?? 0, hasActionableProposedPlan: thread.hasActionableProposedPlan,