Skip to content

Commit 594f1ce

Browse files
juliusmarmingeclaudeLenoridev
authored
feat(server): delegate_task to a linked environment (#16719)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Lenori <122464369+Lenoridev@users.noreply.github.com>
1 parent a6676f9 commit 594f1ce

67 files changed

Lines changed: 5156 additions & 322 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/mobile/src/features/home/useThreadListActions.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,10 @@ vi.mock("../../state/thread-order", () => ({
114114
},
115115
}));
116116
vi.mock("../../state/threads", () => ({
117-
environmentThreadShells: { threadShellsAtom: "thread-shells" },
117+
environmentThreadShells: {
118+
threadShellsAtom: "thread-shells",
119+
navigationThreadShellsAtom: "thread-shells",
120+
},
118121
threadEnvironment: Object.fromEntries(
119122
[
120123
"archive",

‎apps/mobile/src/features/home/useThreadListActions.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,7 @@ export function useThreadListActions(): {
611611
const moveThread = async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => {
612612
if (!checkThreadOperationPermission(thread, "Could not move thread")) return false;
613613
if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false;
614-
const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
614+
const shells = appAtomRegistry.get(environmentThreadShells.navigationThreadShellsAtom);
615615
const current = shells.find(
616616
(row) => row.id === thread.id && row.environmentId === thread.environmentId,
617617
);

‎apps/mobile/src/features/threads/SubagentRow.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,17 @@ type SubagentRowSubagent = Pick<
2626
| "status"
2727
| "progress"
2828
| "result"
29+
| "remoteChild"
2930
>;
3031

3132
export function SubagentRow(props: {
3233
readonly environmentId: EnvironmentId;
3334
readonly subagent: SubagentRowSubagent;
3435
readonly elapsed: ReactNode;
36+
/** The task runs in a linked environment this app is connected to. */
37+
readonly canOpenRemote?: boolean;
3538
}) {
36-
const presentation = resolveSubagentRowPresentation(props.subagent);
39+
const presentation = resolveSubagentRowPresentation(props.subagent, props.canOpenRemote);
3740
const detail = subagentCardDetail(presentation.detail);
3841
return (
3942
<View className="flex-row gap-3">
@@ -64,6 +67,11 @@ export function SubagentRow(props: {
6467
>
6568
{presentation.statusLabel}
6669
</Text>
70+
{presentation.runsOn !== null ? (
71+
<Text numberOfLines={1} className="shrink text-xs text-foreground-muted">
72+
on {presentation.runsOn}
73+
</Text>
74+
) : null}
6775
</View>
6876
{props.elapsed}
6977
{presentation.canOpenThread ? (
@@ -92,7 +100,8 @@ function SubagentMetadata(props: {
92100
readonly subagent: SubagentRowSubagent;
93101
}) {
94102
const { environmentId, subagent } = props;
95-
const config = useEnvironmentServerConfig(environmentId);
103+
// A task in a linked environment runs a provider instance of that environment.
104+
const config = useEnvironmentServerConfig(subagent.remoteChild?.environmentId ?? environmentId);
96105
const provider = config?.providers.find(
97106
(candidate) => candidate.instanceId === subagent.providerInstanceId,
98107
);

‎apps/mobile/src/features/threads/ThreadAgentsSheet.tsx‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import { AppText as Text } from "../../components/ui/AppText";
2020
import { environmentThreadDetails } from "../../state/threads";
2121
import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
2222
import { NativeHeader, NativeScreen } from "../../native/ThemedScreenStack";
23+
import { useEnvironments } from "../../state/environments";
2324
import { SubagentRow } from "./SubagentRow";
2425
import { useVisibleSecondClock } from "./use-visible-second-clock";
2526
import { ScreenContent } from "../../components/ui/ScreenContent";
2627
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
28+
import { subagentThreadTarget } from "./threadAgentsPresentation";
2729

2830
import { useOpenThreadInspector } from "./thread-inspector-navigation";
2931

@@ -53,21 +55,21 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) {
5355
const subagents = turn?.subagents ?? [];
5456
const hasLiveAgent = (turn?.liveCount ?? 0) > 0;
5557

56-
const openChildThread = (childThreadId: ThreadId) => {
58+
const openChildThread = (child: { environmentId: EnvironmentId; threadId: ThreadId }) => {
5759
void Haptics.selectionAsync();
5860
if (
5961
openInspector(target, {
6062
type: "thread",
61-
environmentId: target.environmentId,
62-
threadId: childThreadId,
63+
environmentId: child.environmentId,
64+
threadId: child.threadId,
6365
})
6466
)
6567
return;
6668
// Replace this leaf sheet so the child enters the workspace stack expected by Home's Back button.
6769
navigation.dispatch(
6870
StackActions.replace("Thread", {
69-
environmentId: target.environmentId,
70-
threadId: childThreadId,
71+
environmentId: child.environmentId,
72+
threadId: child.threadId,
7173
}),
7274
);
7375
};
@@ -143,26 +145,34 @@ function AgentRow(props: {
143145
readonly environmentId: EnvironmentId;
144146
readonly subagent: OrchestrationV2Subagent;
145147
readonly tickSeconds: boolean;
146-
readonly onOpen: (childThreadId: ThreadId) => void;
148+
readonly onOpen: (child: { environmentId: EnvironmentId; threadId: ThreadId }) => void;
147149
}) {
148150
const { subagent } = props;
149-
const childThreadId = subagent.childThreadId;
151+
const { presentationById } = useEnvironments();
152+
const target = subagentThreadTarget(subagent, props.environmentId, (id) =>
153+
presentationById.has(id),
154+
);
150155

151156
const row = (
152157
<View className="border-b border-border py-3.5">
153158
<SubagentRow
154159
environmentId={props.environmentId}
155160
subagent={subagent}
161+
canOpenRemote={target !== null}
156162
elapsed={<AgentElapsed subagent={subagent} tickSeconds={props.tickSeconds} />}
157163
/>
158164
</View>
159165
);
160166

161-
if (childThreadId === null) {
167+
if (target === null) {
162168
return (
163169
<View
164170
accessible
165-
accessibilityHint="Provider-managed agent. Its work appears in the transcript."
171+
accessibilityHint={
172+
subagent.remoteChild === undefined
173+
? "Provider-managed agent. Its work appears in the transcript."
174+
: `Runs on ${subagent.remoteChild.label}. Connect to it to open its thread.`
175+
}
166176
>
167177
{row}
168178
</View>
@@ -173,7 +183,7 @@ function AgentRow(props: {
173183
<Pressable
174184
accessibilityRole="link"
175185
accessibilityHint="Opens this agent's thread"
176-
onPress={() => props.onOpen(childThreadId)}
186+
onPress={() => props.onOpen(target)}
177187
className="active:opacity-70"
178188
>
179189
{row}

‎apps/mobile/src/features/threads/ThreadRouteScreen.tsx‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import { vcsEnvironment } from "../../state/vcs";
4343
import { EmptyState } from "../../components/ui/EmptyState";
4444
import { LoadingScreen } from "../../components/LoadingScreen";
4545
import { scopedThreadKey } from "../../lib/scopedEntities";
46+
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
47+
import { useThreadShell } from "../../state/entities";
4648
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
4749
import { connectionTone } from "../connection/connectionTone";
4850
import {
@@ -486,10 +488,22 @@ function ThreadRouteContent(
486488
: null;
487489

488490
const usesNativeHeaderGlass = NATIVE_LIQUID_GLASS_SUPPORTED;
491+
const delegatedFrom = selectedThread?.delegatedFrom ?? null;
492+
const delegatedFromRef =
493+
delegatedFrom === null
494+
? null
495+
: scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId);
496+
// The title it had at delegation, until this client sees the parent itself.
497+
const delegatedFromThread = useThreadShell(delegatedFromRef);
489498
const headerSubtitle = [
490499
selectedThreadProject?.title ?? null,
491500
selectedEnvironmentConnection?.environmentLabel ?? null,
492-
selectedThread?.linkOrigin ? `From ${selectedThread.linkOrigin.label}` : null,
501+
// A linked environment's delegated task names its parent; other linked work its origin.
502+
delegatedFrom !== null
503+
? `Subagent of ${delegatedFromThread?.title ?? delegatedFrom.title}`
504+
: selectedThread?.linkOrigin
505+
? `From ${selectedThread.linkOrigin.label}`
506+
: null,
493507
]
494508
.filter(Boolean)
495509
.join(" · ");

‎apps/mobile/src/features/threads/thread-subagent-group.tsx‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ import type { ThreadFeedActivity } from "../../lib/threadActivity";
1818
import { serverEnvironment } from "../../state/server";
1919
import { environmentThreadDetails } from "../../state/threads";
2020
import { subagentCardElapsed } from "./subagent-card-presentation";
21+
import { useEnvironments } from "../../state/environments";
22+
import { useServerConfigs } from "../../state/entities";
2123
import { SubagentRow } from "./SubagentRow";
2224
import { useVisibleSecondClock } from "./use-visible-second-clock";
25+
import { subagentThreadTarget } from "./threadAgentsPresentation";
2326
import { WorkLogBlock } from "./work-log-layout";
2427

2528
import { useOpenThreadInspector } from "./thread-inspector-navigation";
@@ -68,6 +71,7 @@ export function ThreadSubagentGroup(props: {
6871
environmentThreadDetails.threadAtom(scopeThreadRef(props.environmentId, members[0]!.threadId)),
6972
(thread) => thread?.projection.subagents,
7073
);
74+
const { presentationById } = useEnvironments();
7175
const agents = members.map((item) => {
7276
const live = liveAgents?.find((agent) => agent.id === item.subagentId);
7377
return {
@@ -85,8 +89,13 @@ export function ThreadSubagentGroup(props: {
8589
const label = `${agents.length} subagents`;
8690
const summary = summarizeSubagentStatuses(agents.map((agent) => agent.status));
8791
const expanded = props.expandedRows[props.anchorKey] ?? false;
92+
const serverConfigs = useServerConfigs();
93+
// A task in a linked environment runs a provider instance of that environment.
8894
const iconUrl = (item: SubagentItem) =>
89-
config?.providers.find((provider) => provider.instanceId === item.providerInstanceId)?.iconUrl;
95+
(item.remoteChild === undefined
96+
? config
97+
: serverConfigs.get(item.remoteChild.environmentId)
98+
)?.providers.find((provider) => provider.instanceId === item.providerInstanceId)?.iconUrl;
9099
return (
91100
<WorkLogBlock>
92101
{grouped ? (
@@ -137,29 +146,40 @@ export function ThreadSubagentGroup(props: {
137146
{!grouped || expanded ? (
138147
<View className="mb-1 gap-px rounded-xl border border-border bg-card/30 p-1">
139148
{agents.map((agent) => {
140-
const threadId = agent.childThreadId;
149+
// A task in a linked environment opens there, if this app is connected to it.
150+
const target = subagentThreadTarget(agent, props.environmentId, (id) =>
151+
presentationById.has(id),
152+
);
141153
return (
142154
<Pressable
143155
key={agent.item.id}
144156
accessible
145-
accessibilityRole={threadId === null ? undefined : "link"}
157+
accessibilityRole={target === null ? undefined : "link"}
146158
accessibilityHint={
147-
threadId === null ? "Provider-managed agent" : "Opens this agent's thread"
159+
target !== null
160+
? "Opens this agent's thread"
161+
: agent.remoteChild !== undefined
162+
? `Runs on ${agent.remoteChild.label}`
163+
: "Provider-managed agent"
148164
}
149-
disabled={threadId === null}
165+
disabled={target === null}
150166
onPress={() => {
151-
if (threadId === null) return;
167+
if (target === null) return;
152168
if (
153169
openInspector(
154170
{ environmentId: props.environmentId, threadId: members[0]!.threadId },
155-
{ type: "thread", environmentId: props.environmentId, threadId },
171+
{
172+
type: "thread",
173+
environmentId: target.environmentId,
174+
threadId: target.threadId,
175+
},
156176
)
157177
)
158178
return;
159179
navigation.dispatch(
160180
StackActions.push("Thread", {
161-
environmentId: String(props.environmentId),
162-
threadId: String(threadId),
181+
environmentId: String(target.environmentId),
182+
threadId: String(target.threadId),
163183
}),
164184
);
165185
}}
@@ -168,6 +188,7 @@ export function ThreadSubagentGroup(props: {
168188
<SubagentRow
169189
environmentId={props.environmentId}
170190
subagent={agent}
191+
canOpenRemote={target !== null}
171192
elapsed={<SubagentElapsed agents={[agent]} />}
172193
/>
173194
</Pressable>

‎apps/mobile/src/features/threads/threadAgentsPresentation.ts‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
subagentDetailPreview,
44
} from "@t3tools/client-runtime/state/subagent-display";
55
import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime";
6-
import type { OrchestrationV2Subagent } from "@t3tools/contracts";
6+
import type { EnvironmentId, OrchestrationV2Subagent, ThreadId } from "@t3tools/contracts";
77

88
const PROMPT_TITLE_LIMIT = 80;
99

@@ -16,6 +16,27 @@ export interface SubagentRowPresentation {
1616
readonly tone: SubagentRowTone;
1717
readonly live: boolean;
1818
readonly canOpenThread: boolean;
19+
/** The linked environment the task runs in, when it runs in one. */
20+
readonly runsOn: string | null;
21+
}
22+
23+
/**
24+
* Where an agent's thread opens: here, or in the linked environment that runs
25+
* it. Null when there is no thread, or this app is not connected to that
26+
* environment.
27+
*/
28+
export function subagentThreadTarget(
29+
subagent: Pick<OrchestrationV2Subagent, "childThreadId" | "remoteChild">,
30+
environmentId: EnvironmentId,
31+
isKnownEnvironment: (environmentId: EnvironmentId) => boolean,
32+
): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null {
33+
if (subagent.childThreadId !== null) {
34+
return { environmentId, threadId: subagent.childThreadId };
35+
}
36+
const remote = subagent.remoteChild;
37+
return remote !== undefined && isKnownEnvironment(remote.environmentId)
38+
? { environmentId: remote.environmentId, threadId: remote.threadId }
39+
: null;
1940
}
2041

2142
function rowTitle(subagent: Pick<OrchestrationV2Subagent, "title" | "prompt">): string {
@@ -58,8 +79,9 @@ function rowStatusLabel(status: OrchestrationV2Subagent["status"]): string {
5879
export function resolveSubagentRowPresentation(
5980
subagent: Pick<
6081
OrchestrationV2Subagent,
61-
"title" | "prompt" | "status" | "progress" | "result" | "childThreadId"
82+
"title" | "prompt" | "status" | "progress" | "result" | "childThreadId" | "remoteChild"
6283
>,
84+
canOpenRemote = false,
6385
): SubagentRowPresentation {
6486
const live = isActiveSubagentStatus(subagent.status);
6587
return {
@@ -68,6 +90,8 @@ export function resolveSubagentRowPresentation(
6890
statusLabel: rowStatusLabel(subagent.status),
6991
tone: rowTone(subagent.status),
7092
live,
71-
canOpenThread: subagent.childThreadId !== null,
93+
canOpenThread:
94+
subagent.childThreadId !== null || (subagent.remoteChild !== undefined && canOpenRemote),
95+
runsOn: subagent.remoteChild?.label ?? null,
7296
};
7397
}

‎apps/mobile/src/state/thread-order.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ vi.mock("./atom-registry", async () => {
2020
});
2121
vi.mock("./threads", async () => {
2222
const { Atom } = await import("effect/reactivity");
23-
return { environmentThreadShells: { threadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) } };
23+
return {
24+
environmentThreadShells: { navigationThreadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) },
25+
};
2426
});
2527
vi.mock("./server", async () => {
2628
const { Atom } = await import("effect/reactivity");
@@ -34,7 +36,7 @@ vi.mock("./use-thread-outbox", async () => {
3436
type ServerConfigs = Atom.Type<typeof environmentServerConfigsAtom>;
3537
type ServerConfig = ServerConfigs extends ReadonlyMap<EnvironmentId, infer Config> ? Config : never;
3638
const configsAtom = environmentServerConfigsAtom as Atom.Writable<ServerConfigs, ServerConfigs>;
37-
const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable<
39+
const shellsAtom = environmentThreadShells.navigationThreadShellsAtom as Atom.Writable<
3840
readonly EnvironmentThreadShell[],
3941
readonly EnvironmentThreadShell[]
4042
>;

‎apps/mobile/src/state/thread-order.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) {
6464
const current = appAtomRegistry.get(pendingThreadOrderAtom);
6565
if (current === null) return;
6666
const configs = appAtomRegistry.get(environmentServerConfigsAtom);
67-
const threads = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
67+
const threads = appAtomRegistry.get(environmentThreadShells.navigationThreadShellsAtom);
6868
const nowMs = Date.now();
6969
const ordered = getThreadListV2OrderedSection({
7070
threads,
@@ -95,13 +95,16 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) {
9595
teardownPendingOrder = teardown;
9696
appAtomRegistry.set(pendingThreadOrderAtom, pending);
9797
unsubscribers.push(
98-
appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh),
98+
appAtomRegistry.subscribe(environmentThreadShells.navigationThreadShellsAtom, refresh),
9999
appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh),
100100
appAtomRegistry.subscribe(queuedThreadKeysAtom, refresh),
101101
minuteTick.subscribe(refresh),
102102
);
103103
armWake(
104-
nextSnoozeWakeAfter(appAtomRegistry.get(environmentThreadShells.threadShellsAtom), Date.now()),
104+
nextSnoozeWakeAfter(
105+
appAtomRegistry.get(environmentThreadShells.navigationThreadShellsAtom),
106+
Date.now(),
107+
),
105108
);
106109
return {
107110
isPending: () => {

‎apps/server/src/mcp/McpToolAccess.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
import * as PeerForwarding from "../peer/PeerForwarding.ts";
2424
import { assertNotLinked, assertSameLink, callerLinkOrigin } from "./linkOrigin.ts";
2525
import * as McpInvocationContext from "./McpInvocationContext.ts";
26-
import { resolveInteractionMode, resolveRuntimeMode } from "./OrchestratorMcpService.ts";
26+
import { resolveInteractionMode, resolveRuntimeMode } from "./delegatedTaskModes.ts";
2727
import {
2828
assertFullAccess,
2929
assertLiveCaller,

0 commit comments

Comments
 (0)