Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/mobile/src/features/threads/thread-list-v2-items.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,27 @@ it.each(["idle", "running", "waiting"] as const)(
expect(rendered.text).toHaveBeenCalledWith(status === "idle" ? "Waiting" : "Working");
},
);
it("names the work a waiting row waits on", async () => {
await render({
thread: {
...thread,
runtime: {
status: "idle",
activeRunId: null,
providerInstanceId: ProviderInstanceId.make("codex"),
providerName: "Codex",
lastError: null,
updatedAt: "2026-06-02T00:00:00.000Z",
},
pendingBackgroundTasks: [
{ taskId: "a", kind: "subagent", description: "Review src/math.ts" },
{ taskId: "b", kind: "subagent", description: "Write tests" },
],
},
});
expect(rendered.text).toHaveBeenCalledWith("Waiting on 2 subagents");
expect(rendered.text).not.toHaveBeenCalledWith("Waiting");
});
it("keeps Done, settled timestamps and snoozed wake labels on recycled rows", async () => {
await render({
thread: {
Expand Down
23 changes: 17 additions & 6 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import type { EnvironmentMachineKind } from "@t3tools/contracts";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import { presentWaitingRowStatus } 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";
Expand Down Expand Up @@ -589,7 +590,11 @@ 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, in the time label's grey.
const waitingStatus =
status === "waiting" ? presentWaitingRowStatus(thread.pendingBackgroundTasks) : null;
const statusLabel =
(waitingStatus ? { label: waitingStatus.label, className: undefined } : 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" }
Expand Down Expand Up @@ -918,6 +923,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 =
secondaryAction === null
? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`
Expand Down Expand Up @@ -960,11 +973,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
<Text
className={cn(
"text-xs tabular-nums",
waitingStatus !== null && "max-w-48",
statusLabel?.className ??
(selected
? selectedThreadRowColors.foregroundClassName
: rowAppearance.tertiaryForegroundClassName),
)}
numberOfLines={1}
>
{statusLabel?.label ?? timeLabel}
</Text>
Expand Down Expand Up @@ -1121,9 +1136,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={() => {
Expand Down Expand Up @@ -1153,9 +1166,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}
Expand Down
36 changes: 29 additions & 7 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox";
import { presentWaitingRowStatus } from "@t3tools/client-runtime/state/thread-execution";
import {
resolveSettledThreadTimestamp,
sortSettledThreads,
Expand Down Expand Up @@ -62,6 +63,7 @@ import {
AlarmClockIcon,
AlarmClockOffIcon,
ArrowRightLeftIcon,
BotIcon,
CheckIcon,
CircleAlertIcon,
CircleCheckIcon,
Expand Down Expand Up @@ -1255,6 +1257,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"
? {
Expand All @@ -1269,8 +1273,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"
Expand Down Expand Up @@ -1976,9 +1982,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 ? (
Expand All @@ -2005,7 +2014,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
) : (
<span
className={cn(
"inline-flex items-center gap-1 font-medium",
"inline-flex min-w-0 items-center gap-1 font-medium",
topStatus.className,
)}
>
Expand All @@ -2019,11 +2028,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
<CircleAlertIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "done" ? (
<CircleCheckIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "subagent" ? (
<BotIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "command" ? (
<TerminalIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "monitor" ? (
<EyeIcon aria-hidden className="size-4 shrink-0" />
) : topStatus.icon === "background_task" ? (
<ClockIcon aria-hidden className="size-4 shrink-0" />
) : null}
{/* The label alone is the live region: a role="status"
wrapper around the ticking duration would make
screen readers announce every second. */}
<span role="status">{topStatus.label}</span>
<span
role="status"
className={cn(waitingStatus !== null && "max-w-40 truncate")}
>
{topStatus.label}
</span>
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
Expand Down
39 changes: 39 additions & 0 deletions packages/client-runtime/src/state/threadExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { v2Projection } from "./orchestrationV2TestFixtures.ts";
import {
presentPendingBackgroundWork,
presentProviderGoal,
presentWaitingRowStatus,
deriveReportedModelSelection,
deriveLatestThreadRun,
deriveProviderSubagentStatus,
Expand Down Expand Up @@ -825,3 +826,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();
});
});
25 changes: 25 additions & 0 deletions packages/client-runtime/src/state/threadExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,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<OrchestrationV2PendingBackgroundTask>,
): 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,
Expand Down