-
- ) : null
- }
- bannerItems={composerBannerItems}
- // With attachments or contexts aboard the pick just inserts the
- // text, so it sends as a prompt like the typed path would.
- onUsageLimitsCommand={
- usageLimitsOffered &&
- usageLimitsKey !== null &&
- !composerHasNonPromptContent
- ? openUsageLimits
- : undefined
- }
- environmentUnavailable={activeEnvironmentUnavailableState}
- activePendingApproval={activePendingApproval}
- pendingApprovals={pendingApprovals}
- pendingUserInputs={pendingUserInputs}
- activePendingProgress={activePendingProgress}
- activePendingResolvedAnswers={activePendingResolvedAnswers}
- activePendingIsResponding={activePendingIsResponding}
- activePendingDraftAnswers={activePendingDraftAnswers}
- activePendingQuestionIndex={activePendingQuestionIndex}
- respondingRequestIds={respondingRequestIds}
- showPlanFollowUpPrompt={showPlanFollowUpPrompt}
- activeProposedPlan={activeProposedPlan}
- threadSyncPhase={activeEnvironmentUnavailable ? null : threadSyncPhase}
- runtimeMode={runtimeMode}
- interactionMode={interactionMode}
- lockedProvider={modelPickerLockedProvider}
- providerStatuses={providerStatuses as ServerProvider[]}
- providerCatalogKnown={serverConfig !== null}
- activeProjectDefaultModelSelection={activeProjectDefaultModelSelection}
- activeThreadModelSelection={activeThread?.modelSelection}
- activeContextWindow={activeContextWindow}
- activeTasksProgress={activeComposerTasksProgress}
- activeTaskSteps={activeComposerTaskSteps}
- compactThreadUnavailable={compactThreadUnavailable}
- compactDisabled={compactDisabled}
- compactDisabledReason={compactDisabledReason}
- resolvedTheme={resolvedTheme}
- settings={settings}
- keybindings={keybindings}
- terminalOpen={Boolean(terminalUiState.terminalOpen)}
- gitCwd={gitCwd}
- pullRequestProjectId={
- supportsPullRequests ? (activeProject?.id ?? null) : null
- }
- pullRequestRepository={
- supportsPullRequests ? activeProjectRepository : null
- }
- restingControlsHost={restingComposerControlsHost}
- restingControlsHaveLeadingContext={
- mountComposerContextStrip &&
- (isGitRepo || showComposerEnvironmentIndicator)
- }
- onRestingControlsVisibilityChange={setRestingComposerControlsVisible}
- getTimelineScrollableNode={getTimelineScrollableNode}
- isTimelineAtLogicalEnd={isTimelineAtLogicalEnd}
- timelineOverflows={timelineOverflows}
- onComposerOverlayHeightChange={publishComposerOverlayHeight}
- onRestingChange={onComposerRestingChange}
- promptRef={promptRef}
- composerImagesRef={composerImagesRef}
- composerFilesRef={composerFilesRef}
- composerTerminalContextsRef={composerTerminalContextsRef}
- onPageScrollKeyDown={onComposerPageScrollKeyDown}
- onPageScrollKeyUp={onComposerPageScrollKeyUp}
- onPageScrollRelease={onComposerPageScrollRelease}
- onCompactContext={onCompactContext}
- onSend={onSend}
- onInterrupt={onInterrupt}
- onImplementPlanInNewThread={onImplementPlanInNewThread}
- onRespondToApproval={onRespondToApproval}
- onSelectActivePendingUserInputOption={
- onSelectActivePendingUserInputOption
- }
- onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
- onDismissActivePendingUserInput={onDismissUserInput}
- onPreviousActivePendingUserInputQuestion={
- onPreviousActivePendingUserInputQuestion
- }
- onChangeActivePendingUserInputCustomAnswer={
- onChangeActivePendingUserInputCustomAnswer
- }
- onProviderModelSelect={onProviderModelSelect}
- onOpenProviderSetup={openProviderSetup}
- getModelDisabledReason={getModelDisabledReason}
- toggleInteractionMode={toggleInteractionMode}
- handleRuntimeModeChange={handleRuntimeModeChange}
- handleInteractionModeChange={handleInteractionModeChange}
- focusComposer={focusComposer}
- scheduleComposerFocus={scheduleComposerFocus}
- setThreadError={setThreadError}
- onExpandImage={onExpandTimelineImage}
- onFileOpen={openFileAttachment}
- editingQueuedAttachments={composerEditingQueuedAttachments}
- onRemoveEditingQueuedAttachment={removeEditingQueuedAttachment}
- />
+ {showProviderSubagentBar ? (
+
onOpenRelatedThread(parentThreadLink.threadId)
+ : null
+ }
+ />
+ ) : null}
+ {!composerMounted ? null : (
+
+ ) : null
+ }
+ bannerItems={composerBannerItems}
+ // With attachments or contexts aboard the pick just inserts the
+ // text, so it sends as a prompt like the typed path would.
+ onUsageLimitsCommand={
+ usageLimitsOffered &&
+ usageLimitsKey !== null &&
+ !composerHasNonPromptContent
+ ? openUsageLimits
+ : undefined
+ }
+ environmentUnavailable={activeEnvironmentUnavailableState}
+ activePendingApproval={activePendingApproval}
+ pendingApprovals={pendingApprovals}
+ pendingUserInputs={pendingUserInputs}
+ activePendingProgress={activePendingProgress}
+ activePendingResolvedAnswers={activePendingResolvedAnswers}
+ activePendingIsResponding={activePendingIsResponding}
+ activePendingDraftAnswers={activePendingDraftAnswers}
+ activePendingQuestionIndex={activePendingQuestionIndex}
+ respondingRequestIds={respondingRequestIds}
+ showPlanFollowUpPrompt={showPlanFollowUpPrompt}
+ activeProposedPlan={activeProposedPlan}
+ threadSyncPhase={
+ activeEnvironmentUnavailable ? null : threadSyncPhase
+ }
+ runtimeMode={runtimeMode}
+ interactionMode={interactionMode}
+ lockedProvider={modelPickerLockedProvider}
+ providerStatuses={providerStatuses as ServerProvider[]}
+ providerCatalogKnown={serverConfig !== null}
+ activeProjectDefaultModelSelection={
+ activeProjectDefaultModelSelection
+ }
+ activeThreadModelSelection={activeThread?.modelSelection}
+ activeContextWindow={activeContextWindow}
+ activeTasksProgress={activeComposerTasksProgress}
+ activeTaskSteps={activeComposerTaskSteps}
+ compactThreadUnavailable={compactThreadUnavailable}
+ compactDisabled={compactDisabled}
+ compactDisabledReason={compactDisabledReason}
+ resolvedTheme={resolvedTheme}
+ settings={settings}
+ keybindings={keybindings}
+ terminalOpen={Boolean(terminalUiState.terminalOpen)}
+ gitCwd={gitCwd}
+ pullRequestProjectId={
+ supportsPullRequests ? (activeProject?.id ?? null) : null
+ }
+ pullRequestRepository={
+ supportsPullRequests ? activeProjectRepository : null
+ }
+ restingControlsHost={restingComposerControlsHost}
+ restingControlsHaveLeadingContext={
+ mountComposerContextStrip &&
+ (isGitRepo || showComposerEnvironmentIndicator)
+ }
+ onRestingControlsVisibilityChange={setRestingComposerControlsVisible}
+ getTimelineScrollableNode={getTimelineScrollableNode}
+ isTimelineAtLogicalEnd={isTimelineAtLogicalEnd}
+ timelineOverflows={timelineOverflows}
+ onComposerOverlayHeightChange={publishComposerOverlayHeight}
+ onRestingChange={onComposerRestingChange}
+ promptRef={promptRef}
+ composerImagesRef={composerImagesRef}
+ composerFilesRef={composerFilesRef}
+ composerTerminalContextsRef={composerTerminalContextsRef}
+ onPageScrollKeyDown={onComposerPageScrollKeyDown}
+ onPageScrollKeyUp={onComposerPageScrollKeyUp}
+ onPageScrollRelease={onComposerPageScrollRelease}
+ onCompactContext={onCompactContext}
+ onSend={onSend}
+ onInterrupt={onInterrupt}
+ onImplementPlanInNewThread={onImplementPlanInNewThread}
+ onRespondToApproval={onRespondToApproval}
+ onSelectActivePendingUserInputOption={
+ onSelectActivePendingUserInputOption
+ }
+ onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
+ onDismissActivePendingUserInput={onDismissUserInput}
+ onPreviousActivePendingUserInputQuestion={
+ onPreviousActivePendingUserInputQuestion
+ }
+ onChangeActivePendingUserInputCustomAnswer={
+ onChangeActivePendingUserInputCustomAnswer
+ }
+ onProviderModelSelect={onProviderModelSelect}
+ onOpenProviderSetup={openProviderSetup}
+ getModelDisabledReason={getModelDisabledReason}
+ toggleInteractionMode={toggleInteractionMode}
+ handleRuntimeModeChange={handleRuntimeModeChange}
+ handleInteractionModeChange={handleInteractionModeChange}
+ focusComposer={focusComposer}
+ scheduleComposerFocus={scheduleComposerFocus}
+ setThreadError={setThreadError}
+ onExpandImage={onExpandTimelineImage}
+ onFileOpen={openFileAttachment}
+ editingQueuedAttachments={composerEditingQueuedAttachments}
+ onRemoveEditingQueuedAttachment={removeEditingQueuedAttachment}
+ />
+ )}
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index aac01d291c9c..ffb36fdfd08f 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -188,6 +188,7 @@ import {
import { useComposerPathSearch } from "../../lib/composerPathSearchState";
import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import {
+ composerModelPickerCanStayOpen,
getRestingComposerImagePreviewCounts,
resolveRestingComposerControlsLayout,
shouldAnimateComposerRestingTransition,
@@ -1511,6 +1512,11 @@ export interface ChatComposerProps {
isLocalDraftThread: boolean;
forceExpandedOnMobile: boolean;
projectSelectionRequired: boolean;
+ /**
+ * The thread's model, effort, and access belong to its provider (a native
+ * subagent answering a question): hide those pickers and attachments.
+ */
+ hideThreadSettings?: boolean;
// Session phase
phase: SessionPhase;
@@ -1685,6 +1691,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isLocalDraftThread: _isLocalDraftThread,
forceExpandedOnMobile,
projectSelectionRequired,
+ hideThreadSettings = false,
phase,
isConnecting,
isSendBusy,
@@ -2754,6 +2761,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const showCollapsedMobilePromptRow =
isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
const showComposerAttachAction =
+ !hideThreadSettings &&
fileStagingLimit !== null &&
(!activePendingProgress ||
(supportsQuestionAttachments &&
@@ -5058,7 +5066,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const composerControlsVisibleInStrip =
composerControlsCollapsed && restingControlsHost !== null && restingControlsVisible;
const composerControlsHidden = composerControlsCollapsed && !restingControlsVisible;
- if (composerControlsHidden && isComposerModelPickerOpen) {
+ if (
+ isComposerModelPickerOpen &&
+ !composerModelPickerCanStayOpen({
+ controlsHidden: composerControlsHidden,
+ threadSettingsHidden: hideThreadSettings,
+ })
+ ) {
setIsComposerModelPickerOpen(false);
}
useLayoutEffect(() => {
@@ -5304,7 +5318,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const hiddenRestingBlockIds = restingBlockDefs
.slice(restingBlockDefs.length - restingHiddenBlockCount)
.map((def) => def.id);
- const composerControls = showProviderUnavailable ? (
+ const composerControls = hideThreadSettings ? null : showProviderUnavailable ? (
void) | null;
+}) {
+ const statusRef = useRef(null);
+ const { status } = props;
+ const live = status !== null && isOrchestrationV2WorkActive(status.status);
+ const modelDescription =
+ props.effortLabel === null ? props.modelLabel : `${props.modelLabel}, ${props.effortLabel}`;
+ // Announced once per transition; the ticking label below is not.
+ const announcement = formatProviderSubagentStatus(
+ status === null ? null : { ...status, startedAt: null },
+ 0,
+ );
+
+ // The label is written from an effect, and live bars tick through DOM
+ // writes, so a running timer never re-renders the chat view.
+ useLayoutEffect(() => {
+ const update = () => {
+ if (statusRef.current) {
+ statusRef.current.textContent = formatProviderSubagentStatus(status, Date.now());
+ }
+ };
+ update();
+ if (!live) return;
+ const id = setInterval(update, 1_000);
+ return () => clearInterval(id);
+ }, [live, status]);
+
+ return (
+
+
+ {props.provider ? (
+
+ ) : null}
+ {props.modelLabel}
+ {props.effortLabel === null ? null : (
+ {props.effortLabel}
+ )}
+
+
+
+ {`${modelDescription} subagent: ${announcement}`}
+
+
Runs on its own
+ {props.onOpenParent ? (
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts
index 9a1f54b1e48f..f892da336ccc 100644
--- a/apps/web/src/components/composerFooterLayout.test.ts
+++ b/apps/web/src/components/composerFooterLayout.test.ts
@@ -6,6 +6,8 @@ import {
COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
COMPOSER_RESTING_EXPANSION_MIN_PX,
getRestingComposerImagePreviewCounts,
+ composerModelPickerCanStayOpen,
+ overlayComposerIsResting,
resolveComposerTimelineInset,
resolveScrollToEndClearance,
resolveRestingComposerControlsLayout,
@@ -77,6 +79,38 @@ describe("shouldUseCompactComposerPrimaryActions", () => {
});
});
+describe("composerModelPickerCanStayOpen", () => {
+ it("closes the picker while a native subagent hides the thread settings", () => {
+ expect(
+ composerModelPickerCanStayOpen({ controlsHidden: false, threadSettingsHidden: true }),
+ ).toBe(false);
+ expect(
+ composerModelPickerCanStayOpen({ controlsHidden: true, threadSettingsHidden: false }),
+ ).toBe(false);
+ expect(
+ composerModelPickerCanStayOpen({ controlsHidden: false, threadSettingsHidden: false }),
+ ).toBe(true);
+ });
+});
+
+describe("overlayComposerIsResting", () => {
+ it("drops a resting reservation once a status bar replaces the composer", () => {
+ // The composer rested on a scroll, then the thread swapped it for the
+ // subagent bar. The bar's 56px overlay must not keep the resting estimate.
+ const isResting = overlayComposerIsResting({
+ composerMounted: false,
+ composerReportedResting: true,
+ });
+ expect(isResting).toBe(false);
+ expect(resolveComposerTimelineInset({ currentInset: 0, overlayHeight: 56, isResting })).toBe(
+ 56,
+ );
+ expect(overlayComposerIsResting({ composerMounted: true, composerReportedResting: true })).toBe(
+ true,
+ );
+ });
+});
+
describe("resolveComposerTimelineInset", () => {
it("follows the expanded overlay height", () => {
expect(
diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts
index 55cf7076cfd8..2eb7a9550a38 100644
--- a/apps/web/src/components/composerFooterLayout.ts
+++ b/apps/web/src/components/composerFooterLayout.ts
@@ -94,6 +94,31 @@ export function resolveComposerTimelineInset(input: {
: input.overlayHeight;
}
+/**
+ * Whether the overlay's composer is resting. Only a mounted composer can be:
+ * a status bar in its place (a native subagent thread) never is, even if the
+ * composer it replaced last reported resting.
+ */
+export function overlayComposerIsResting(input: {
+ composerMounted: boolean;
+ composerReportedResting: boolean;
+}): boolean {
+ return input.composerMounted && input.composerReportedResting;
+}
+
+/**
+ * The model picker lives in the composer controls. When they are hidden (a
+ * collapsed composer, or a native subagent whose settings belong to its
+ * provider) the picker cannot stay open, or a shortcut that opened it would
+ * pop it up later on another thread.
+ */
+export function composerModelPickerCanStayOpen(input: {
+ controlsHidden: boolean;
+ threadSettingsHidden: boolean;
+}): boolean {
+ return !input.controlsHidden && !input.threadSettingsHidden;
+}
+
export function shouldAnimateComposerRestingTransition(input: {
hasCompletedInitialLayout: boolean;
stateChanged: boolean;
diff --git a/docs/user/cursor.md b/docs/user/cursor.md
index 0afeb7dd8108..ad1a69ef956c 100644
--- a/docs/user/cursor.md
+++ b/docs/user/cursor.md
@@ -55,8 +55,8 @@ instructions must carry forward.
Cursor task events include an `agentId`, but the local SDK does not register that identifier as a
resumable agent: `Agent.resume()` returns `AgentNotFoundError`. The adapter therefore does not attach
-a provider thread to native task projections or advertise subagent thread IDs. Sending a new message
-from a projected child starts a new Cursor agent rather than pretending to resume the task runtime.
+a provider thread to native task projections or advertise subagent thread IDs. A projected child is
+read-only: send messages from the parent thread.
Runtime modes map to the controls the local SDK exposes: full access disables its sandbox, while
restricted modes and explicit non-full-access sandbox policies enable it. Explicit approval policy
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index 163ad91b02b0..369e85493d73 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -166,6 +166,9 @@ retry and the normal snooze choices.
On web and desktop, use **Agents** to follow work delegated to subagents.
+Subagent threads started by the agent can't take messages; message the parent
+thread instead.
+
Expand a tool call in the conversation to see its full command and output.
Summaries shorten shell wrappers and can still describe the latest call after it
finishes; the call's own result shows its status.
diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts
index 186a799ccb1c..4e8f48cc30aa 100644
--- a/packages/client-runtime/src/state/threadExecution.test.ts
+++ b/packages/client-runtime/src/state/threadExecution.test.ts
@@ -2,7 +2,9 @@ import {
TurnItemId,
NodeId,
MessageId,
+ ProviderInstanceId,
RunId,
+ ThreadId,
type OrchestrationV2ExecutionNode,
type OrchestrationV2RunStatus,
} from "@t3tools/contracts";
@@ -12,6 +14,9 @@ import { describe, expect, it } from "vite-plus/test";
import { v2Projection } from "./orchestrationV2TestFixtures.ts";
import {
deriveLatestThreadRun,
+ deriveProviderSubagentStatus,
+ formatModelSelectionEffort,
+ formatProviderSubagentStatus,
deriveRunlessWorkStartedAt,
deriveThreadActivityRun,
deriveThreadRuntime,
@@ -192,8 +197,21 @@ describe("deriveRunlessWorkStartedAt", () => {
completedAt: null,
});
+ const nativeChild = {
+ ...v2Projection,
+ thread: {
+ ...v2Projection.thread,
+ creationSource: "provider" as const,
+ lineage: {
+ parentThreadId: ThreadId.make("parent"),
+ relationshipToParent: "subagent" as const,
+ rootThreadId: ThreadId.make("parent"),
+ },
+ },
+ };
+
it("times a provider-native subagent from its runless root turn while it works", () => {
- const projection = { ...v2Projection, nodes: [rootTurn("running", later)] };
+ const projection = { ...nativeChild, nodes: [rootTurn("running", later)] };
expect(deriveRunlessWorkStartedAt(projection)).toBe("2026-07-28T10:05:00.000Z");
// The subagent has no run, so it stays unstoppable and unqueueable.
expect(deriveThreadRuntime(projection)).toBeNull();
@@ -202,13 +220,143 @@ describe("deriveRunlessWorkStartedAt", () => {
it.each(["completed", "cancelled", "failed", "interrupted", "idle"] as const)(
"is idle once the subagent is %s",
(status) => {
- expect(deriveRunlessWorkStartedAt({ ...v2Projection, nodes: [rootTurn(status)] })).toBe(null);
+ expect(deriveRunlessWorkStartedAt({ ...nativeChild, nodes: [rootTurn(status)] })).toBe(null);
},
);
- it("ignores root turns that belong to a run", () => {
+ it("ignores root turns that belong to a run, and threads the provider does not run", () => {
const owned = { ...rootTurn("running"), runId: RunId.make("run-1") };
- expect(deriveRunlessWorkStartedAt({ ...v2Projection, nodes: [owned] })).toBeNull();
+ expect(deriveRunlessWorkStartedAt({ ...nativeChild, nodes: [owned] })).toBeNull();
+ expect(
+ deriveRunlessWorkStartedAt({ ...v2Projection, nodes: [rootTurn("running")] }),
+ ).toBeNull();
+ });
+});
+
+describe("deriveProviderSubagentStatus", () => {
+ const root = {
+ id: NodeId.make("child-root"),
+ threadId: v2Projection.thread.id,
+ runId: null,
+ parentNodeId: null,
+ rootNodeId: NodeId.make("child-root"),
+ kind: "root_turn" as const,
+ status: "completed" as const,
+ countsForRun: false,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ runtimeRequestId: null,
+ checkpointScopeId: null,
+ startedAt: now,
+ completedAt: now,
+ };
+ const child = (creationSource: "provider" | "mcp") => ({
+ ...v2Projection,
+ thread: {
+ ...v2Projection.thread,
+ creationSource,
+ lineage: {
+ parentThreadId: ThreadId.make("parent"),
+ relationshipToParent: "subagent" as const,
+ rootThreadId: ThreadId.make("parent"),
+ },
+ },
+ nodes: [root],
+ });
+
+ it("reports the provider's own subagent from its runless root turn", () => {
+ expect(deriveProviderSubagentStatus(child("provider"))).toEqual({
+ status: "completed",
+ startedAt: "2026-07-28T10:00:00.000Z",
+ completedAt: "2026-07-28T10:00:00.000Z",
+ });
+ });
+
+ it("says how long the subagent has worked, or took", () => {
+ const startedAt = "2026-07-28T10:00:00.000Z";
+ const at = (iso: string) => Date.parse(iso);
+ expect(
+ formatProviderSubagentStatus(
+ { status: "running", startedAt, completedAt: null },
+ at("2026-07-28T10:01:05.400Z"),
+ ),
+ ).toBe("Working 1m 5s");
+ expect(
+ formatProviderSubagentStatus(
+ { status: "completed", startedAt, completedAt: "2026-07-28T10:00:34.000Z" },
+ at("2026-07-28T11:00:00.000Z"),
+ ),
+ ).toBe("Completed in 34s");
+ expect(
+ formatProviderSubagentStatus(
+ { status: "cancelled", startedAt, completedAt: "2026-07-28T10:00:34.000Z" },
+ 0,
+ ),
+ ).toBe("Cancelled");
+ expect(formatProviderSubagentStatus(null, 0)).toBe("Starting");
+ });
+
+ it("leaves T3 delegated tasks and ordinary threads alone", () => {
+ expect(deriveProviderSubagentStatus(child("mcp"))).toBeNull();
+ expect(deriveProviderSubagentStatus({ ...v2Projection, nodes: [root] })).toBeNull();
+ });
+});
+
+describe("formatModelSelectionEffort", () => {
+ const instanceId = ProviderInstanceId.make("claudeAgent");
+ const selection = (options?: ReadonlyArray<{ id: string; value: string }>) => ({
+ instanceId,
+ model: "claude-sonnet-5",
+ ...(options === undefined ? {} : { options }),
+ });
+ const catalog = (descriptor: { currentValue?: string }) => [
+ {
+ slug: "claude-sonnet-5",
+ name: "Claude Sonnet 5",
+ isCustom: false,
+ capabilities: {
+ optionDescriptors: [
+ {
+ id: "effort",
+ label: "Reasoning",
+ type: "select" as const,
+ options: [
+ { id: "medium", label: "Medium" },
+ { id: "high", label: "High", isDefault: true },
+ { id: "xhigh", label: "Extra High" },
+ ],
+ ...descriptor,
+ },
+ ],
+ },
+ },
+ ];
+
+ it("shows the model's default effort when the user never picked one", () => {
+ expect(formatModelSelectionEffort(selection(), catalog({}))).toBe("High");
+ });
+
+ it("names a stored effort the way the catalog does", () => {
+ expect(
+ formatModelSelectionEffort(selection([{ id: "effort", value: "xhigh" }]), catalog({})),
+ ).toBe("Extra High");
+ });
+
+ it("uses the descriptor's current value over the default", () => {
+ expect(formatModelSelectionEffort(selection(), catalog({ currentValue: "medium" }))).toBe(
+ "Medium",
+ );
+ });
+
+ it("shows nothing for a model the catalog does not describe", () => {
+ expect(formatModelSelectionEffort(selection([{ id: "effort", value: "high" }]))).toBeNull();
+ expect(
+ formatModelSelectionEffort(
+ { ...selection(), model: "claude-haiku-4-5" },
+ catalog({ currentValue: "medium" }),
+ ),
+ ).toBeNull();
});
});
diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts
index 3b99c597923f..3530df2d7d9b 100644
--- a/packages/client-runtime/src/state/threadExecution.ts
+++ b/packages/client-runtime/src/state/threadExecution.ts
@@ -4,9 +4,15 @@ import {
} from "@t3tools/shared/orchestrationV2ThreadError";
import {
isOrchestrationV2WorkActive,
+ isProviderNativeSubagentThread,
+ type ModelSelection,
+ type ServerProviderModel,
+ type OrchestrationV2ExecutionNode,
type OrchestrationV2ThreadProjection,
} from "@t3tools/contracts";
import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
+import { getProviderOptionCurrentLabel, getProviderOptionDescriptors } from "@t3tools/shared/model";
+import { formatDuration } from "@t3tools/shared/orchestrationTiming";
import * as DateTime from "effect/DateTime";
import {
@@ -79,13 +85,96 @@ export function deriveThreadActivityRun(
export function deriveRunlessWorkStartedAt(
projection: OrchestrationV2ThreadProjection,
): string | null {
+ const status = deriveProviderSubagentStatus(projection);
+ return status !== null && isOrchestrationV2WorkActive(status.status) ? status.startedAt : null;
+}
+
+export interface ProviderSubagentStatus {
+ readonly status: OrchestrationV2ExecutionNode["status"];
+ readonly startedAt: string | null;
+ readonly completedAt: string | null;
+}
+
+/**
+ * Status of a provider-native subagent thread (see
+ * isProviderNativeSubagentThread), read from its runless root turn. Null
+ * until that root turn arrives, and for every other thread.
+ */
+export function deriveProviderSubagentStatus(
+ projection: OrchestrationV2ThreadProjection,
+): ProviderSubagentStatus | null {
+ if (!isProviderNativeSubagentThread(projection.thread)) return null;
const node = projection.nodes.findLast(
- (candidate) =>
- candidate.kind === "root_turn" &&
- candidate.runId === null &&
- isOrchestrationV2WorkActive(candidate.status),
+ (candidate) => candidate.kind === "root_turn" && candidate.runId === null,
);
- return node?.startedAt == null ? null : DateTime.formatIso(node.startedAt);
+ if (node === undefined) return null;
+ return {
+ status: node.status,
+ startedAt: node.startedAt === null ? null : DateTime.formatIso(node.startedAt),
+ completedAt: node.completedAt === null ? null : DateTime.formatIso(node.completedAt),
+ };
+}
+
+// Option ids providers use for reasoning effort (Codex, Claude, Grok/ACP, OpenCode).
+const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "variant"] as const;
+
+/**
+ * The reasoning effort a thread's model runs at, resolved and named the way
+ * the composer's effort picker does: the stored choice when valid, else the
+ * descriptor's current value, else the model's default. Null when the
+ * provider catalog has no effort option for this model (a subagent on a
+ * model the catalog does not describe), rather than guessing.
+ */
+export function formatModelSelectionEffort(
+ selection: ModelSelection,
+ models: ReadonlyArray = [],
+): string | null {
+ const caps = models.find((model) => model.slug === selection.model)?.capabilities;
+ if (!caps) return null;
+ const descriptors = getProviderOptionDescriptors({ caps, selections: selection.options });
+ for (const id of REASONING_EFFORT_OPTION_IDS) {
+ const descriptor = descriptors.find((candidate) => candidate.id === id);
+ if (descriptor?.type !== "select") continue;
+ const label = getProviderOptionCurrentLabel(descriptor);
+ if (label) return label;
+ }
+ return null;
+}
+
+const SUBAGENT_STATUS_LABELS: Record = {
+ idle: "Idle",
+ pending: "Working",
+ running: "Working",
+ waiting: "Waiting",
+ completed: "Completed",
+ interrupted: "Interrupted",
+ failed: "Failed",
+ cancelled: "Cancelled",
+ rolled_back: "Cancelled",
+};
+
+/**
+ * One line for the read-only subagent bar: "Working 12s", "Completed in 34s",
+ * or just the status when no duration is known.
+ */
+export function formatProviderSubagentStatus(
+ status: ProviderSubagentStatus | null,
+ nowMs: number,
+): string {
+ if (status === null) return "Starting";
+ const label = SUBAGENT_STATUS_LABELS[status.status];
+ const live = isOrchestrationV2WorkActive(status.status);
+ if (!live && status.status !== "completed") return label;
+ const start = status.startedAt === null ? Number.NaN : Date.parse(status.startedAt);
+ const end = live
+ ? nowMs
+ : status.completedAt === null
+ ? Number.NaN
+ : Date.parse(status.completedAt);
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return label;
+ // Whole seconds: a ticking label must not flicker through tenths.
+ const elapsed = formatDuration(Math.max(1_000, Math.floor((end - start) / 1_000) * 1_000));
+ return live ? `${label} ${elapsed}` : `${label} in ${elapsed}`;
}
export function deriveThreadRuntime(
diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts
index 3ba1244729bd..8e1cbd51e5e4 100644
--- a/packages/contracts/src/orchestrationV2.ts
+++ b/packages/contracts/src/orchestrationV2.ts
@@ -418,6 +418,17 @@ export const OrchestrationV2AppThread = Schema.Struct({
});
export type OrchestrationV2AppThread = typeof OrchestrationV2AppThread.Type;
+/**
+ * A subagent the provider spawned on its own (Claude's Agent tool, Codex or
+ * Cursor native subagents). The provider owns its conversation, so it cannot
+ * take messages; T3 delegate_task children (`creationSource: "mcp"`) can.
+ */
+export function isProviderNativeSubagentThread(
+ thread: Pick,
+): boolean {
+ return thread.lineage.relationshipToParent === "subagent" && thread.creationSource === "provider";
+}
+
export const OrchestrationV2RunStatus = Schema.Literals([
"preparing",
"queued",