Skip to content
Merged
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
9 changes: 4 additions & 5 deletions apps/mobile/src/features/threads/LinkRequestCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -107,13 +106,13 @@ function PendingLinkRequest(props: {
const [open, setOpen] = useState(false);
const [declining, setDeclining] = useState(false);
const [error, setError] = useState<string | null>(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);

Expand Down Expand Up @@ -244,7 +243,7 @@ function LinkRequestSheet(props: {
const [mintFailed, setMintFailed] = useState<ReadonlySet<EnvironmentId>>(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,
Expand Down
19 changes: 14 additions & 5 deletions apps/mobile/src/features/threads/thread-subagent-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
EnvironmentId,
OrchestrationV2Subagent,
OrchestrationV2TurnItem,
ServerProvider,
} from "@t3tools/contracts";
import { Pressable, View, type ColorValue } from "react-native";

Expand Down Expand Up @@ -41,14 +42,22 @@ function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray<AgentTimin

function SubagentAvatar(props: {
readonly item: SubagentItem;
readonly iconUrl?: string | null | undefined;
readonly provider: ServerProvider | undefined;
}) {
// A remote task's own driver is only a "remote" label.
const driver =
props.provider?.driver ??
(props.item.remoteChild === undefined ? props.item.driver : undefined);
return (
<View
accessible={false}
className="h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-card"
>
<ProviderIcon provider={props.item.driver} iconUrl={props.iconUrl} size={15} />
{driver === undefined ? (
<SymbolView name="sparkles" size={13} tintColorClassName="accent-icon-muted" />
) : (
<ProviderIcon provider={driver} iconUrl={props.provider?.iconUrl} size={15} />
)}
</View>
);
}
Expand Down Expand Up @@ -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 (
<WorkLogBlock>
{grouped ? (
Expand All @@ -109,7 +118,7 @@ export function ThreadSubagentGroup(props: {
<View className="flex-row items-center">
{agents.slice(0, 3).map((agent, index) => (
<View key={agent.item.id} style={{ marginLeft: index === 0 ? 0 : -7 }}>
<SubagentAvatar item={agent.item} iconUrl={iconUrl(agent.item)} />
<SubagentAvatar item={agent.item} provider={providerOf(agent.item)} />
</View>
))}
{agents.length > 3 ? (
Expand Down
9 changes: 4 additions & 5 deletions apps/web/src/components/chat/LinkRequestCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -88,13 +87,13 @@ function PendingLinkRequest(props: {
const [open, setOpen] = useState(false);
const [declining, setDeclining] = useState(false);
const [error, setError] = useState<string | null>(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);

Expand Down Expand Up @@ -223,7 +222,7 @@ function LinkRequestDialog(props: {
const [mintFailed, setMintFailed] = useState<ReadonlySet<EnvironmentId>>(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,
Expand Down
87 changes: 84 additions & 3 deletions apps/web/src/components/chat/ThreadRelationshipsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
isProviderNativeSubagentThread,
NodeId,
type EnvironmentId,
type OrchestrationV2DelegatedFrom,
type OrchestrationV2RemoteTaskChild,
type OrchestrationV2Subagent,
type OrchestrationV2ThreadShell,
Expand Down Expand Up @@ -60,6 +61,7 @@ import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
import { buildThreadRouteParams } from "../../threadRoutes";
import {
useProjects,
useQuietThreadShell,
useThreadProjection,
useThreadRelationshipShells,
} from "../../state/entities";
Expand Down Expand Up @@ -261,7 +263,8 @@ function RemoteSubagentRow(props: {
}
>
<ThreadRelationshipIcon
driver={props.agent.driver}
// The task's own driver here is only a "remote" label.
driver={props.provider?.driver}
provider={props.provider}
fallbackIcon={BotIcon}
status={status}
Expand All @@ -287,6 +290,62 @@ function RemoteSubagentRow(props: {
);
}

/**
* The thread on a linked environment whose agent delegated this one. It opens
* there when this client is connected to that environment.
*/
function DelegatedFromRow(props: {
readonly delegatedFrom: OrchestrationV2DelegatedFrom;
readonly fallbackLabel: string | undefined;
}) {
const navigate = useNavigate();
const { delegatedFrom } = props;
const environment = useEnvironment(delegatedFrom.environmentId);
const parentRef = scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId);
// The title it had at delegation, until this client sees the parent itself.
const parent = useQuietThreadShell(parentRef);
const label = environment?.label ?? props.fallbackLabel;
const title = parent?.title ?? delegatedFrom.title;
return (
<li className="group relative flex h-8 items-center rounded-lg">
<Tooltip>
<TooltipTrigger
delay={200}
render={
<ThreadDetailsControl
size="sm"
variant="ghost"
part="row"
disabled={environment === null}
onClick={() =>
void navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(parentRef),
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
/>
}
>
<ThreadRelationshipIcon fallbackIcon={CornerLeftUpIcon} status={null} />
<span className="min-w-0 flex-1">
<span className="block truncate text-left text-sm font-medium leading-4 text-foreground/85">
{title}
</span>
</span>
{label ? (
<span className="shrink-0 text-2xs text-muted-foreground">on {label}</span>
) : null}
</TooltipTrigger>
<TooltipPopup side="left">
{environment === null
? `Delegated from ${label ?? "a linked environment"}. Connect to it to open the parent thread.`
: "Open parent agent"}
</TooltipPopup>
</Tooltip>
</li>
);
}

export function ThreadRelationshipsPanel(props: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 ? (
Expand Down Expand Up @@ -515,6 +588,14 @@ export function ThreadRelationshipsPanel(props: {
) : null
}
>
{delegatedFrom === undefined ? null : (
<ThreadLineageRowList hiddenCount={0} onShowMore={() => {}}>
<DelegatedFromRow
delegatedFrom={delegatedFrom}
fallbackLabel={currentThread?.linkOrigin?.label}
/>
</ThreadLineageRowList>
)}
{groups.map((group) => (
<ThreadLineageGroup key={`${scopedThreadKey(ref)}:${group.id}`} {...group}>
{(visibleRows) =>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/TimelineV2EventRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
))}
Expand Down
23 changes: 21 additions & 2 deletions apps/web/src/components/chat/V2LifecycleRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -268,7 +279,14 @@ export function EnvironmentSubagentAvatar({
(providers) =>
providers?.find((provider) => provider.instanceId === providerInstanceId)?.iconUrl,
);
return <SubagentAvatar {...props} displayName={displayName} iconUrl={iconUrl} />;
return (
<SubagentAvatar
{...props}
driver={providerDriver ?? (remote === undefined ? driver : undefined)}
displayName={displayName}
iconUrl={iconUrl}
/>
);
}

const NOTIFICATION_OUTCOME_STATUS: Record<
Expand Down Expand Up @@ -399,6 +417,7 @@ function SubagentTimelineLink(props: {
environmentId={props.parentRef.environmentId}
providerInstanceId={props.providerInstanceId}
driver={props.driver}
remote={remote}
status={status ?? undefined}
/>
<span className="min-w-0 flex-1">
Expand Down
12 changes: 12 additions & 0 deletions packages/client-runtime/src/state/peerLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ export function createPeerLinkEnvironmentAtoms<R, E>(
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: {} }))),
}),
};
}

Expand Down
10 changes: 0 additions & 10 deletions packages/client-runtime/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1527,16 +1527,6 @@ export function createServerEnvironmentAtoms<R, E>(
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,
Expand Down
Loading