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 d7596305cff2..34665a577ae0 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -19,6 +19,10 @@ import type { import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; +import { + presentWaitingRowStatus, + type WaitingRowStatus, +} from "@t3tools/client-runtime/state/thread-execution"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Alert, Pressable, useWindowDimensions, View } from "react-native"; @@ -116,6 +120,24 @@ const DONE_STATUS_LABEL: StatusLabel = { iconTintClassName: "accent-adaptive-emerald-700-300", }; +// A waiting row stays grey like the plain "Waiting" label; the icon names the +// kind of work it waits on, as in the thread's work log. +const WAITING_ICON_BY_KIND: Record = { + subagent: { ios: "sparkles", android: "auto_awesome" }, + command: "terminal", + monitor: "eye", + background_task: "clock", +}; + +function waitingStatusLabel(status: WaitingRowStatus): StatusLabel { + return { + label: status.label, + icon: WAITING_ICON_BY_KIND[status.kind], + className: "text-foreground-muted", + iconTintClassName: "accent-foreground-muted", + }; +} + // Menus keep lifecycle and title regeneration together. Archive keeps its // own surface (thread screen / settings) rather than crowding v2 rows. const CARD_MENU_ACTIONS: MenuAction[] = [ @@ -632,11 +654,17 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { // so checking a thread on any device clears it everywhere. const isUnread = status === "ready" && threadHasUnseenCompletion(thread); const workingLabel = STATUS_LABEL_BY_STATUS[status]; + // A waiting row names what the task waits on. + const waitingStatus = + status === "waiting" ? presentWaitingRowStatus(thread.pendingBackgroundTasks) : null; const statusLabel = + // The detailed label wins; the plain "Waiting" covers rosters without named work. + (waitingStatus ? waitingStatusLabel(waitingStatus) : undefined) ?? // A native /goal keeps the agent going across turns until it is met. (status === "working" && workingLabel !== undefined && thread.goal?.status === "active" ? { ...workingLabel, label: "Goal" } - : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); + : workingLabel) ?? + (isUnread ? DONE_STATUS_LABEL : undefined); const recede = shouldRecedeThreadRow({ status, selected }); // The timestamp is precomputed on the list item (same stamps the settled // tail sorts by) so a minute tick only re-renders rows that draw it. @@ -962,6 +990,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : null, [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title], ); + // VoiceOver reads the row as one element, so it carries what a waiting task waits on. + const accessibilityLabel = [ + thread.title, + waitingStatus?.label, + props.hasQueuedMessages ? "messages queued to send" : undefined, + ] + .filter(Boolean) + .join(", "); const swipeAccessibilityHint = !canOperateThread ? "Opens the thread" : secondaryAction === null @@ -1003,7 +1039,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { /> ) : null} {statusLabel ? ( - + @@ -1197,9 +1234,7 @@ 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={accessibilityLabel} accessibilityRole="button" accessibilityState={{ selected }} onPress={() => { @@ -1229,9 +1264,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { interactionClassName={rowAppearance.interactionClassName} interactionOpacity={rowAppearance.interactionOpacity} accessibilityHint={swipeAccessibilityHint} - accessibilityLabel={ - props.hasQueuedMessages ? `${thread.title}, messages queued to send` : thread.title - } + accessibilityLabel={accessibilityLabel} accessibilityRole="button" accessibilityState={{ selected }} className={rowAppearance.className} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d405d7210e03..229cc30af292 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -34,6 +34,7 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; +import { presentWaitingRowStatus } from "@t3tools/client-runtime/state/thread-execution"; import { resolveSettledThreadTimestamp, sortSettledThreads, @@ -65,6 +66,7 @@ import { AlarmClockIcon, AlarmClockOffIcon, ArrowRightLeftIcon, + BotIcon, CheckIcon, CircleAlertIcon, CircleCheckIcon, @@ -1292,6 +1294,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // Status hues follow the system-wide convention set by sidebar v1 and the // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. + const waitingStatus = + status === "waiting" ? presentWaitingRowStatus(thread.pendingBackgroundTasks) : null; const topStatus = status === "working" ? { @@ -1306,8 +1310,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ? { // Waiting is calm background presence (post-settle background // roster), not active progress, so the label keeps full strength. - label: "Waiting", - icon: null, + // It names what the task waits on; the Working shelf already says + // the task is underway. + label: waitingStatus?.label ?? "Waiting", + icon: waitingStatus?.kind ?? null, className: "text-muted-foreground", } : status === "approval" @@ -2026,9 +2032,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { className={cn( isWokeStatus ? "pointer-events-auto" - : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-any-hover/sidebar-row:absolute group-any-hover/sidebar-row:right-0 group-any-hover/sidebar-row:opacity-0", - "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity", - snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", + : // While fading out, the label stays inside the slot the actions + // now fill, so a long waiting label cannot sweep over the project name. + "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:inset-x-0 group-has-[:focus-visible]/sidebar-status-slot:overflow-hidden group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-any-hover/sidebar-row:absolute group-any-hover/sidebar-row:inset-x-0 group-any-hover/sidebar-row:overflow-hidden group-any-hover/sidebar-row:opacity-0", + "flex items-center justify-end self-center tabular-nums text-secondary-label transition-opacity", + snoozeMenuOpen && + "pointer-events-none absolute inset-x-0 overflow-hidden opacity-0", )} > {topStatus ? ( @@ -2055,7 +2064,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( @@ -2069,11 +2078,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : topStatus.icon === "done" ? ( + ) : topStatus.icon === "subagent" ? ( + + ) : topStatus.icon === "command" ? ( + + ) : topStatus.icon === "monitor" ? ( + + ) : topStatus.icon === "background_task" ? ( + ) : null} {/* The label alone is the live region: a role="status" wrapper around the ticking duration would make screen readers announce every second. */} - {topStatus.label} + + {topStatus.label} + {status === "working" ? ( diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index ba426b8195fe..e5b41eccef34 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -19,6 +19,7 @@ import { v2Projection } from "./orchestrationV2TestFixtures.ts"; import { presentPendingBackgroundWork, presentProviderGoal, + presentWaitingRowStatus, deriveReportedModelSelection, deriveLatestThreadRun, deriveProviderSubagentStatus, @@ -718,3 +719,41 @@ describe("presentProviderGoal", () => { ).toBe("Goal set"); }); }); + +describe("presentWaitingRowStatus", () => { + it("names the one piece of work the agent waits on", () => { + expect( + presentWaitingRowStatus([ + { taskId: "a", kind: "subagent", description: "Review src/math.ts" }, + ]), + ).toEqual({ label: "Waiting on Review src/math.ts", kind: "subagent" }); + expect(presentWaitingRowStatus([{ taskId: "a", kind: "monitor" }])).toEqual({ + label: "Waiting on monitor", + kind: "monitor", + }); + }); + + it("counts several pieces of work, led by subagents", () => { + expect( + presentWaitingRowStatus([ + { taskId: "m", kind: "monitor", description: "Watch PR checks" }, + { taskId: "a", kind: "subagent", description: "Review src/math.ts" }, + { taskId: "b", kind: "subagent", description: "Write tests" }, + ]), + ).toEqual({ label: "Waiting on 2 subagents and 1 monitor", kind: "subagent" }); + }); + + // A dev server left running is not what the task waits on. + it("leaves out commands the agent left running", () => { + expect( + presentWaitingRowStatus([ + { taskId: "dev", kind: "command", description: "vp run dev" }, + { taskId: "m", kind: "monitor", description: "Watch PR checks" }, + ]), + ).toEqual({ label: "Waiting on Watch PR checks", kind: "monitor" }); + expect( + presentWaitingRowStatus([{ taskId: "dev", kind: "command", description: "vp run dev" }]), + ).toBeNull(); + expect(presentWaitingRowStatus([])).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 8206ccea2739..6a2dfdb46f4b 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -433,6 +433,31 @@ export function presentProviderGoal( }; } +export interface WaitingRowStatus { + /** "Waiting on Review src/math.ts" or "Waiting on 2 subagents and 1 monitor". */ + readonly label: string; + /** The kind that leads the label, for the row's icon. */ + readonly kind: BackgroundWorkKind; +} + +/** + * What a waiting thread's list row says it waits on. Only work that wakes the + * agent counts; a dev server it left running is not why the task is underway. + */ +export function presentWaitingRowStatus( + tasks: ReadonlyArray, +): WaitingRowStatus | null { + const presentation = presentPendingBackgroundWork( + tasks.filter((task) => backgroundWorkHoldsCompletion([task])), + ); + const [first] = presentation?.items ?? []; + if (presentation === null || first === undefined) return null; + return { + label: presentation.items.length === 1 ? `Waiting on ${first.label}` : presentation.title, + kind: first.kind, + }; +} + /** The thread a notification row opens: that of the one subagent or delegated task it reports. */ export function notificationChildThreadId( source: OrchestrationV2NotificationSource,