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; + } +}