Skip to content
Merged
80 changes: 80 additions & 0 deletions apps/mobile/src/features/threads/ProviderSubagentBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {
formatProviderSubagentStatus,
type ProviderSubagentStatus,
} from "@t3tools/client-runtime/state/thread-execution";
import { isOrchestrationV2WorkActive } from "@t3tools/contracts";
import { useEffect, useState } from "react";
import { View } from "react-native";

import { AppText as Text } from "../../components/AppText";
import { ProviderIcon } from "../../components/ProviderIcon";
import { RequestActionButton } from "./RequestActionButton";

/**
* Replaces the composer on a provider-native subagent thread. The provider
* runs that conversation, so there is nothing to send; the bar says which
* model is working, for how long, and leads back to the parent.
*/
export function ProviderSubagentBar(props: {
/** Driver and catalog icon of the provider running the subagent. */
readonly provider: { readonly driver: string; readonly iconUrl?: string | undefined } | null;
readonly modelLabel: string;
/** Reasoning effort as the composer names it, when the subagent has one. */
readonly effortLabel: string | null;
/** Null until the subagent's root turn arrives. */
readonly status: ProviderSubagentStatus | null;
readonly onOpenParent: (() => void) | null;
}) {
const live = props.status !== null && isOrchestrationV2WorkActive(props.status.status);
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
if (!live) return;
const id = setInterval(() => setNowMs(Date.now()), 1_000);
return () => clearInterval(id);
}, [live]);
const statusLabel = formatProviderSubagentStatus(props.status, nowMs);
const modelDescription =
props.effortLabel === null ? props.modelLabel : `${props.modelLabel}, ${props.effortLabel}`;

return (
<View className="flex-row items-center gap-3 rounded-[20px] border border-border-subtle bg-card-alt py-2 pe-2 ps-4">
{/* Only the text is one element, so "Open parent" stays reachable. */}
<View
accessible
accessibilityLabel={`${modelDescription} subagent, ${statusLabel}. It runs on its own and cannot take messages.`}
className="min-w-0 flex-1 gap-0.5"
>
<View className="min-w-0 flex-row items-center gap-1.5">
{props.provider ? (
<ProviderIcon
iconUrl={props.provider.iconUrl}
provider={props.provider.driver}
size={16}
/>
) : null}
<Text numberOfLines={1} className="min-w-0 shrink font-t3-bold text-sm text-foreground">
{props.modelLabel}
</Text>
{props.effortLabel === null ? null : (
<Text
numberOfLines={1}
className="shrink-0 font-sans text-sm text-foreground-secondary"
>
{props.effortLabel}
</Text>
)}
</View>
<Text
numberOfLines={1}
className="font-sans text-xs text-foreground-secondary"
style={{ fontVariant: ["tabular-nums"] }}
>
{statusLabel} · Runs on its own
</Text>
</View>
{props.onOpenParent ? (
<RequestActionButton label="Open parent" tone="secondary" onPress={props.onOpenParent} />
) : null}
</View>
);
}
154 changes: 101 additions & 53 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ import {
} from "@t3tools/client-runtime/codex-artifact-templates";
import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
import {
formatModelSelectionEffort,
type ProviderSubagentStatus,
} from "@t3tools/client-runtime/state/thread-execution";
import { formatModelSlugName } from "@t3tools/shared/model";
import { isProviderNativeSubagentThread } from "@t3tools/contracts";
import type { QueuedRunEdit } from "../../state/queued-run-edit";
import type { FollowUpBehavior } from "../../lib/followUpBehavior";
import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch";
Expand Down Expand Up @@ -98,6 +104,7 @@ import { PendingApprovalCard } from "./PendingApprovalCard";
import { ComposerFeedback } from "./ComposerFeedback";
import { ComposerUsageLimits } from "./ComposerUsageLimits";
import { PendingUserInputCard } from "./PendingUserInputCard";
import { ProviderSubagentBar } from "./ProviderSubagentBar";
import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard";
import {
FLOATING_WORKING_CONTROL_COVERAGE,
Expand Down Expand Up @@ -138,6 +145,8 @@ export interface ThreadDetailScreenProps {
readonly activeWorkStartedAt: string | null;
/** The live work is a provider-native subagent's runless root turn. */
readonly runlessWorkActive?: boolean;
/** Set on a provider-native subagent thread, which shows status instead of a composer. */
readonly providerSubagentStatus?: ProviderSubagentStatus | null;
readonly isCompacting: boolean;
/**
* The server has not created this thread yet. "preparing" runs while the
Expand Down Expand Up @@ -731,6 +740,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const layoutVariant = props.layoutVariant ?? "compact";
const isSplitLayout = layoutVariant === "split";
const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
const providerSubagentProvider = props.serverConfig?.providers.find(
(provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId,
);
const providerSubagentCatalogModel = providerSubagentProvider?.models.find(
(model) => model.slug === props.selectedThread.modelSelection.model,
);
const workspaceContentWidth = useWorkspaceContentWidth();
const composerWidthStyle = useAnimatedStyle(() =>
isSplitLayout && workspaceContentWidth !== null
Expand Down Expand Up @@ -1215,59 +1230,92 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
: undefined
}
>
<>
<ThreadComposer
editorRef={composerEditorRef}
draftMessage={props.draftMessage}
draftAttachments={props.draftAttachments}
placeholder="Ask the repo agent, or run a command…"
contentMaxWidth={contentMaxWidth}
connectionState={props.connectionStateLabel}
environmentLabel={props.environmentLabel}
selectedThread={props.selectedThread}
hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
serverConfig={props.serverConfig}
queueCount={props.selectedThreadQueueCount}
activeThreadBusy={props.activeThreadBusy}
canStopThread={props.canStopThread}
environmentId={props.environmentId}
projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
// Follow-ups typed during setup wait in the draft: queueing
// them against a thread id the server may still reject
// would strand them in the outbox.
sendBlockedReason={
props.creationState?.kind === "preparing" ? "Starting the task…" : null
}
draftKey={props.composerDraftKey ?? undefined}
followUpBehavior={props.followUpBehavior}
canSteerActiveTurn={props.canSteerActiveTurn}
queuedEdit={
props.queuedRunEdit === null
? null
: {
existingAttachments: props.queuedRunEdit.existingAttachments,
saving: props.isSavingQueuedEdit,
onRemoveExistingAttachment: props.onRemoveQueuedEditAttachment,
}
}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
onPickDraftMedia={props.onPickDraftMedia}
onPickDraftFiles={props.onPickDraftFiles}
onNativePasteImages={props.onNativePasteImages}
onNativePasteText={props.onNativePasteText}
onRemoveDraftImage={props.onRemoveDraftImage}
onStopThread={props.onStopThread}
onSendMessage={handleSendMessage}
onShowUsageLimits={showUsageLimits}
canSwitchProvider={props.canSwitchThreadProvider}
onUpdateModelSelection={props.onUpdateThreadModelSelection}
onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
onExpandedChange={setComposerExpanded}
onEditorFocusChange={handleComposerFocusChange}
/>
</>
{isProviderNativeSubagentThread(props.selectedThread.source) ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High threads/ThreadDetailScreen.tsx:1233

Provider-native threads still expose ThreadFeed's pending-message edit action, but this branch renders only ProviderSubagentBar, so an edited outbox message has no composer in which to modify or send it. Pressing the visible ComposerQueuedEditBanner cancel action then clears the queued-edit draft and permanently discards the message. Disable pending-message editing and its queued-edit banner for provider-native threads, or render a composer that can complete the edit.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/ThreadDetailScreen.tsx around line 1233:

Provider-native threads still expose `ThreadFeed`'s pending-message edit action, but this branch renders only `ProviderSubagentBar`, so an edited outbox message has no composer in which to modify or send it. Pressing the visible `ComposerQueuedEditBanner` cancel action then clears the queued-edit draft and permanently discards the message. Disable pending-message editing and its queued-edit banner for provider-native threads, or render a composer that can complete the edit.

<View
className="self-center px-3 pt-1.5"
style={{
width: "100%",
maxWidth: contentMaxWidth,
paddingBottom: composerBottomInset + 6,
}}
>
<ProviderSubagentBar
provider={providerSubagentProvider ?? null}
modelLabel={
providerSubagentCatalogModel?.name ??
formatModelSlugName(props.selectedThread.modelSelection.model)
}
effortLabel={formatModelSelectionEffort(
props.selectedThread.modelSelection,
providerSubagentProvider?.models,
)}
status={props.providerSubagentStatus ?? null}
onOpenParent={
props.selectedThread.lineage.parentThreadId === null
? null
: () =>
navigation.navigate("Thread", {
environmentId: String(props.environmentId),
threadId: String(props.selectedThread.lineage.parentThreadId),
})
}
/>
</View>
) : (
<>
<ThreadComposer
editorRef={composerEditorRef}
draftMessage={props.draftMessage}
draftAttachments={props.draftAttachments}
placeholder="Ask the repo agent, or run a command…"
contentMaxWidth={contentMaxWidth}
connectionState={props.connectionStateLabel}
environmentLabel={props.environmentLabel}
selectedThread={props.selectedThread}
hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
serverConfig={props.serverConfig}
queueCount={props.selectedThreadQueueCount}
activeThreadBusy={props.activeThreadBusy}
canStopThread={props.canStopThread}
environmentId={props.environmentId}
projectCwd={props.threadCwd ?? props.projectWorkspaceRoot}
// Follow-ups typed during setup wait in the draft: queueing
// them against a thread id the server may still reject
// would strand them in the outbox.
sendBlockedReason={
props.creationState?.kind === "preparing" ? "Starting the task…" : null
}
draftKey={props.composerDraftKey ?? undefined}
followUpBehavior={props.followUpBehavior}
canSteerActiveTurn={props.canSteerActiveTurn}
queuedEdit={
props.queuedRunEdit === null
? null
: {
existingAttachments: props.queuedRunEdit.existingAttachments,
saving: props.isSavingQueuedEdit,
onRemoveExistingAttachment: props.onRemoveQueuedEditAttachment,
}
}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
onPickDraftMedia={props.onPickDraftMedia}
onPickDraftFiles={props.onPickDraftFiles}
onNativePasteImages={props.onNativePasteImages}
onNativePasteText={props.onNativePasteText}
onRemoveDraftImage={props.onRemoveDraftImage}
onStopThread={props.onStopThread}
onSendMessage={handleSendMessage}
onShowUsageLimits={showUsageLimits}
canSwitchProvider={props.canSwitchThreadProvider}
onUpdateModelSelection={props.onUpdateThreadModelSelection}
onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
onExpandedChange={setComposerExpanded}
onEditorFocusChange={handleComposerFocusChange}
/>
</>
)}
</View>
</View>
</Animated.View>
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1006,6 +1006,7 @@ function ThreadRouteContent(
}
isCompacting={composer.isCompacting}
runlessWorkActive={composer.runlessWorkActive}
providerSubagentStatus={composer.providerSubagentStatus}
creationState={creationState}
setupWorkingStartedAt={
composer.activeWorkStartedAt !== null &&
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ComposerTextPaste } from "../native/T3ComposerEditor.types";
import { useAtomValue } from "@effect/atom-react";
import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
import {
deriveProviderSubagentStatus,
deriveRunlessWorkStartedAt,
deriveThreadActivityRun,
deriveThreadRuntime,
Expand Down Expand Up @@ -414,6 +415,14 @@ export function useThreadComposerState() {
}, [selectedThreadActivityRun, runlessWorkStartedAt, selectedThreadRuntime, selectedThreadShell]);
const runlessWorkActive = runlessWorkStartedAt !== null;

const providerSubagentStatus = useMemo(
() =>
selectedThreadProjection
? deriveProviderSubagentStatus(selectedThreadProjection.projection)
: null,
[selectedThreadProjection],
);

// The run can start, or be cancelled from another client, while its message
// is open in the composer. Leave edit mode rather than saving into a run the
// server will refuse, and keep whatever was typed if there is room for it.
Expand Down Expand Up @@ -1036,6 +1045,7 @@ export function useThreadComposerState() {
dispatchingQueuedMessageId,
activeWorkStartedAt,
runlessWorkActive,
providerSubagentStatus,
isCompacting,
draftMessage,
draftAttachments,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
EnvironmentId,
EventId,
IsoDateTime,
isProviderNativeSubagentThread,
MessageId,
type ModelSelection,
type OrchestrationV2ProviderCapabilities,
Expand Down Expand Up @@ -3212,6 +3213,11 @@ describe("orchestrator MCP toolkit", () => {
delegated.resultContextTransferId,
);

// Delegated children are subagent threads too, but T3 owns them, so
// they keep taking follow-ups (provider-native children do not).
const delegatedChild = yield* orchestrator.getThreadProjection(delegated.childThreadId);
expect(delegatedChild.thread.lineage.relationshipToParent).toBe("subagent");
expect(isProviderNativeSubagentThread(delegatedChild.thread)).toBe(false);
const followupStartSequence = yield* orchestrator.getThreadEventSequence(
delegated.childThreadId,
);
Expand Down
30 changes: 29 additions & 1 deletion apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import {
type ChatAttachment,
CommandId,
isProviderNativeSubagentThread,
MessageId,
type ModelSelection,
OrchestrationV2Command,
Expand Down Expand Up @@ -155,6 +156,15 @@ export class OrchestratorProviderAdapterError extends Schema.TaggedError<Orchest
}
}

export class OrchestratorSubagentThreadReadOnlyError extends Schema.TaggedError<OrchestratorSubagentThreadReadOnlyError>()(
"OrchestratorSubagentThreadReadOnlyError",
{ commandId: CommandId, threadId: ThreadId },
) {
override get message(): string {
return "This subagent is run by its provider and cannot take messages. Message the parent thread instead.";
}
}

export class OrchestratorCommandPreviouslyRejectedError extends Schema.TaggedError<OrchestratorCommandPreviouslyRejectedError>()(
"OrchestratorCommandPreviouslyRejectedError",
{
Expand Down Expand Up @@ -203,6 +213,7 @@ export const OrchestratorV2Error = Schema.Union([
OrchestratorProviderAdapterError,
OrchestratorCommandPreviouslyRejectedError,
OrchestratorCommandIdConflictError,
OrchestratorSubagentThreadReadOnlyError,
]);
export type OrchestratorV2Error = typeof OrchestratorV2Error.Type;

Expand Down Expand Up @@ -8767,9 +8778,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
case "provider-session.detach":
yield* dispatchProviderSessionDetach(command, events, effects);
break;
case "message.dispatch":
case "message.dispatch": {
// The provider owns a native subagent's conversation, so a sent
// message has nowhere to go. Only sends are refused: answers to the
// subagent's own questions reuse dispatchMessage and must still land.
const thread = yield* projectionStore
.getThread(command.threadId)
.pipe(
Effect.mapError(
(cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
),
);
if (isProviderNativeSubagentThread(thread)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High orchestration-v2/Orchestrator.ts:8792

Pre-existing queued sends still execute on provider-native subagent threads, bypassing the new read-only protection. handleTerminalRun unconditionally calls startNextQueuedRun when an active run finishes, and queue.resume can do the same, so queued message.dispatch runs created before this guard reaches dispatchOnce are forwarded to the provider. Enforce the read-only check when promoting queued runs (and resume), or cancel those queued sends during migration.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Orchestrator.ts around line 8792:

Pre-existing queued sends still execute on provider-native subagent threads, bypassing the new read-only protection. `handleTerminalRun` unconditionally calls `startNextQueuedRun` when an active run finishes, and `queue.resume` can do the same, so queued `message.dispatch` runs created before this guard reaches `dispatchOnce` are forwarded to the provider. Enforce the read-only check when promoting queued runs (and resume), or cancel those queued sends during migration.

return yield* new OrchestratorSubagentThreadReadOnlyError({
commandId: command.commandId,
threadId: command.threadId,
});
}
yield* dispatchMessage(command, events, effects);
break;
}
case "notification.delivery.accept":
yield* dispatchNotificationAccepted(command, events);
break;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration-v2/ThreadMessageIntake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function dispatchWasNotAccepted(
case "OrchestratorProviderAdapterError":
case "OrchestratorCommandPreviouslyRejectedError":
case "OrchestratorCommandIdConflictError":
case "OrchestratorSubagentThreadReadOnlyError":
return true;
default:
return false;
Expand Down
Loading
Loading