From c729d509a8c9b549bf3e842b09f1f1e63d26796b Mon Sep 17 00:00:00 2001 From: bailey Date: Sun, 25 Oct 2026 09:27:18 -0500 Subject: [PATCH 1/4] feat(clients): mark threads with an open async question while they keep working Since #15430 a thread whose run asks a non-blocking question stays Working until the run ends, but nothing in the thread list shows the question is waiting, and the default sidebar dims the row like any other running thread. The thread shell now carries hasPendingAsyncQuestion. The web sidebar row and the mobile thread list show the existing Input icon and label before the working spinner and timer, and keep the row from receding. The legacy sidebar shows the Awaiting Input pill beside Working. Status, ordering and blocking questions are unchanged. Reviewed-by: gpt-6.1-sol (Codex) Co-Authored-By: Claude Opus 5.5 --- .../features/threads/thread-list-v2-items.tsx | 43 +++++++++++--- apps/web/src/components/LegacySidebar.tsx | 3 + apps/web/src/components/Sidebar.logic.test.ts | 31 ++++++++++ apps/web/src/components/Sidebar.logic.ts | 25 +++++++-- .../src/components/ThreadStatusIndicators.tsx | 3 + .../components/sidebar/SidebarThreadRow.tsx | 56 +++++++++++++------ .../client-runtime/src/state/entities.test.ts | 5 +- packages/client-runtime/src/state/models.ts | 6 ++ 8 files changed, 141 insertions(+), 31 deletions(-) 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 6c270a0144ccf..f38f1f4ee752e 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -80,6 +80,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", @@ -87,12 +93,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", @@ -652,6 +653,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected); const status = resolveThreadListV2Status(thread); + const hasAsyncQuestion = status === "working" && thread.hasPendingAsyncQuestion === true; const isUnread = status === "ready" && threadHasUnseenCompletion(thread); const workingLabel = STATUS_LABEL_BY_STATUS[status]; const showsWoke = status === "ready" && props.wokeAt !== null; @@ -659,7 +661,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 = !hasAsyncQuestion && shouldRecedeThreadRow({ status, selected }); const timeLabel = props.timeLabel; const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); @@ -1070,6 +1072,31 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : statusLabel ? ( + {hasAsyncQuestion ? ( + <> + + + {INPUT_STATUS_LABEL.label} + + + ) : null} - {statusLabel.label} + {hasAsyncQuestion ? null : statusLabel.label} {status === "working" ? ( ) : status === "waiting" ? ( diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 10ff6ef6bef69..69ed909da9e52 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -202,6 +202,7 @@ import { isTrailingDoubleClick, resolveProjectStatusIndicator, resolveThreadLastVisitedAt, + resolveAsyncQuestionPill, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, @@ -459,6 +460,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP lastVisitedAt, }, }); + const asyncQuestionStatus = resolveAsyncQuestionPill(thread, threadStatus); const linkedPullRequestStatus = useLinkedThreadPullRequest( thread.environmentId, thread.linkedPullRequest, @@ -736,6 +738,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null} + {asyncQuestionStatus && } {threadStatus && } {canOperateThread && renamingThreadKey === threadKey ? ( { }); }); + 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 90264caa28798..194caf9bc40ba 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1046,6 +1046,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 { @@ -1061,12 +1079,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 15fe062a960f9..1eb9e731e296f 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, @@ -912,6 +913,7 @@ export function ThreadRowLeadingStatus({ lastVisitedAt, }, }); + const asyncQuestionStatus = resolveAsyncQuestionPill(thread, threadStatus); const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); const pendingLink = @@ -949,6 +951,7 @@ export function ThreadRowLeadingStatus({ 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 8837d47ae0eec..671508448319c 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -158,6 +158,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; @@ -683,19 +689,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" ? { @@ -716,11 +725,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", @@ -964,7 +969,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const accessibility = resolveSidebarRowAccessibility({ title: thread.title, - statusLabel: topStatus?.label ?? null, + statusLabel: hasAsyncQuestion ? "Input, still working" : (topStatus?.label ?? null), projectDisplayName: props.projectDisplayName, isActive: props.isActive, }); @@ -987,7 +992,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP "truncate", shouldRecede ? "text-secondary-label redesign:text-foreground/75 redesign:group-hover/sidebar-row:text-foreground redesign:group-focus-visible/sidebar-row:text-foreground" - : isUnread || isWoke || status === "input" || status === "request" + : isUnread || + isWoke || + status === "input" || + status === "request" || + hasAsyncQuestion ? "text-foreground" : status === "failed" ? "text-foreground/95" @@ -997,7 +1006,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/70 redesign:text-secondary-label" - : props.isActive || isWoke || status === "input" || status === "request" + : props.isActive || + isWoke || + status === "input" || + status === "request" || + hasAsyncQuestion ? "text-foreground" : isUnread ? "text-muted-foreground" @@ -1365,6 +1378,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP topStatus.className, )} > + {hasAsyncQuestion ? ( + + + {INPUT_STATUS.label} + + ) : null} {topStatus.icon === "working" ? ( ) : topStatus.icon === "waiting" ? ( @@ -1383,7 +1407,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {/* Not a live region: a collapsed sidebar is inert, so BackgroundThreadAnnouncer and the open thread's timeline speak status changes instead. */} - {topStatus.label} + {hasAsyncQuestion ? null : topStatus.label} {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 d772989f23edc..471c97ae0bc14 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -155,6 +155,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; @@ -313,6 +315,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, From f189316162b6355d3e7aadddaddfd2b078b84ab9 Mon Sep 17 00:00:00 2001 From: bailey Date: Sun, 25 Oct 2026 09:38:36 -0500 Subject: [PATCH 2/4] fix(mobile): announce an open async question in the thread row label The row's accessibilityLabel overrides the text screen readers would derive from its children, so VoiceOver and TalkBack users couldn't tell which working thread has a question waiting. Match the web row's "Input, still working". Reviewed-by: gpt-6.1-sol (Codex) Co-Authored-By: Claude Opus 5.5 --- .../features/threads/thread-list-v2-items.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 f38f1f4ee752e..83f64711c5c65 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1263,6 +1263,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { cardContent ); + const rowAccessibilityLabel = [ + thread.title, + hasAsyncQuestion ? "Input, still working" : null, + props.hasQueuedMessages ? "messages queued to send" : null, + ] + .filter(Boolean) + .join(", "); + const rowContent = (close: () => void) => variant === "card" ? ( Date: Thu, 29 Oct 2026 08:13:48 -0500 Subject: [PATCH 3/4] fix(clients): keep the Goal label beside an open async question While an async question is open, the row hid its status label because the spinner and timer already say Working. Upstream now labels a working thread with an active goal "Goal", which carries more than Working, so hiding it dropped the goal from the web card, the web and mobile accessible labels, and the mobile row. Only the Working label is hidden now; a goal reads "Input, Goal". Reviewed-by: gpt-6.1-sol (Codex) Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/thread-list-v2-items.tsx | 5 +++-- apps/web/src/components/sidebar/SidebarThreadRow.tsx | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) 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 66b63bdcb8b59..d2d57ab1b6e75 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1170,7 +1170,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { selected ? selectedThreadRowColors.foregroundClassName : statusLabel.className, )} > - {hasAsyncQuestion ? null : statusLabel.label} + {/* Beside Input the spinner and timer already say Working; Goal says more. */} + {hasAsyncQuestion && statusLabel.label === "Working" ? null : statusLabel.label} {status === "working" ? ( ) : status === "waiting" ? ( @@ -1320,7 +1321,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const rowAccessibilityLabel = [ thread.title, - hasAsyncQuestion ? "Input, still working" : null, + hasAsyncQuestion ? `Input, ${statusLabel?.label === "Goal" ? "Goal" : "still working"}` : null, props.hasQueuedMessages ? "messages queued to send" : null, ] .filter(Boolean) diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index d19f06c67e298..0532ad023f6d0 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1000,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: hasAsyncQuestion ? "Input, still working" : (topStatus?.label ?? null), + statusLabel: hasAsyncQuestion ? `Input, ${topStatusLabel ?? "still working"}` : topStatusLabel, projectDisplayName: props.projectDisplayName, isActive: props.isActive, }); @@ -1514,7 +1517,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {/* Not a live region: a collapsed sidebar is inert, so BackgroundThreadAnnouncer and the open thread's timeline speak status changes instead. */} - {hasAsyncQuestion ? null : topStatus.label} + {topStatusLabel} {status === "working" ? ( From 168af64551c03bd39032fbe818e3cf6c6f9476ef Mon Sep 17 00:00:00 2001 From: bailey Date: Thu, 29 Oct 2026 08:32:59 -0500 Subject: [PATCH 4/4] test(mobile): cover the thread row's async-question marker The row's rules for an open async question move into pure helpers beside shouldRecedeThreadRow, so a test can check them without a render harness: Input shows only while the run keeps working, the row keeps full emphasis, Working goes unsaid beside Input while Goal stays, and the accessible label reads ", Input, still working" or "<title>, Input, Goal". Behavior is unchanged. Reviewed-by: gpt-6.1-sol (Codex) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../features/threads/thread-list-v2-items.tsx | 30 +++++++----- .../threads/thread-row-emphasis.test.ts | 49 +++++++++++++++++++ .../features/threads/thread-row-emphasis.ts | 43 +++++++++++++++- 3 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-row-emphasis.test.ts 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 d2d57ab1b6e75..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"; @@ -688,7 +693,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected); const status = resolveThreadListV2Status(thread); - const hasAsyncQuestion = status === "working" && thread.hasPendingAsyncQuestion === true; + 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; @@ -696,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 = !hasAsyncQuestion && shouldRecedeThreadRow({ status, selected }); + const recede = shouldRecedeThreadRow({ status, selected, hasAsyncQuestion }); const timeLabel = props.timeLabel; const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); @@ -1170,8 +1178,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { selected ? selectedThreadRowColors.foregroundClassName : statusLabel.className, )} > - {/* Beside Input the spinner and timer already say Working; Goal says more. */} - {hasAsyncQuestion && statusLabel.label === "Working" ? null : statusLabel.label} + {threadRowStatusText({ hasAsyncQuestion, label: statusLabel.label })} {status === "working" ? ( <ThreadListV2Duration startedAt={resolveThreadWorkingStartedAt(thread)} /> ) : status === "waiting" ? ( @@ -1319,13 +1326,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { cardContent ); - const rowAccessibilityLabel = [ - thread.title, - hasAsyncQuestion ? `Input, ${statusLabel?.label === "Goal" ? "Goal" : "still working"}` : null, - props.hasQueuedMessages ? "messages queued to send" : null, - ] - .filter(Boolean) - .join(", "); + const rowAccessibilityLabel = threadRowAccessibilityLabel({ + title: thread.title, + hasAsyncQuestion, + statusLabel: statusLabel?.label ?? null, + hasQueuedMessages: props.hasQueuedMessages === true, + }); const rowContent = (close: () => void) => variant === "card" ? ( diff --git a/apps/mobile/src/features/threads/thread-row-emphasis.test.ts b/apps/mobile/src/features/threads/thread-row-emphasis.test.ts new file mode 100644 index 0000000000000..6d5276bfafd34 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-row-emphasis.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + hasThreadRowAsyncQuestion, + shouldRecedeThreadRow, + threadRowAccessibilityLabel, + threadRowStatusText, +} from "./thread-row-emphasis"; + +describe("thread row with an open async question", () => { + 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(", "); }