From 80b33d4b6afaa6fea875d0a9afbc97583b66c614 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 04:19:56 -0700 Subject: [PATCH] fix(web): remote delegates show their harness icon, a parent row, and new links in settings - Tasks delegated to a linked environment resolve their icon from that environment's provider instead of the "remote" driver label. - Answering an agent's link card refreshes the linked environments list. - A thread delegated from another machine gets a parent row in Lineage. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/LinkRequestCard.tsx | 9 +- .../threads/thread-subagent-group.tsx | 19 ++-- .../src/components/chat/LinkRequestCard.tsx | 9 +- .../chat/ThreadRelationshipsControl.tsx | 87 ++++++++++++++++++- .../components/chat/TimelineV2EventRow.tsx | 1 + .../src/components/chat/V2LifecycleRow.tsx | 23 ++++- .../client-runtime/src/state/peerLinks.ts | 12 +++ packages/client-runtime/src/state/server.ts | 10 --- 8 files changed, 140 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx index ac96063001836..4150c96a34e5c 100644 --- a/apps/mobile/src/features/threads/LinkRequestCard.tsx +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -30,9 +30,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; -import { useLinkableMachines } from "../../state/peerLinks"; +import { peerLinkEnvironment, useLinkableMachines } from "../../state/peerLinks"; import { mintPairingCodeOn } from "../../state/peerPairingCode"; -import { serverEnvironment } from "../../state/server"; import { useEnvironmentScope, usePreparedConnection } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow"; @@ -107,13 +106,13 @@ function PendingLinkRequest(props: { const [open, setOpen] = useState(false); const [declining, setDeclining] = useState(false); const [error, setError] = useState(null); - const answer = useAtomCommand(serverEnvironment.answerLinkRequest, { + const answer = useAtomCommand(peerLinkEnvironment.answerRequest, { label: "decline link request", reportFailure: false, reportDefect: false, }); const canAnswer = useAtomValue( - serverEnvironment.answerLinkRequest.permissionAtom(props.environmentId), + peerLinkEnvironment.answerRequest.permissionAtom(props.environmentId), ); const target = linkRequestTargetName(item); @@ -244,7 +243,7 @@ function LinkRequestSheet(props: { const [mintFailed, setMintFailed] = useState>(new Set()); // Submit then a tap can both run before a re-render; this guard is synchronous. const inFlight = useRef(false); - const answer = useAtomCommand(serverEnvironment.answerLinkRequest, { + const answer = useAtomCommand(peerLinkEnvironment.answerRequest, { label: "answer link request", // The failure cause holds the request, and with it the code; keep it out of the console. reportFailure: false, diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index f83b85de0b8e2..cabd4eeb371bb 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -7,6 +7,7 @@ import type { EnvironmentId, OrchestrationV2Subagent, OrchestrationV2TurnItem, + ServerProvider, } from "@t3tools/contracts"; import { Pressable, View, type ColorValue } from "react-native"; @@ -41,14 +42,22 @@ function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray - + {driver === undefined ? ( + + ) : ( + + )} ); } @@ -91,11 +100,11 @@ export function ThreadSubagentGroup(props: { 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) => + const providerOf = (item: SubagentItem) => (item.remoteChild === undefined ? config : serverConfigs.get(item.remoteChild.environmentId) - )?.providers.find((provider) => provider.instanceId === item.providerInstanceId)?.iconUrl; + )?.providers.find((provider) => provider.instanceId === item.providerInstanceId); return ( {grouped ? ( @@ -109,7 +118,7 @@ export function ThreadSubagentGroup(props: { {agents.slice(0, 3).map((agent, index) => ( - + ))} {agents.length > 3 ? ( diff --git a/apps/web/src/components/chat/LinkRequestCard.tsx b/apps/web/src/components/chat/LinkRequestCard.tsx index 728c8fe61efa2..a9bdd0fb1114a 100644 --- a/apps/web/src/components/chat/LinkRequestCard.tsx +++ b/apps/web/src/components/chat/LinkRequestCard.tsx @@ -28,8 +28,7 @@ import { useId, useRef, useState } from "react"; import { useEnvironmentScope, usePreparedConnection } from "../../state/session"; import { mintPairingCodeOn } from "../../state/peerPairingCode"; -import { useLinkableMachines } from "../../state/peerLinks"; -import { serverEnvironment } from "../../state/server"; +import { peerLinkEnvironment, useLinkableMachines } from "../../state/peerLinks"; import { useAtomCommand } from "../../state/use-atom-command"; import { accessConfig } from "../auth/ConnectAgentSurface"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; @@ -88,13 +87,13 @@ function PendingLinkRequest(props: { const [open, setOpen] = useState(false); const [declining, setDeclining] = useState(false); const [error, setError] = useState(null); - const answer = useAtomCommand(serverEnvironment.answerLinkRequest, { + const answer = useAtomCommand(peerLinkEnvironment.answerRequest, { label: "decline link request", reportFailure: false, reportDefect: false, }); const canAnswer = useAtomValue( - serverEnvironment.answerLinkRequest.permissionAtom(props.environmentId), + peerLinkEnvironment.answerRequest.permissionAtom(props.environmentId), ); const target = linkRequestTargetName(item); @@ -223,7 +222,7 @@ function LinkRequestDialog(props: { const [mintFailed, setMintFailed] = useState>(new Set()); // Enter then a click can both run before a re-render; this guard is synchronous. const inFlight = useRef(false); - const answer = useAtomCommand(serverEnvironment.answerLinkRequest, { + const answer = useAtomCommand(peerLinkEnvironment.answerRequest, { label: "answer link request", // The failure cause holds the request, and with it the code; keep it out of the console. reportFailure: false, diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index b37f9d1d1078d..e107bbc4039c0 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -32,6 +32,7 @@ import { isProviderNativeSubagentThread, NodeId, type EnvironmentId, + type OrchestrationV2DelegatedFrom, type OrchestrationV2RemoteTaskChild, type OrchestrationV2Subagent, type OrchestrationV2ThreadShell, @@ -60,6 +61,7 @@ import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { buildThreadRouteParams } from "../../threadRoutes"; import { useProjects, + useQuietThreadShell, useThreadProjection, useThreadRelationshipShells, } from "../../state/entities"; @@ -261,7 +263,8 @@ function RemoteSubagentRow(props: { } > + + + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(parentRef), + }) + } + /> + } + > + + + + {title} + + + {label ? ( + on {label} + ) : null} + + + {environment === null + ? `Delegated from ${label ?? "a linked environment"}. Connect to it to open the parent thread.` + : "Open parent agent"} + + + + ); +} + export function ThreadRelationshipsPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; @@ -412,7 +471,15 @@ export function ThreadRelationshipsPanel(props: { (agent) => agent.childThreadId === null && agent.status === "running", ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length; - if (relationshipRows.length === 0 && runningCount === 0 && remoteSubagents.length === 0) { + // A linked environment's agent may have delegated this thread from there. + const delegatedFrom = currentThread?.delegatedFrom; + + if ( + relationshipRows.length === 0 && + runningCount === 0 && + remoteSubagents.length === 0 && + delegatedFrom === undefined + ) { return null; } @@ -487,7 +554,13 @@ export function ThreadRelationshipsPanel(props: { headingId="thread-details-lineage-heading" title={runningCount > 0 ? `Lineage ยท ${runningCount} running` : "Lineage"} // A lone "Previous agents" row names itself; the heading returns for status or actions. - showHeading={runningCount > 0 || canDetach || related.length > 0 || active.length > 0} + showHeading={ + runningCount > 0 || + canDetach || + related.length > 0 || + active.length > 0 || + delegatedFrom !== undefined + } data-thread-relationships-panel actions={ canDetach ? ( @@ -515,6 +588,14 @@ export function ThreadRelationshipsPanel(props: { ) : null } > + {delegatedFrom === undefined ? null : ( + {}}> + + + )} {groups.map((group) => ( {(visibleRows) => diff --git a/apps/web/src/components/chat/TimelineV2EventRow.tsx b/apps/web/src/components/chat/TimelineV2EventRow.tsx index 66e1b6fac1df5..e69ffdbaffdd4 100644 --- a/apps/web/src/components/chat/TimelineV2EventRow.tsx +++ b/apps/web/src/components/chat/TimelineV2EventRow.tsx @@ -109,6 +109,7 @@ const V2SubagentGroup = memo(function V2SubagentGroup({ environmentId={ctx.activeThreadEnvironmentId} providerInstanceId={item.providerInstanceId} driver={item.driver} + remote={item.remoteChild} status={agents.length === 1 ? status : undefined} /> ))} diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index e1af81cab35ae..42ab22fa5f3d8 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -250,14 +250,25 @@ function SubagentAvatar({ export function EnvironmentSubagentAvatar({ environmentId, providerInstanceId, + driver, + remote, ...props }: { environmentId: EnvironmentId; providerInstanceId: ProviderInstanceId; driver?: ProviderDriverKind | undefined; + /** A task in a linked environment runs a provider instance there; its driver here is only a label. */ + remote?: OrchestrationV2RemoteTaskChild | undefined; status?: OrchestrationV2TurnItem["status"] | undefined; }) { - const providersAtom = serverEnvironment.providersValueAtom(environmentId); + const providersAtom = serverEnvironment.providersValueAtom( + remote?.environmentId ?? environmentId, + ); + const providerDriver = useAtomValue( + providersAtom, + (providers) => + providers?.find((provider) => provider.instanceId === providerInstanceId)?.driver, + ); const displayName = useAtomValue( providersAtom, (providers) => @@ -268,7 +279,14 @@ export function EnvironmentSubagentAvatar({ (providers) => providers?.find((provider) => provider.instanceId === providerInstanceId)?.iconUrl, ); - return ; + return ( + + ); } const NOTIFICATION_OUTCOME_STATUS: Record< @@ -399,6 +417,7 @@ function SubagentTimelineLink(props: { environmentId={props.parentRef.environmentId} providerInstanceId={props.providerInstanceId} driver={props.driver} + remote={remote} status={status ?? undefined} /> diff --git a/packages/client-runtime/src/state/peerLinks.ts b/packages/client-runtime/src/state/peerLinks.ts index 6e65f62eeb748..9295bf1b40156 100644 --- a/packages/client-runtime/src/state/peerLinks.ts +++ b/packages/client-runtime/src/state/peerLinks.ts @@ -30,6 +30,18 @@ export function createPeerLinkEnvironmentAtoms( onSuccess: ({ environmentId }, registry) => Effect.sync(() => registry.refresh(list({ environmentId, input: {} }))), }), + // An agent's link card. One answer per card at a time. + answerRequest: createEnvironmentRpcCommand(runtime, { + label: "environment-data:peer-links:answer-request", + tag: WS_METHODS.peerLinksAnswerRequest, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => + JSON.stringify([environmentId, input.threadId, input.turnItemId]), + }, + onSuccess: ({ environmentId }, registry) => + Effect.sync(() => registry.refresh(list({ environmentId, input: {} }))), + }), }; } diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 3cb4f77325fa6..c8ce8b3878744 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1527,16 +1527,6 @@ export function createServerEnvironmentAtoms( JSON.stringify([environmentId, input.threadId, input.turnItemId]), }, }), - // Like a secret card's answer: off the config lane, one per card at a time. - answerLinkRequest: createEnvironmentRpcCommand(runtime, { - label: "environment-data:server:peer-links:answer-request", - tag: WS_METHODS.peerLinksAnswerRequest, - concurrency: { - mode: "singleFlight", - key: ({ environmentId, input }) => - JSON.stringify([environmentId, input.threadId, input.turnItemId]), - }, - }), refreshUsageRates: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-usage-rates", tag: WS_METHODS.serverRefreshUsageRates,