Skip to content
Open
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
13 changes: 11 additions & 2 deletions apps/mobile/src/features/threads/SubagentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type SubagentRowSubagent = Pick<
| "status"
| "progress"
| "result"
| "remoteChild"
>;

/**
Expand All @@ -37,8 +38,10 @@ export function SubagentRow(props: {
readonly environmentId: EnvironmentId;
readonly subagent: SubagentRowSubagent;
readonly elapsed: ReactNode;
/** The task runs in a linked environment this app is connected to. */
readonly canOpenRemote?: boolean;
}) {
const presentation = resolveSubagentRowPresentation(props.subagent);
const presentation = resolveSubagentRowPresentation(props.subagent, props.canOpenRemote);
const detail = subagentCardDetail(presentation.detail);
return (
<View className="flex-row gap-3">
Expand Down Expand Up @@ -69,6 +72,11 @@ export function SubagentRow(props: {
>
{presentation.statusLabel}
</Text>
{presentation.runsOn !== null ? (
<Text numberOfLines={1} className="shrink text-xs text-foreground-muted">
on {presentation.runsOn}
</Text>
) : null}
</View>
{props.elapsed}
{presentation.canOpenThread ? (
Expand Down Expand Up @@ -98,7 +106,8 @@ function SubagentMetadata(props: {
readonly subagent: SubagentRowSubagent;
}) {
const { environmentId, subagent } = props;
const config = useEnvironmentServerConfig(environmentId);
// A task in a linked environment runs a provider instance of that environment.
const config = useEnvironmentServerConfig(subagent.remoteChild?.environmentId ?? environmentId);
const provider = config?.providers.find(
(candidate) => candidate.instanceId === subagent.providerInstanceId,
);
Expand Down
26 changes: 18 additions & 8 deletions apps/mobile/src/features/threads/ThreadAgentsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import { AppText as Text } from "../../components/AppText";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { environmentThreadDetails } from "../../state/threads";
import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
import { useEnvironments } from "../../state/environments";
import { SubagentRow } from "./SubagentRow";
import { subagentThreadTarget } from "./threadAgentsPresentation";

const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);

Expand All @@ -39,14 +41,14 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) {
const subagents = turn?.subagents ?? [];
const hasLiveAgent = (turn?.liveCount ?? 0) > 0;

const openChildThread = (childThreadId: ThreadId) => {
const openChildThread = (child: { environmentId: EnvironmentId; threadId: ThreadId }) => {
void Haptics.selectionAsync();
// Replace rather than push: the sheet is a leaf, and the child thread
// belongs in the workspace stack where Home's back button expects it.
navigation.dispatch(
StackActions.replace("Thread", {
environmentId: target.environmentId,
threadId: childThreadId,
environmentId: child.environmentId,
threadId: child.threadId,
}),
);
};
Expand Down Expand Up @@ -122,26 +124,34 @@ function AgentRow(props: {
readonly environmentId: EnvironmentId;
readonly subagent: OrchestrationV2Subagent;
readonly tickSeconds: boolean;
readonly onOpen: (childThreadId: ThreadId) => void;
readonly onOpen: (child: { environmentId: EnvironmentId; threadId: ThreadId }) => void;
}) {
const { subagent } = props;
const childThreadId = subagent.childThreadId;
const { presentationById } = useEnvironments();
const target = subagentThreadTarget(subagent, props.environmentId, (id) =>
presentationById.has(id),
);

const row = (
<View className="border-b border-border py-3.5">
<SubagentRow
environmentId={props.environmentId}
subagent={subagent}
canOpenRemote={target !== null}
elapsed={<AgentElapsed subagent={subagent} tickSeconds={props.tickSeconds} />}
/>
</View>
);

if (childThreadId === null) {
if (target === null) {
return (
<View
accessible
accessibilityHint="Provider-managed agent. Its work appears in the transcript."
accessibilityHint={
subagent.remoteChild === undefined
? "Provider-managed agent. Its work appears in the transcript."
: `Runs on ${subagent.remoteChild.label}. Connect to it to open its thread.`
}
>
{row}
</View>
Expand All @@ -152,7 +162,7 @@ function AgentRow(props: {
<Pressable
accessibilityRole="link"
accessibilityHint="Opens this agent's thread"
onPress={() => props.onOpen(childThreadId)}
onPress={() => props.onOpen(target)}
className="active:opacity-70"
>
{row}
Expand Down
19 changes: 18 additions & 1 deletion apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import { vcsEnvironment } from "../../state/vcs";
import { EmptyState } from "../../components/EmptyState";
import { LoadingScreen } from "../../components/LoadingScreen";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { useThreadShell } from "../../state/entities";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { connectionTone } from "../connection/connectionTone";
import {
Expand Down Expand Up @@ -391,10 +393,25 @@ function ThreadRouteContent(

/* ─── Native header theming ──────────────────────────────────────── */
const usesNativeHeaderGlass = NATIVE_LIQUID_GLASS_SUPPORTED;
const delegatedFrom = selectedThread?.delegatedFrom ?? null;
const delegatedFromRef = useMemo(
() =>
delegatedFrom === null
? null
: scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId),
[delegatedFrom],
);
// The title it had at delegation, until this client sees the parent itself.
const delegatedFromThread = useThreadShell(delegatedFromRef);
const headerSubtitle = [
selectedThreadProject?.title ?? null,
selectedEnvironmentConnection?.environmentLabel ?? null,
selectedThread?.linkOrigin ? `From ${selectedThread.linkOrigin.label}` : null,
// A linked environment's delegated task names its parent; other linked work its origin.
delegatedFrom !== null
? `Subagent of ${delegatedFromThread?.title ?? delegatedFrom.title}`
: selectedThread?.linkOrigin
? `From ${selectedThread.linkOrigin.label}`
: null,
]
.filter(Boolean)
.join(" · ");
Expand Down
33 changes: 25 additions & 8 deletions apps/mobile/src/features/threads/thread-subagent-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import type { ThreadFeedActivity } from "../../lib/threadActivity";
import { serverEnvironment } from "../../state/server";
import { environmentThreadDetails } from "../../state/threads";
import { subagentCardElapsed } from "./subagent-card-presentation";
import { useEnvironments } from "../../state/environments";
import { useServerConfigs } from "../../state/entities";
import { SubagentRow } from "./SubagentRow";
import { subagentThreadTarget } from "./threadAgentsPresentation";
import { WorkLogBlock } from "./work-log-layout";

type SubagentItem = Extract<OrchestrationV2TurnItem, { type: "subagent" }>;
Expand Down Expand Up @@ -78,6 +81,7 @@ export function ThreadSubagentGroup(props: {
environmentThreadDetails.threadAtom(scopeThreadRef(props.environmentId, members[0]!.threadId)),
(thread) => thread?.projection.subagents,
);
const { presentationById } = useEnvironments();
const agents = members.map((item) => {
const live = liveAgents?.find((agent) => agent.id === item.subagentId);
return {
Expand All @@ -95,8 +99,13 @@ export function ThreadSubagentGroup(props: {
const label = `${agents.length} subagents`;
const summary = summarizeSubagentStatuses(agents.map((agent) => agent.status));
const expanded = props.expandedRows[props.anchorKey] ?? false;
const serverConfigs = useServerConfigs();
// A task in a linked environment runs a provider instance of that environment.
const iconUrl = (item: SubagentItem) =>
config?.providers.find((provider) => provider.instanceId === item.providerInstanceId)?.iconUrl;
(item.remoteChild === undefined
? config
: serverConfigs.get(item.remoteChild.environmentId)
)?.providers.find((provider) => provider.instanceId === item.providerInstanceId)?.iconUrl;
return (
<WorkLogBlock>
{grouped ? (
Expand Down Expand Up @@ -147,24 +156,31 @@ export function ThreadSubagentGroup(props: {
{!grouped || expanded ? (
<View className="mb-1 gap-px rounded-xl border border-border bg-card/30 p-1">
{agents.map((agent) => {
const threadId = agent.childThreadId;
// A task in a linked environment opens there, if this app is connected to it.
const target = subagentThreadTarget(agent, props.environmentId, (id) =>
presentationById.has(id),
);
return (
<Pressable
key={agent.item.id}
accessible
accessibilityRole={threadId === null ? undefined : "link"}
accessibilityRole={target === null ? undefined : "link"}
accessibilityHint={
threadId === null ? "Provider-managed agent" : "Opens this agent's thread"
target !== null
? "Opens this agent's thread"
: agent.remoteChild !== undefined
? `Runs on ${agent.remoteChild.label}`
: "Provider-managed agent"
}
disabled={threadId === null}
disabled={target === null}
onPress={() => {
// Push, not navigate: navigate reuses this Thread route, so back
// would skip the parent thread.
if (threadId !== null)
if (target !== null)
navigation.dispatch(
StackActions.push("Thread", {
environmentId: String(props.environmentId),
threadId: String(threadId),
environmentId: String(target.environmentId),
threadId: String(target.threadId),
}),
);
}}
Expand All @@ -173,6 +189,7 @@ export function ThreadSubagentGroup(props: {
<SubagentRow
environmentId={props.environmentId}
subagent={agent}
canOpenRemote={target !== null}
elapsed={<SubagentElapsed agents={[agent]} />}
/>
</Pressable>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vite-plus/test";

import { resolveSubagentRowPresentation } from "./threadAgentsPresentation";
import { resolveSubagentRowPresentation, subagentThreadTarget } from "./threadAgentsPresentation";

const base = {
title: null,
Expand Down Expand Up @@ -59,6 +59,31 @@ describe("resolveSubagentRowPresentation", () => {
);
});

it("opens a task in a linked environment there, only when this app knows it", () => {
const remote = {
...base,
childThreadId: null,
remoteChild: {
environmentId: "environment-box" as never,
threadId: "thread-on-box" as never,
label: "Box",
},
};
expect(resolveSubagentRowPresentation(remote).runsOn).toBe("Box");
expect(resolveSubagentRowPresentation(remote, false).canOpenThread).toBe(false);
expect(resolveSubagentRowPresentation(remote, true).canOpenThread).toBe(true);
const here = "environment-here" as never;
expect(subagentThreadTarget(remote, here, () => false)).toBeNull();
expect(subagentThreadTarget(remote, here, (id) => id === "environment-box")).toEqual({
environmentId: "environment-box",
threadId: "thread-on-box",
});
expect(subagentThreadTarget(base, here, () => false)).toEqual({
environmentId: here,
threadId: "thread-child",
});
});

it("uses the status label when there is nothing to report yet", () => {
const row = resolveSubagentRowPresentation({ ...base, status: "pending" });

Expand Down
30 changes: 27 additions & 3 deletions apps/mobile/src/features/threads/threadAgentsPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
subagentDetailPreview,
} from "@t3tools/client-runtime/state/subagent-display";
import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime";
import type { OrchestrationV2Subagent } from "@t3tools/contracts";
import type { EnvironmentId, OrchestrationV2Subagent, ThreadId } from "@t3tools/contracts";

const PROMPT_TITLE_LIMIT = 80;

Expand All @@ -18,6 +18,27 @@ export interface SubagentRowPresentation {
readonly live: boolean;
/** Provider-native tasks have no thread of their own to open. */
readonly canOpenThread: boolean;
/** The linked environment the task runs in, when it runs in one. */
readonly runsOn: string | null;
}

/**
* Where an agent's thread opens: here, or in the linked environment that runs
* it. Null when there is no thread, or this app is not connected to that
* environment.
*/
export function subagentThreadTarget(
subagent: Pick<OrchestrationV2Subagent, "childThreadId" | "remoteChild">,
environmentId: EnvironmentId,
isKnownEnvironment: (environmentId: EnvironmentId) => boolean,
): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null {
if (subagent.childThreadId !== null) {
return { environmentId, threadId: subagent.childThreadId };
}
const remote = subagent.remoteChild;
return remote !== undefined && isKnownEnvironment(remote.environmentId)
? { environmentId: remote.environmentId, threadId: remote.threadId }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: null;
}

function rowTitle(subagent: Pick<OrchestrationV2Subagent, "title" | "prompt">): string {
Expand Down Expand Up @@ -60,8 +81,9 @@ function rowStatusLabel(status: OrchestrationV2Subagent["status"]): string {
export function resolveSubagentRowPresentation(
subagent: Pick<
OrchestrationV2Subagent,
"title" | "prompt" | "status" | "progress" | "result" | "childThreadId"
"title" | "prompt" | "status" | "progress" | "result" | "childThreadId" | "remoteChild"
>,
canOpenRemote = false,
): SubagentRowPresentation {
const live = isActiveSubagentStatus(subagent.status);
return {
Expand All @@ -70,6 +92,8 @@ export function resolveSubagentRowPresentation(
statusLabel: rowStatusLabel(subagent.status),
tone: rowTone(subagent.status),
live,
canOpenThread: subagent.childThreadId !== null,
canOpenThread:
subagent.childThreadId !== null || (subagent.remoteChild !== undefined && canOpenRemote),
runsOn: subagent.remoteChild?.label ?? null,
};
}
2 changes: 1 addition & 1 deletion apps/server/src/mcp/McpToolAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from "../orchestration-v2/DispatchModeLimit.ts";
import { assertNotLinked, assertSameLink, callerLinkOrigin } from "./linkOrigin.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
import { resolveInteractionMode, resolveRuntimeMode } from "./OrchestratorMcpService.ts";
import { resolveInteractionMode, resolveRuntimeMode } from "./delegatedTaskModes.ts";
import {
assertFullAccess,
assertLiveCaller,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as RemoteDelegation from "../peer/RemoteDelegation.ts";
import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
import type * as McpInvocationContext from "./McpInvocationContext.ts";
import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
Expand Down Expand Up @@ -151,6 +152,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a
} satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(RemoteDelegation.RemoteDelegation)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
list: () => Effect.succeed({ tasks: [] }),
} satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
Expand Down Expand Up @@ -216,6 +218,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run
} satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(RemoteDelegation.RemoteDelegation)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
list: () => Effect.succeed({ tasks: [] }),
} satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
Expand Down Expand Up @@ -329,6 +332,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy
} satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(RemoteDelegation.RemoteDelegation)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
list: () => Effect.succeed({ tasks: [] }),
} satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
Expand Down Expand Up @@ -452,6 +456,7 @@ it("readThread and sendToThread reach threads in other projects", async () => {
} satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(RemoteDelegation.RemoteDelegation)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
list: () =>
Effect.succeed({
Expand Down
Loading
Loading