From e0dcd953bf116214c6662e03851afe36427f9e83 Mon Sep 17 00:00:00 2001 From: Markus Heimbach Date: Sun, 25 Oct 2026 17:17:31 +0100 Subject: [PATCH 1/8] fix(clients): show Input for async questions while the agent works Since #15430 the shell hides nonblocking questions from hasPendingUserInput while the run is working, and the thread list status resolvers only read that flag, so a pending async question left no trace in the sidebar, status pill, mobile list, attention notifications or background announcements. List status now reads the pending runtime request directly via a shared threadHasOpenQuestion helper. Shelf placement, chat view and snooze keep the #15430 semantics. closes #24914 --- .../src/features/home/useThreadListActions.ts | 3 +- .../src/features/threads/threadListV2.test.ts | 21 +++++++++++++ .../src/features/threads/threadListV2.ts | 11 +++++-- apps/web/src/components/Sidebar.logic.test.ts | 30 ++++++++++++++++++- apps/web/src/components/Sidebar.logic.ts | 14 ++++++--- .../backgroundThreadAnnouncement.test.ts | 11 +++++++ .../backgroundThreadAnnouncement.ts | 3 +- packages/client-runtime/src/state/models.ts | 9 ++++++ 8 files changed, 92 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index ddbbf9ff343c5..3eae60a0ca413 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,5 +1,6 @@ import type { ThreadMoveDestination } from "../threads/threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { threadHasOpenQuestion } from "@t3tools/client-runtime/state/models"; import * as ThreadUndo from "@t3tools/client-runtime/state/thread-undo"; import { showThreadUndoNotice } from "./thread-undo-notice"; import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; @@ -342,7 +343,7 @@ export function useThreadListActions(): { Dialog.alert( "Could not snooze thread", thread.hasPendingApprovals || - thread.hasPendingUserInput || + threadHasOpenQuestion(thread) || thread.pendingSubagentRequest != null ? "This thread is waiting on you. Respond to the pending request before snoozing it." : "This thread is still starting a turn. Try again once it's running.", diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index de7400ba741ee..d2f13076fc4b7 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -23,6 +23,7 @@ import { ProjectId, ProviderInstanceId, RunId, + RuntimeRequestId, ThreadId, } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -101,6 +102,26 @@ describe("resolveThreadListV2Status", () => { ); }); + it("shows Input for an async question while the agent keeps working", () => { + const thread = presentThreadShell( + environmentId, + makeRawThreadShell({ + status: "running", + activeRunId: RunId.make("run-async-question"), + latestRunId: RunId.make("run-async-question"), + pendingRuntimeRequest: { + id: RuntimeRequestId.make("request-async-question"), + kind: "user_input", + createdAt: DateTime.makeUnsafe(NOW), + blocking: false, + }, + }), + ); + + expect(thread.hasPendingUserInput).toBe(false); + expect(resolveThreadListV2Status(thread)).toBe("input"); + }); + it("shows a waiting guest message as Request, after the agent's own requests", () => { const thread = presentThreadShell( environmentId, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index d72ab913dec35..25244d52ee68b 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -9,7 +9,10 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; +import { + resolveThreadProviderStack, + threadHasOpenQuestion, +} from "@t3tools/client-runtime/state/models"; import { threadSearchMatchKey, threadSearchSubject, @@ -179,12 +182,14 @@ export function resolveThreadListV2Status( | "pendingGuestMessageCount" | "runtime" | "blockedOnThreadWait" - >, + > & { + readonly source?: Pick; + }, ): ThreadListV2Status { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return "approval"; } - if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") { + if (threadHasOpenQuestion(thread) || thread.pendingSubagentRequest === "user_input") { return "input"; } // A guest is waiting on the owner; the agent's own requests come first. diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 503a6160df778..802004c1615a2 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -35,7 +35,14 @@ import { resolveSidebarDropVerb, } from "./Sidebar.logic"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; -import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + RunId, + RuntimeRequestId, + ThreadId, +} from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, @@ -438,6 +445,27 @@ describe("resolveThreadStatusPill", () => { }, }; + it("shows Input for an async question while the agent keeps working", () => { + const asyncQuestion = presentThreadShell(localEnvironmentId, { + ...makeThreadFixture().source, + latestRunId: RunId.make("run-async-question"), + activeRunId: RunId.make("run-async-question"), + status: "running", + pendingRuntimeRequest: { + id: RuntimeRequestId.make("request-async-question"), + kind: "user_input", + createdAt: DateTime.makeUnsafe("2026-03-09T10:00:00.000Z"), + blocking: false, + }, + }); + + expect(asyncQuestion.hasPendingUserInput).toBe(false); + expect(resolveSidebarThreadStatus(asyncQuestion)).toBe("input"); + expect(resolveThreadStatusPill({ thread: asyncQuestion })).toMatchObject({ + label: "Awaiting Input", + }); + }); + it("shows an active thread wait without a pulse and resumes Working when it ends", () => { const blocked = { ...baseThread, blockedOnThreadWait: true }; expect(resolveSidebarThreadStatus(blocked)).toBe("waiting"); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f2ff8d22e6a89..8d790efe0847c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,7 @@ -import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; +import { + resolveThreadWorkingStartedAt, + threadHasOpenQuestion, +} from "@t3tools/client-runtime/state/models"; import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import * as React from "react"; @@ -612,6 +615,7 @@ type ThreadStatusInput = Pick< lastVisitedAt?: string | null | undefined; pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"] | undefined; blockedOnThreadWait?: boolean | undefined; + source?: Pick | undefined; }; export interface ThreadJumpHintVisibilityController { @@ -886,13 +890,15 @@ type SidebarThreadStatusInput = Pick< | "pendingGuestMessageCount" | "runtime" | "blockedOnThreadWait" ->; +> & { + source?: Pick | undefined; +}; export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return "approval"; } - if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") { + if (threadHasOpenQuestion(thread) || thread.pendingSubagentRequest === "user_input") { return "input"; } // A guest is waiting on the owner; the agent's own requests come first. @@ -1080,7 +1086,7 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.hasPendingUserInput || thread.pendingSubagentRequest === "user_input") { + if (threadHasOpenQuestion(thread) || thread.pendingSubagentRequest === "user_input") { return { label: "Awaiting Input", colorClass: "text-indigo-600 dark:text-indigo-300/90", diff --git a/apps/web/src/components/backgroundThreadAnnouncement.test.ts b/apps/web/src/components/backgroundThreadAnnouncement.test.ts index b2b6195d8f288..a6beb668aefd6 100644 --- a/apps/web/src/components/backgroundThreadAnnouncement.test.ts +++ b/apps/web/src/components/backgroundThreadAnnouncement.test.ts @@ -173,6 +173,17 @@ describe("resolveBackgroundThreadRequest", () => { source: { pendingRuntimeRequest: parentQuestion === null ? null : question(parentQuestion) }, }); + it("raises an async question the agent keeps working past", () => { + expect( + resolveBackgroundThreadRequest({ + hasPendingApprovals: false, + hasPendingUserInput: false, + pendingSubagentRequest: null, + source: { pendingRuntimeRequest: { ...question("question-async"), blocking: false } }, + }), + ).toEqual({ request: "input", requestId: "question-async" }); + }); + it("raises a subagent's request on the parent without borrowing the parent's request id", () => { expect(childApproval(null)).toEqual({ request: "approval", requestId: null }); // The parent's own questions come and go while the child's approval waits. diff --git a/apps/web/src/components/backgroundThreadAnnouncement.ts b/apps/web/src/components/backgroundThreadAnnouncement.ts index 10ca422d0f3cb..f3a77badab327 100644 --- a/apps/web/src/components/backgroundThreadAnnouncement.ts +++ b/apps/web/src/components/backgroundThreadAnnouncement.ts @@ -1,4 +1,5 @@ import type { OrchestrationV2RunStatus, RunId } from "@t3tools/contracts"; +import { threadHasOpenQuestion } from "@t3tools/client-runtime/state/models"; import type { SidebarThreadSummary } from "../types"; import { runOutcomeText, THREAD_STATUS_TEXT } from "./chat/threadActivityAnnouncement"; @@ -29,7 +30,7 @@ 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 (threadHasOpenQuestion(thread)) return { request: "input", requestId: ownRequestId }; if (thread.pendingSubagentRequest === "user_input") return { request: "input", requestId: null }; return { request: null, requestId: null }; } diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index d772989f23edc..d0f76a995a8a7 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -251,6 +251,15 @@ export function scopeProject( export type PendingSubagentRequest = "approval" | "user_input"; +/** Thread lists show Input even for async questions the agent keeps working past. */ +export function threadHasOpenQuestion( + thread: Pick & { + readonly source?: Pick | undefined; + }, +): boolean { + return thread.hasPendingUserInput || thread.source?.pendingRuntimeRequest?.kind === "user_input"; +} + export function presentThreadShell( environmentId: EnvironmentId, thread: OrchestrationV2ThreadShell, From e4b6841120bf47c3fdd145c85bfd9bbdc02f777b Mon Sep 17 00:00:00 2001 From: Markus Heimbach Date: Sun, 25 Oct 2026 17:26:22 +0100 Subject: [PATCH 2/8] fix(web): key thread attention by the pending request Async questions now raise Input while the run keeps going, so a run-scoped attention key would hide a later secret in the same run and renotify the same question whenever a follow-up run is queued. --- ...hreadNotificationCoordinator.logic.test.ts | 25 ++++++++++++++++++ .../ThreadNotificationCoordinator.logic.ts | 26 ++++++++++++++++++- .../ThreadNotificationCoordinator.tsx | 10 ++----- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts index 2bba09f54dcb1..aba141d12461a 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -1,8 +1,11 @@ import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; +import { RunId, RuntimeRequestId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { createThreadNotificationGate, + resolveThreadAttention, type ThreadNotificationObservation, } from "./ThreadNotificationCoordinator.logic"; @@ -191,3 +194,25 @@ describe("createThreadNotificationGate", () => { expect(notify).not.toHaveBeenCalled(); }); }); + +describe("resolveThreadAttention", () => { + const shell = (requestId: string, runId: string) => ({ + hasPendingApprovals: false, + hasPendingUserInput: false, + pendingSubagentRequest: null, + latestRun: { runId: RunId.make(runId) }, + source: { + pendingRuntimeRequest: { + id: RuntimeRequestId.make(requestId), + kind: "user_input" as const, + createdAt: DateTime.makeUnsafe(0), + }, + }, + }); + + it("follows the thread's own request rather than its latest run", () => { + const question = resolveThreadAttention(shell("question", "run-1"), "input"); + expect(resolveThreadAttention(shell("question", "run-2"), "input")).toBe(question); + expect(resolveThreadAttention(shell("secret", "run-1"), "input")).not.toBe(question); + }); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 77edccfbe7741..39a8f1dedfe79 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -1,9 +1,13 @@ import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; +import type { SidebarThreadSummary } from "../types"; +import { resolveBackgroundThreadRequest } from "./backgroundThreadAnnouncement"; +import type { SidebarThreadStatus } from "./Sidebar.logic"; + export type ThreadNotificationKind = "input" | "completion"; export interface ThreadNotificationObservation { - /** Run and status key while the thread needs attention, otherwise null. */ + /** Request or run, and status, while the thread needs attention, otherwise null. */ readonly attention: string | null; /** The thread is idle and its latest run completed. */ readonly settled: boolean; @@ -76,3 +80,23 @@ export function createThreadNotificationGate( }, }; } + +/** The thread's own request keys its attention, so a queued run cannot renotify it. */ +export function resolveThreadAttention( + thread: Parameters[0] & { + readonly latestRun: Pick, "runId"> | null; + }, + status: SidebarThreadStatus, +): string | null { + if ( + status !== "input" && + status !== "request" && + status !== "approval" && + status !== "failed" && + status !== "limited" + ) { + return null; + } + const { requestId } = resolveBackgroundThreadRequest(thread); + return `${requestId ?? thread.latestRun?.runId ?? ""}:${status}`; +} diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 1d6f37ed351d8..9caacf2c89718 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -30,6 +30,7 @@ import { import { resolveSidebarThreadStatus, type SidebarThreadStatus } from "./Sidebar.logic"; import { createThreadNotificationGate, + resolveThreadAttention, type ThreadNotificationKind, type ThreadNotificationObservation, } from "./ThreadNotificationCoordinator.logic"; @@ -247,14 +248,7 @@ function EnvironmentNotifications({ const thread = presentThreadShell(environmentId, rawThread, subagentRequest); let status = resolveSidebarThreadStatus(thread); if (status === "ready" && thread.latestRun?.status === "failed") status = "failed"; - const attention = - status === "input" || - status === "request" || - status === "approval" || - status === "failed" || - status === "limited" - ? `${thread.latestRun?.runId ?? ""}:${status}` - : null; + const attention = resolveThreadAttention(thread, status); const completedAt = Date.parse(thread.latestRun?.completedAt ?? ""); const settled = status === "ready" && thread.latestRun?.status === "completed"; const state = { From 554a9f58e416892de7f4511f5ac5115a38a16642 Mon Sep 17 00:00:00 2001 From: Markus Heimbach Date: Tue, 27 Oct 2026 17:38:28 +0100 Subject: [PATCH 3/8] fix(clients): keep a Working cue beside Input for async questions A running thread with an open async question now shows its working timer next to Input on the web sidebar row and the mobile row, and the Awaiting Input pill keeps its activity pulse, so an agent that keeps working does not look stalled. Blocking questions are unchanged. --- .../features/threads/thread-list-v2-items.tsx | 40 ++++++++++++++++--- apps/web/src/components/Sidebar.logic.test.ts | 33 +++++++++------ apps/web/src/components/Sidebar.logic.ts | 3 +- .../components/sidebar/SidebarThreadRow.tsx | 12 +++++- .../client-runtime/src/state/entities.test.ts | 9 ++++- packages/client-runtime/src/state/models.ts | 17 +++++--- 6 files changed, 86 insertions(+), 28 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 031b2bd529066..ba8c1b79bc810 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -24,6 +24,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state import { formatWorkingDurationLabel, resolveThreadWorkingStartedAt, + threadWorksPastQuestion, } from "@t3tools/client-runtime/state/models"; import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; @@ -82,6 +83,12 @@ interface StatusLabel { readonly className: string; readonly iconTintClassName: string; } +const WORKING_STATUS_LABEL: StatusLabel = { + label: "Working", + icon: "circle.dashed", + className: "text-adaptive-sky-600-400", + iconTintClassName: "accent-adaptive-sky-600-400", +}; const STATUS_LABEL_BY_STATUS: Partial> = { approval: { label: "Approval", @@ -101,12 +108,7 @@ const STATUS_LABEL_BY_STATUS: Partial> = className: "text-adaptive-fuchsia-600-300", iconTintClassName: "accent-adaptive-fuchsia-600-300", }, - working: { - label: "Working", - icon: "circle.dashed", - className: "text-adaptive-sky-600-400", - iconTintClassName: "accent-adaptive-sky-600-400", - }, + working: WORKING_STATUS_LABEL, waiting: { label: "Waiting", icon: "circle.dashed.inset.filled", @@ -680,6 +682,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected); const status = resolveThreadListV2Status(thread); + const worksPastQuestion = status === "input" && threadWorksPastQuestion(thread); const isUnread = status === "ready" && threadHasUnseenCompletion(thread); const workingLabel = STATUS_LABEL_BY_STATUS[status]; const showsWoke = status === "ready" && props.wokeAt !== null; @@ -1116,6 +1119,31 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} + {worksPastQuestion ? ( + <> + + + + + + ) : null} ) : ( { }, }; - it("shows Input for an async question while the agent keeps working", () => { - const asyncQuestion = presentThreadShell(localEnvironmentId, { - ...makeThreadFixture().source, - latestRunId: RunId.make("run-async-question"), - activeRunId: RunId.make("run-async-question"), - status: "running", - pendingRuntimeRequest: { - id: RuntimeRequestId.make("request-async-question"), - kind: "user_input", - createdAt: DateTime.makeUnsafe("2026-03-09T10:00:00.000Z"), - blocking: false, - }, - }); + it("shows Input for an async question and keeps pulsing while the agent works", () => { + const question = (blocking: boolean) => + presentThreadShell(localEnvironmentId, { + ...makeThreadFixture().source, + latestRunId: RunId.make("run-question"), + activeRunId: RunId.make("run-question"), + status: "running", + pendingRuntimeRequest: { + id: RuntimeRequestId.make("request-question"), + kind: "user_input", + createdAt: DateTime.makeUnsafe("2026-03-09T10:00:00.000Z"), + blocking, + }, + }); + const asyncQuestion = question(false); expect(asyncQuestion.hasPendingUserInput).toBe(false); expect(resolveSidebarThreadStatus(asyncQuestion)).toBe("input"); expect(resolveThreadStatusPill({ thread: asyncQuestion })).toMatchObject({ label: "Awaiting Input", + pulse: true, + }); + expect(resolveThreadStatusPill({ thread: question(true) })).toMatchObject({ + label: "Awaiting Input", + pulse: false, }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9b099b8b9c461..b07b64e639384 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,7 @@ import { resolveThreadWorkingStartedAt, threadHasOpenQuestion, + threadWorksPastQuestion, } from "@t3tools/client-runtime/state/models"; import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; @@ -1071,7 +1072,7 @@ export function resolveThreadStatusPill(input: { label: "Awaiting Input", colorClass: "text-indigo-600 dark:text-indigo-300/90", dotClass: "bg-indigo-500 dark:bg-indigo-300/90", - pulse: false, + pulse: threadWorksPastQuestion(thread), }; } diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index b087e3d1e548b..856d6606ac91e 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -18,6 +18,7 @@ import { type EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state import { formatWorkingDurationLabel, resolveThreadProviderStack, + threadWorksPastQuestion, } from "@t3tools/client-runtime/state/models"; import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { @@ -683,6 +684,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarThreadStatus(thread); + const worksPastQuestion = status === "input" && threadWorksPastQuestion(thread); const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); const isWoke = @@ -964,7 +966,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const accessibility = resolveSidebarRowAccessibility({ title: thread.title, - statusLabel: topStatus?.label ?? null, + statusLabel: worksPastQuestion ? "Input, Working" : (topStatus?.label ?? null), projectDisplayName: props.projectDisplayName, isActive: props.isActive, }); @@ -1385,6 +1387,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP + ) : worksPastQuestion ? ( + + + + ) : null} ) diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index abc16b9a7df67..983648422324d 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -12,7 +12,11 @@ import { import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; -import { presentThreadShell, resolveThreadWorkingStartedAt } from "./models.ts"; +import { + presentThreadShell, + resolveThreadWorkingStartedAt, + threadWorksPastQuestion, +} from "./models.ts"; import { v2Projection, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { deriveLatestThreadRun, deriveThreadRuntime } from "./threadExecution.ts"; import { isThreadWorking } from "./threadInbox.ts"; @@ -40,6 +44,7 @@ describe("V2 client presentation", () => { }; const active = presentThreadShell(environmentId, running); expect(active.hasPendingUserInput).toBe(false); + expect(threadWorksPastQuestion(active)).toBe(true); expect(isThreadWorking(active)).toBe(true); expect(canSnooze(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); @@ -49,6 +54,7 @@ describe("V2 client presentation", () => { status: "completed", }); expect(completed.hasPendingUserInput).toBe(true); + expect(threadWorksPastQuestion(completed)).toBe(false); expect(isThreadWorking(completed)).toBe(false); expect(effectiveSnoozed(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); }); @@ -68,6 +74,7 @@ describe("V2 client presentation", () => { }, }); expect(shell.hasPendingUserInput).toBe(true); + expect(threadWorksPastQuestion(shell)).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 d0f76a995a8a7..f47938b0993ca 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -251,13 +251,18 @@ export function scopeProject( export type PendingSubagentRequest = "approval" | "user_input"; +type OpenQuestionShell = Pick & { + readonly source?: Pick | undefined; +}; + /** Thread lists show Input even for async questions the agent keeps working past. */ -export function threadHasOpenQuestion( - thread: Pick & { - readonly source?: Pick | undefined; - }, -): boolean { - return thread.hasPendingUserInput || thread.source?.pendingRuntimeRequest?.kind === "user_input"; +export function threadHasOpenQuestion(thread: OpenQuestionShell): boolean { + return thread.hasPendingUserInput || threadWorksPastQuestion(thread); +} + +/** An async question during active work; lists pair Input with a Working cue. */ +export function threadWorksPastQuestion(thread: OpenQuestionShell): boolean { + return !thread.hasPendingUserInput && thread.source?.pendingRuntimeRequest?.kind === "user_input"; } export function presentThreadShell( From 40b9cca05a23a8fe13dcfe7b47368d8a88a3b3c6 Mon Sep 17 00:00:00 2001 From: Markus Heimbach Date: Tue, 27 Oct 2026 17:46:20 +0100 Subject: [PATCH 4/8] fix(clients): name Working in the pill and drop it during thread waits The Awaiting Input pill now says Working in its text, tooltip and accessible label instead of relying on a pulse, and no Working cue shows while the agent is blocked on a thread wait. --- apps/web/src/components/Sidebar.logic.test.ts | 12 ++++++++---- apps/web/src/components/Sidebar.logic.ts | 6 +++++- apps/web/src/components/ThreadStatusIndicators.tsx | 11 ++++++----- packages/client-runtime/src/state/entities.test.ts | 5 +++++ packages/client-runtime/src/state/models.ts | 12 +++++++++--- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index ca5e26cb94696..d828297994a10 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -470,11 +470,15 @@ describe("resolveThreadStatusPill", () => { expect(resolveThreadStatusPill({ thread: asyncQuestion })).toMatchObject({ label: "Awaiting Input", pulse: true, + working: true, }); - expect(resolveThreadStatusPill({ thread: question(true) })).toMatchObject({ - label: "Awaiting Input", - pulse: false, - }); + for (const thread of [question(true), { ...asyncQuestion, blockedOnThreadWait: true }]) { + expect(resolveThreadStatusPill({ thread })).toMatchObject({ + label: "Awaiting Input", + pulse: false, + working: false, + }); + } }); it("shows an active thread wait without a pulse and resumes Working when it ends", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index b07b64e639384..afa2978360609 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -638,6 +638,8 @@ export interface ThreadStatusPill { colorClass: string; dotClass: string; pulse: boolean; + /** The agent keeps working past an async question. */ + working?: boolean; } const THREAD_STATUS_PRIORITY: Record = { @@ -1068,11 +1070,13 @@ export function resolveThreadStatusPill(input: { } if (threadHasOpenQuestion(thread) || thread.pendingSubagentRequest === "user_input") { + const working = threadWorksPastQuestion(thread); return { label: "Awaiting Input", colorClass: "text-indigo-600 dark:text-indigo-300/90", dotClass: "bg-indigo-500 dark:bg-indigo-300/90", - pulse: threadWorksPastQuestion(thread), + pulse: working, + working, }; } diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 78b2a247ba5e5..8369b152c386a 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -810,6 +810,7 @@ export function ThreadStatusLabel({ status: ThreadStatusPill; compact?: boolean; }) { + const text = status.working ? `${status.label} · Working` : status.label; if (compact) { return ( @@ -817,7 +818,7 @@ export function ThreadStatusLabel({ render={ } @@ -828,7 +829,7 @@ export function ThreadStatusLabel({ }`} /> - {status.label} + {text} ); } @@ -839,7 +840,7 @@ export function ThreadStatusLabel({ render={ } @@ -849,9 +850,9 @@ export function ThreadStatusLabel({ status.pulse ? "motion-safe:animate-status-pulse" : "" }`} /> - {status.label} + {text} - {status.label} + {text} ); } diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 983648422324d..1c6857499c9ac 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -45,6 +45,11 @@ describe("V2 client presentation", () => { const active = presentThreadShell(environmentId, running); expect(active.hasPendingUserInput).toBe(false); expect(threadWorksPastQuestion(active)).toBe(true); + expect( + threadWorksPastQuestion( + presentThreadShell(environmentId, { ...running, blockedOnThreadWait: true }), + ), + ).toBe(false); expect(isThreadWorking(active)).toBe(true); expect(canSnooze(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index f47938b0993ca..d71e9410bea74 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -257,12 +257,18 @@ type OpenQuestionShell = Pick & { /** Thread lists show Input even for async questions the agent keeps working past. */ export function threadHasOpenQuestion(thread: OpenQuestionShell): boolean { - return thread.hasPendingUserInput || threadWorksPastQuestion(thread); + return thread.hasPendingUserInput || thread.source?.pendingRuntimeRequest?.kind === "user_input"; } /** An async question during active work; lists pair Input with a Working cue. */ -export function threadWorksPastQuestion(thread: OpenQuestionShell): boolean { - return !thread.hasPendingUserInput && thread.source?.pendingRuntimeRequest?.kind === "user_input"; +export function threadWorksPastQuestion( + thread: OpenQuestionShell & { readonly blockedOnThreadWait?: boolean | undefined }, +): boolean { + return ( + !thread.hasPendingUserInput && + thread.blockedOnThreadWait !== true && + thread.source?.pendingRuntimeRequest?.kind === "user_input" + ); } export function presentThreadShell( From f3ca12e42625fb1664773521db7bfd96bc9e09da Mon Sep 17 00:00:00 2001 From: Markus Heimbach Date: Tue, 27 Oct 2026 17:51:45 +0100 Subject: [PATCH 5/8] fix(web): keep the Working cue in collapsed project status The collapsed project indicator now names Working like the thread pill does, and a blocked question outranks one the agent keeps working past regardless of thread order. --- apps/web/src/components/LegacySidebar.tsx | 5 +++-- apps/web/src/components/Sidebar.logic.test.ts | 16 ++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 13 +++++++++++-- .../src/components/ThreadStatusIndicators.tsx | 3 ++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 10ff6ef6bef69..1e45371f82686 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -201,6 +201,7 @@ import { isSidebarNestedLinkClick, isTrailingDoubleClick, resolveProjectStatusIndicator, + threadStatusPillText, resolveThreadLastVisitedAt, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -2302,7 +2303,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } @@ -2316,7 +2317,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec - {projectStatus.label} + {threadStatusPillText(projectStatus)} ) : ( { }); describe("resolveProjectStatusIndicator", () => { + it("lets a blocked question outrank one the agent keeps working past", () => { + const input = { + label: "Awaiting Input" as const, + colorClass: "text-indigo-600", + dotClass: "bg-indigo-500", + }; + const blocked = { ...input, pulse: false, working: false }; + const working = { ...input, pulse: true, working: true }; + + expect(resolveProjectStatusIndicator([blocked, working])).toBe(blocked); + expect(resolveProjectStatusIndicator([working, blocked])).toBe(blocked); + expect(threadStatusPillText(working)).toBe("Awaiting Input · Working"); + expect(threadStatusPillText(blocked)).toBe("Awaiting Input"); + }); + it("ranks waiting below active work and above plan-ready", () => { const waiting = { label: "Waiting" as const, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index afa2978360609..a0d85bd76a739 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -642,6 +642,10 @@ export interface ThreadStatusPill { working?: boolean; } +export function threadStatusPillText(status: ThreadStatusPill): string { + return status.working ? `${status.label} · Working` : status.label; +} + const THREAD_STATUS_PRIORITY: Record = { "Pending Approval": 5, "Awaiting Input": 4, @@ -1168,9 +1172,14 @@ export function resolveProjectStatusIndicator( for (const status of statuses) { if (status === null) continue; + const priority = THREAD_STATUS_PRIORITY[status.label]; + const highestPriority = + highestPriorityStatus === null ? null : THREAD_STATUS_PRIORITY[highestPriorityStatus.label]; if ( - highestPriorityStatus === null || - THREAD_STATUS_PRIORITY[status.label] > THREAD_STATUS_PRIORITY[highestPriorityStatus.label] + highestPriority === null || + priority > highestPriority || + // A blocked question outranks one the agent keeps working past. + (priority === highestPriority && highestPriorityStatus?.working === true && !status.working) ) { highestPriorityStatus = status; } diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 8369b152c386a..aa33851f1b852 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -43,6 +43,7 @@ import { pullRequestMatchesSearchQuery, resolveThreadLastVisitedAt, resolveThreadStatusPill, + threadStatusPillText, type ThreadStatusPill, useRetainedValue, useSidebarRowSubscriptionLease, @@ -810,7 +811,7 @@ export function ThreadStatusLabel({ status: ThreadStatusPill; compact?: boolean; }) { - const text = status.working ? `${status.label} · Working` : status.label; + const text = threadStatusPillText(status); if (compact) { return ( From 93f62075cb7c63ad9a6fbc73d2a9efc4d49aa8ba Mon Sep 17 00:00:00 2001 From: Markus Heimbach Date: Tue, 27 Oct 2026 18:13:37 +0100 Subject: [PATCH 6/8] revert(web): drop the project-indicator Working cue Keeps the PR to its original scope plus the CodeRabbit-requested Working cue on the web row, status pill and mobile row. This reverts commit f3ca12e42625fb1664773521db7bfd96bc9e09da. --- apps/web/src/components/LegacySidebar.tsx | 5 ++--- apps/web/src/components/Sidebar.logic.test.ts | 16 ---------------- apps/web/src/components/Sidebar.logic.ts | 13 ++----------- .../src/components/ThreadStatusIndicators.tsx | 3 +-- 4 files changed, 5 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 1e45371f82686..10ff6ef6bef69 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -201,7 +201,6 @@ import { isSidebarNestedLinkClick, isTrailingDoubleClick, resolveProjectStatusIndicator, - threadStatusPillText, resolveThreadLastVisitedAt, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -2303,7 +2302,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } @@ -2317,7 +2316,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec - {threadStatusPillText(projectStatus)} + {projectStatus.label} ) : ( { }); describe("resolveProjectStatusIndicator", () => { - it("lets a blocked question outrank one the agent keeps working past", () => { - const input = { - label: "Awaiting Input" as const, - colorClass: "text-indigo-600", - dotClass: "bg-indigo-500", - }; - const blocked = { ...input, pulse: false, working: false }; - const working = { ...input, pulse: true, working: true }; - - expect(resolveProjectStatusIndicator([blocked, working])).toBe(blocked); - expect(resolveProjectStatusIndicator([working, blocked])).toBe(blocked); - expect(threadStatusPillText(working)).toBe("Awaiting Input · Working"); - expect(threadStatusPillText(blocked)).toBe("Awaiting Input"); - }); - it("ranks waiting below active work and above plan-ready", () => { const waiting = { label: "Waiting" as const, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a0d85bd76a739..afa2978360609 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -642,10 +642,6 @@ export interface ThreadStatusPill { working?: boolean; } -export function threadStatusPillText(status: ThreadStatusPill): string { - return status.working ? `${status.label} · Working` : status.label; -} - const THREAD_STATUS_PRIORITY: Record = { "Pending Approval": 5, "Awaiting Input": 4, @@ -1172,14 +1168,9 @@ export function resolveProjectStatusIndicator( for (const status of statuses) { if (status === null) continue; - const priority = THREAD_STATUS_PRIORITY[status.label]; - const highestPriority = - highestPriorityStatus === null ? null : THREAD_STATUS_PRIORITY[highestPriorityStatus.label]; if ( - highestPriority === null || - priority > highestPriority || - // A blocked question outranks one the agent keeps working past. - (priority === highestPriority && highestPriorityStatus?.working === true && !status.working) + highestPriorityStatus === null || + THREAD_STATUS_PRIORITY[status.label] > THREAD_STATUS_PRIORITY[highestPriorityStatus.label] ) { highestPriorityStatus = status; } diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index aa33851f1b852..8369b152c386a 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -43,7 +43,6 @@ import { pullRequestMatchesSearchQuery, resolveThreadLastVisitedAt, resolveThreadStatusPill, - threadStatusPillText, type ThreadStatusPill, useRetainedValue, useSidebarRowSubscriptionLease, @@ -811,7 +810,7 @@ export function ThreadStatusLabel({ status: ThreadStatusPill; compact?: boolean; }) { - const text = threadStatusPillText(status); + const text = status.working ? `${status.label} · Working` : status.label; if (compact) { return ( From 47cddd332367b7af38060bcb506544c6e31361b9 Mon Sep 17 00:00:00 2001 From: Markus Heimbach Date: Tue, 27 Oct 2026 18:14:56 +0100 Subject: [PATCH 7/8] refactor(clients): trim churn in the Working cue change --- .../features/threads/thread-list-v2-items.tsx | 19 +++++++++---------- packages/client-runtime/src/state/models.ts | 14 ++++++++------ 2 files changed, 17 insertions(+), 16 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 ba8c1b79bc810..96da65b42790a 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -83,12 +83,6 @@ interface StatusLabel { readonly className: string; readonly iconTintClassName: string; } -const WORKING_STATUS_LABEL: StatusLabel = { - label: "Working", - icon: "circle.dashed", - className: "text-adaptive-sky-600-400", - iconTintClassName: "accent-adaptive-sky-600-400", -}; const STATUS_LABEL_BY_STATUS: Partial> = { approval: { label: "Approval", @@ -108,7 +102,12 @@ const STATUS_LABEL_BY_STATUS: Partial> = className: "text-adaptive-fuchsia-600-300", iconTintClassName: "accent-adaptive-fuchsia-600-300", }, - working: WORKING_STATUS_LABEL, + working: { + label: "Working", + icon: "circle.dashed", + className: "text-adaptive-sky-600-400", + iconTintClassName: "accent-adaptive-sky-600-400", + }, waiting: { label: "Waiting", icon: "circle.dashed.inset.filled", @@ -1122,12 +1121,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {worksPastQuestion ? ( <> diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index d71e9410bea74..659ec7fc3cf64 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -251,18 +251,20 @@ export function scopeProject( export type PendingSubagentRequest = "approval" | "user_input"; -type OpenQuestionShell = Pick & { - readonly source?: Pick | undefined; -}; - /** Thread lists show Input even for async questions the agent keeps working past. */ -export function threadHasOpenQuestion(thread: OpenQuestionShell): boolean { +export function threadHasOpenQuestion( + thread: Pick & { + readonly source?: Pick | undefined; + }, +): boolean { return thread.hasPendingUserInput || thread.source?.pendingRuntimeRequest?.kind === "user_input"; } /** An async question during active work; lists pair Input with a Working cue. */ export function threadWorksPastQuestion( - thread: OpenQuestionShell & { readonly blockedOnThreadWait?: boolean | undefined }, + thread: Parameters[0] & { + readonly blockedOnThreadWait?: boolean | undefined; + }, ): boolean { return ( !thread.hasPendingUserInput && From a68049022309ff1fa084b27e2c0224f4e57a5ff0 Mon Sep 17 00:00:00 2001 From: Markus Heimbach Date: Tue, 27 Oct 2026 18:18:39 +0100 Subject: [PATCH 8/8] fix(mobile): announce Input and Working on async question rows --- apps/mobile/src/features/threads/thread-list-v2-items.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 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 96da65b42790a..e417b103ef377 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1292,9 +1292,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { interactionOpacity={rowAppearance.interactionOpacity} className={rowAppearance.className} accessibilityHint={swipeAccessibilityHint} - accessibilityLabel={ - props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title - } + accessibilityLabel={`${thread.title}${worksPastQuestion ? ", Input, Working" : ""}${ + props.hasQueuedMessages ? ", messages queued to send" : "" + }`} accessibilityRole="button" accessibilityState={{ selected }} accessibilityActions={rowAccessibilityActions}