diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index 38adf534184c..bd7ef885aabb 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -29,6 +29,7 @@ import {
type CodexArtifactTemplate,
} from "@t3tools/client-runtime/codex-artifact-templates";
import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
+import { presentPendingBackgroundWork } from "@t3tools/client-runtime/state/thread-execution";
import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
import {
formatModelSelectionEffort,
@@ -428,6 +429,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
// One floating pill above the composer: it reads the connection phase while
// disconnected, the sync state while messages load, then the working timer
// once the feed is settled.
+ // The shell's roster is the server's post-settlement view of what still runs.
+ const pendingBackgroundWork = presentPendingBackgroundWork(
+ props.selectedThread.pendingBackgroundTasks,
+ );
const floatingStatus = ((): FloatingWorkingStatus | null => {
const connectionStatus = connectionFloatingStatus({
connectionError: props.connectionError,
@@ -461,6 +466,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {
return { kind: "working", startedAt: props.activeWorkStartedAt };
}
+ if (pendingBackgroundWork !== null && contentPresentationKind === "ready") {
+ return {
+ kind: "waiting",
+ label: pendingBackgroundWork.title,
+ accessibilityLabel: `${pendingBackgroundWork.title}: ${pendingBackgroundWork.items
+ .map((item) => item.label)
+ .join(", ")}`,
+ };
+ }
return null;
})();
const showWorkingControl = floatingStatus !== null;
diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx
index 362cfc3bd5d4..3538949c9d2d 100644
--- a/apps/mobile/src/features/threads/floating-working-control.tsx
+++ b/apps/mobile/src/features/threads/floating-working-control.tsx
@@ -385,6 +385,26 @@ function FloatingStatusLabel(props: {
);
}
+ if (props.status.kind === "waiting") {
+ return (
+
+
+
+ {props.status.label}
+
+
+ );
+ }
if (props.status.kind === "preparing") {
return (
);
}
- const canExpand = row.canExpand;
+ // A subagent's notification opens its thread, like the subagent's own row.
+ const notifiedSubagentThreadId =
+ row.projectedItem.item.type === "notification"
+ ? notificationChildThreadId(row.projectedItem.item.source)
+ : undefined;
+ const canExpand = row.canExpand && notifiedSubagentThreadId === undefined;
const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : null;
const fullDetail = expanded && !reasoning ? row.getFullDetail() : null;
const viewedImagePath = workEntryViewedImagePath(row.workEntry);
@@ -881,15 +888,26 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
{...(isFreshRow(row.createdAt) ? { entering: FadeIn.duration(200) } : {})}
>
{
+ if (notifiedSubagentThreadId !== undefined) {
+ navigation.navigate("Thread", {
+ environmentId: String(props.environmentId),
+ threadId: String(notifiedSubagentThreadId),
+ });
+ return;
+ }
if (canExpand) {
void Haptics.selectionAsync();
props.onToggleRow(row.id, props.anchorKey);
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index d6c9d71471d4..6608f797eea9 100644
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -455,7 +455,23 @@ function itemWorkLogTone(item: OrchestrationV2TurnItem): WorkLogPresentationEntr
}
function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
- if (item.type === "notification") return "zap";
+ if (item.type === "notification") {
+ const source = item.source;
+ switch (source.kind) {
+ case "subagent":
+ case "delegated_task":
+ return "hammer";
+ case "command":
+ return "command";
+ case "monitor":
+ return "eye";
+ case "background_task":
+ return "zap";
+ default:
+ source satisfies never;
+ return "zap";
+ }
+ }
if (item.type === "dynamic_tool") {
const classified = classifyToolActivity({
itemType: "dynamic_tool_call",
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 7ba2c3843e98..65fbb92abeb9 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -93,6 +93,7 @@ import {
deriveThreadActivityRun,
deriveLatestThreadRun,
deriveThreadRuntime,
+ presentPendingBackgroundWork,
} from "@t3tools/client-runtime/state/thread-execution";
import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";
import {
@@ -133,6 +134,7 @@ import { Debouncer } from "@tanstack/react-pacer";
import { useAtomValue } from "@effect/atom-react";
import { Atom } from "effect/unstable/reactivity";
import {
+ Fragment,
lazy,
memo,
type SetStateAction,
@@ -533,7 +535,7 @@ import { assetEnvironment } from "../state/assets";
import { readPreparedConnection } from "../state/session";
import { useAtomCommand } from "../state/use-atom-command";
import { useAtomQueryRunner } from "../state/use-atom-query-runner";
-import { Button } from "./ui/button";
+import { Button, InlineButton } from "./ui/button";
import {
AlertDialog,
AlertDialogClose,
@@ -6878,11 +6880,21 @@ export default function ChatView(props: ChatViewProps) {
}
}
}, [activeThread, environmentId, interruptThreadTurn, setThreadError]);
+ const onOpenRelatedThread = useCallback(
+ (threadId: ThreadId) => {
+ void navigate({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(scopeThreadRef(environmentId, threadId)),
+ });
+ },
+ [environmentId, navigate],
+ );
+
const backgroundWorkBannerItem = useMemo(() => {
- if (activeBackgroundTasks.length === 0 || !activeThread) {
+ const presentation = presentPendingBackgroundWork(activeBackgroundTasks);
+ if (presentation === null || !activeThread) {
return null;
}
- const count = activeBackgroundTasks.length;
return {
id: `background-work:${activeThread.id}`,
variant: "default",
@@ -6893,8 +6905,30 @@ export default function ChatView(props: ChatViewProps) {
aria-hidden="true"
/>
),
- title: count === 1 ? "Waiting on background task" : `Waiting on ${count} background tasks`,
- description: activeBackgroundTasks.map((task) => task.description || task.taskId).join(", "),
+ title: presentation.title,
+ // A single named item is already in the title.
+ description:
+ presentation.items.length === 1 && presentation.items[0]?.childThreadId === undefined
+ ? undefined
+ : presentation.items.map((item, index) => {
+ const childThreadId = item.childThreadId;
+ return (
+
+ {index > 0 ? ", " : null}
+ {childThreadId === undefined ? (
+ item.label
+ ) : (
+ onOpenRelatedThread(childThreadId)}
+ >
+ {item.label}
+
+ )}
+
+ );
+ }),
actions: (
) : null}
+ {notifiedSubagentThreadId ? (
+ {
+ event.stopPropagation();
+ ctx.onOpenThread(notifiedSubagentThreadId);
+ }}
+ onKeyDown={stopRowToggle}
+ >
+ Open subagent
+
+ ) : null}
{showFailedIndicator &&
!showDestructiveRowStyle &&
!toolIconAcceptsTint(entryIconName, entryToolIcon) ? (
diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts
index 82bf2da92d8f..e728c1ae706b 100644
--- a/packages/client-runtime/src/state/threadExecution.test.ts
+++ b/packages/client-runtime/src/state/threadExecution.test.ts
@@ -14,6 +14,7 @@ import { describe, expect, it } from "vite-plus/test";
import { v2Projection } from "./orchestrationV2TestFixtures.ts";
import {
+ presentPendingBackgroundWork,
deriveLatestThreadRun,
deriveProviderSubagentStatus,
formatModelSelectionEffort,
@@ -449,3 +450,49 @@ describe("threadRuntimeCanArchive", () => {
expect(threadRuntimeCanArchive(runtime("idle", null))).toBe(true);
});
});
+
+describe("presentPendingBackgroundWork", () => {
+ it("names a single piece of work by kind", () => {
+ expect(
+ presentPendingBackgroundWork([
+ { taskId: "a", kind: "subagent", description: "Review src/math.ts" },
+ ])?.title,
+ ).toBe("Waiting on subagent Review src/math.ts");
+ expect(presentPendingBackgroundWork([{ taskId: "a", kind: "command" }])?.title).toBe(
+ "Waiting on a command",
+ );
+ expect(presentPendingBackgroundWork([])).toBeNull();
+ });
+
+ it("groups work by kind, subagents first, and keeps each name", () => {
+ const presentation = presentPendingBackgroundWork([
+ { taskId: "cmd", kind: "command", description: "npm test" },
+ {
+ taskId: "b",
+ kind: "subagent",
+ description: "Write tests",
+ childThreadId: ThreadId.make("thread:b"),
+ },
+ { taskId: "a", kind: "subagent", description: "Review src/math.ts" },
+ ]);
+ expect(presentation?.title).toBe("Waiting on 2 subagents and 1 command");
+ expect(presentation?.items.map((item) => [item.kind, item.label, item.childThreadId])).toEqual([
+ ["subagent", "Write tests", "thread:b"],
+ ["subagent", "Review src/math.ts", undefined],
+ ["command", "npm test", undefined],
+ ]);
+ });
+
+ it("names generic work, including rosters from servers that predate kinds", () => {
+ expect(
+ presentPendingBackgroundWork([
+ { taskId: "bash", kind: "command", description: "Background sleep" },
+ { taskId: "watch", kind: "monitor" },
+ { taskId: "other", kind: "background_task" },
+ ])?.title,
+ ).toBe("Waiting on 1 command, 1 monitor and 1 background task");
+ expect(presentPendingBackgroundWork([{ taskId: "old", kind: "background_task" }])?.title).toBe(
+ "Waiting on a background task",
+ );
+ });
+});
diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts
index b9243d837bba..b884656410ea 100644
--- a/packages/client-runtime/src/state/threadExecution.ts
+++ b/packages/client-runtime/src/state/threadExecution.ts
@@ -7,9 +7,12 @@ import {
isOrchestrationV2WorkActive,
isProviderNativeSubagentThread,
type ModelSelection,
+ type OrchestrationV2NotificationSource,
+ type OrchestrationV2PendingBackgroundTask,
type ServerProviderModel,
type OrchestrationV2ExecutionNode,
type OrchestrationV2ThreadProjection,
+ type ThreadId,
} from "@t3tools/contracts";
import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import { getProviderOptionCurrentLabel, getProviderOptionDescriptors } from "@t3tools/shared/model";
@@ -243,3 +246,94 @@ export function threadRuntimeHasInterruptibleRun(
runtime?.activeRunId !== undefined
);
}
+
+type BackgroundWorkKind = OrchestrationV2PendingBackgroundTask["kind"];
+
+// `order` groups work the way a reader thinks about it: agents first, loose tasks last.
+const BACKGROUND_WORK_KINDS: Record<
+ BackgroundWorkKind,
+ { readonly order: number; readonly singular: string; readonly plural: string }
+> = {
+ subagent: { order: 0, singular: "subagent", plural: "subagents" },
+ command: { order: 1, singular: "command", plural: "commands" },
+ monitor: { order: 2, singular: "monitor", plural: "monitors" },
+ background_task: { order: 3, singular: "background task", plural: "background tasks" },
+};
+
+export interface PendingBackgroundWorkItem {
+ readonly taskId: string;
+ readonly kind: BackgroundWorkKind;
+ /** The work's name, or its noun when the provider gave none. */
+ readonly label: string;
+ /** A subagent's own thread, when it has one. */
+ readonly childThreadId: ThreadId | undefined;
+}
+
+export interface PendingBackgroundWorkPresentation {
+ /** "Waiting on subagent Review src/math.ts", "Waiting on 2 subagents and 1 command". */
+ readonly title: string;
+ readonly items: ReadonlyArray;
+}
+
+function joinWithAnd(parts: ReadonlyArray): string {
+ if (parts.length <= 1) return parts.join("");
+ return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
+}
+
+/** Names what a settled thread is still waiting on, grouped by kind, for the composer strip. */
+export function presentPendingBackgroundWork(
+ tasks: ReadonlyArray,
+): PendingBackgroundWorkPresentation | null {
+ if (tasks.length === 0) return null;
+ const items = tasks
+ .map((task): PendingBackgroundWorkItem => {
+ const description = task.description?.trim();
+ return {
+ taskId: task.taskId,
+ kind: task.kind,
+ label:
+ description === undefined || description.length === 0
+ ? BACKGROUND_WORK_KINDS[task.kind].singular
+ : description,
+ childThreadId: task.kind === "subagent" ? task.childThreadId : undefined,
+ };
+ })
+ // `map` returned a new array; Hermes has no `toSorted`.
+ .sort(
+ (left, right) =>
+ BACKGROUND_WORK_KINDS[left.kind].order - BACKGROUND_WORK_KINDS[right.kind].order,
+ );
+ const [only] = items;
+ if (items.length === 1 && only !== undefined) {
+ const noun = BACKGROUND_WORK_KINDS[only.kind].singular;
+ return {
+ title: only.label === noun ? `Waiting on a ${noun}` : `Waiting on ${noun} ${only.label}`,
+ items,
+ };
+ }
+ const counts = new Map();
+ for (const item of items) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1);
+ const groups = Array.from(counts, ([kind, count]) => {
+ const { singular, plural } = BACKGROUND_WORK_KINDS[kind];
+ return `${count} ${count === 1 ? singular : plural}`;
+ });
+ return { title: `Waiting on ${joinWithAnd(groups)}`, items };
+}
+
+/** The thread a notification row opens: that of the one subagent or delegated task it reports. */
+export function notificationChildThreadId(
+ source: OrchestrationV2NotificationSource,
+): ThreadId | undefined {
+ switch (source.kind) {
+ case "subagent":
+ case "delegated_task":
+ return source.childThreadId;
+ case "command":
+ case "monitor":
+ case "background_task":
+ return undefined;
+ default:
+ source satisfies never;
+ return undefined;
+ }
+}