Show provider subagents in the subagents track (#2013)
* Add provider-owned subagent stream contract * OpenCode provider: detect child sessions, attach adopted children to the hosting server, synthesize external turns - Emit internal provider_child_session_detected / provider_session_deleted stream events from the global event stream (before the no-active-turn gate), including BFS hydration of existing descendants on subscribe - Track child sessions in a registry mapping child session id -> hosting server url; resumeSession attaches via acquireExisting() so adopted children share the parent's server generation and keep receiving live events - Synthesize external turns for adopted (externally driven) sessions so timeline, tool calls, and permission requests stream without a Paseo-initiated prompt - Honor launchContext.env in resumeSession (acquireDedicated parity with createSession) - Stop folding child tool parts into the parent's sub_agent action log; the parent row stays as a pointer * Render provider subagents in workspace timelines * fix provider subagent timeline reliability * fix provider subagent lifecycle edge cases * fix provider subagent event fidelity * Fix provider subagent history fidelity * Fix restored provider subagent state * Fix provider subagent visibility edges * Restore provider subagent timelines * Page provider subagent history * Forward subagent questions reliably * fix(subagents): preserve OpenCode child context * fix(subagents): harden provider timeline routing * fix(subagents): preserve provider page continuity * fix(subagents): retain live child prompts --------- Co-authored-by: Omer <639682+omercnet@users.noreply.github.com>
This commit is contained in:
@@ -1270,7 +1270,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
ref={streamViewRef}
|
||||
agentId={agent.id}
|
||||
serverId={serverId}
|
||||
agent={agent}
|
||||
context={agent}
|
||||
streamItems={streamItems}
|
||||
pendingPermissions={pendingPermissions}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
@@ -1364,7 +1364,7 @@ function ActiveAgentComposer({
|
||||
{ initialIsBelow: isCompactFormFactor },
|
||||
);
|
||||
const paneContext = usePaneContext();
|
||||
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
|
||||
const { workspaceId, tabId, retargetCurrentTab, openTab } = paneContext;
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
||||
@@ -1382,6 +1382,12 @@ function ActiveAgentComposer({
|
||||
},
|
||||
[serverId],
|
||||
);
|
||||
const handleOpenProviderSubagent = useCallback(
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
},
|
||||
[openTab],
|
||||
);
|
||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||
const handleDetachSubagent = useDetachSubagent({ serverId });
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
@@ -1482,6 +1488,7 @@ function ActiveAgentComposer({
|
||||
<SubagentsTrack
|
||||
rows={subagentRows}
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||
onArchiveSubagent={handleArchiveSubagent}
|
||||
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { AgentStreamView } from "@/agent-stream/view";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
providerSubagentKey,
|
||||
providerSubagentLifecycleStatus,
|
||||
refreshProviderSubagents,
|
||||
useProviderSubagentStore,
|
||||
} from "@/subagents/provider-store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
|
||||
const EMPTY_PERMISSIONS = new Map<string, PendingPermission>();
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
|
||||
function formatProviderLabel(provider: string): string {
|
||||
return provider
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function useProviderSubagentDescriptor(
|
||||
target: { kind: "provider_subagent"; parentAgentId: string; subagentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const descriptor = useProviderSubagentStore((state) =>
|
||||
state.descriptors.get(
|
||||
providerSubagentKey(context.serverId, target.parentAgentId, target.subagentId),
|
||||
),
|
||||
);
|
||||
const parentProvider = useSessionStore(
|
||||
(state) => state.sessions[context.serverId]?.agents.get(target.parentAgentId)?.provider,
|
||||
);
|
||||
const provider = descriptor?.provider ?? parentProvider ?? "agent";
|
||||
const label = descriptor?.title?.trim() || descriptor?.description?.trim() || "Subagent";
|
||||
return {
|
||||
label,
|
||||
subtitle: `${formatProviderLabel(provider)} subagent`,
|
||||
titleState: descriptor ? "ready" : "loading",
|
||||
icon: getProviderIcon(provider),
|
||||
statusBucket: descriptor
|
||||
? deriveSidebarStateBucket({
|
||||
status: providerSubagentLifecycleStatus(descriptor.status),
|
||||
requiresAttention: descriptor.status === "failed",
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function ProviderSubagentPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, target, openFileInWorkspace } = usePaneContext();
|
||||
invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target");
|
||||
const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId);
|
||||
const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`;
|
||||
const { descriptor, timeline } = useProviderSubagentStore(
|
||||
useShallow((state) => ({
|
||||
descriptor: state.descriptors.get(key) ?? null,
|
||||
timeline: state.timelines.get(key) ?? null,
|
||||
})),
|
||||
);
|
||||
const parent = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[serverId]?.agents.get(target.parentAgentId) ??
|
||||
state.sessions[serverId]?.agentDetails.get(target.parentAgentId) ??
|
||||
null,
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null);
|
||||
// COMPAT(providerSubagents): added in v0.2.11, remove after 2027-01-12.
|
||||
const supported = serverInfo?.features?.providerSubagents === true;
|
||||
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !supported) return;
|
||||
void refreshProviderSubagents(client, serverId, target.parentAgentId).catch(() => undefined);
|
||||
}, [client, serverId, supported, target.parentAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !supported) return;
|
||||
void client
|
||||
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
})
|
||||
.then((payload) => {
|
||||
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [client, serverId, supported, target.parentAgentId, target.subagentId]);
|
||||
|
||||
const loadOlder = useCallback(() => {
|
||||
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) return;
|
||||
const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null;
|
||||
if (firstSeq === null) return;
|
||||
setIsLoadingOlder(true);
|
||||
void client
|
||||
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
|
||||
direction: "before",
|
||||
cursor: { epoch: timeline.epoch, seq: firstSeq },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
})
|
||||
.then((payload) => {
|
||||
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setIsLoadingOlder(false));
|
||||
}, [
|
||||
client,
|
||||
isLoadingOlder,
|
||||
serverId,
|
||||
supported,
|
||||
target.parentAgentId,
|
||||
target.subagentId,
|
||||
timeline,
|
||||
]);
|
||||
|
||||
const streamContext = useMemo<AgentScreenAgent>(
|
||||
() => ({
|
||||
serverId,
|
||||
id: streamId,
|
||||
provider: descriptor?.provider ?? parent?.provider,
|
||||
status: descriptor ? providerSubagentLifecycleStatus(descriptor.status) : "initializing",
|
||||
cwd: descriptor?.cwd ?? parent?.cwd ?? "",
|
||||
workspaceId: parent?.workspaceId,
|
||||
projectPlacement: parent?.projectPlacement,
|
||||
}),
|
||||
[descriptor, parent, serverId, streamId],
|
||||
);
|
||||
const historyPagination = useMemo(
|
||||
() => ({
|
||||
hasOlder: timeline?.hasOlder === true,
|
||||
isLoadingOlder,
|
||||
onLoadOlder: loadOlder,
|
||||
}),
|
||||
[isLoadingOlder, loadOlder, timeline?.hasOlder],
|
||||
);
|
||||
|
||||
if (serverInfo && !supported) {
|
||||
return (
|
||||
<View style={styles.unsupported} testID="provider-subagent-panel-unsupported">
|
||||
<Text style={styles.unsupportedText}>{t("message.actions.forkUnavailable")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="provider-subagent-panel">
|
||||
<AgentStreamView
|
||||
agentId={streamId}
|
||||
serverId={serverId}
|
||||
context={streamContext}
|
||||
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
|
||||
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
|
||||
pendingPermissions={EMPTY_PERMISSIONS}
|
||||
isAuthoritativeHistoryReady
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
readOnly
|
||||
historyPagination={historyPagination}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: { flex: 1 },
|
||||
unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
|
||||
}));
|
||||
|
||||
export const providerSubagentPanelRegistration: PanelRegistration<"provider_subagent"> = {
|
||||
kind: "provider_subagent",
|
||||
component: ProviderSubagentPanel,
|
||||
useDescriptor: useProviderSubagentDescriptor,
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { filePanelRegistration } from "@/panels/file-panel";
|
||||
import { registerPanel } from "@/panels/panel-registry";
|
||||
import { setupPanelRegistration } from "@/panels/setup-panel";
|
||||
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
||||
import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel";
|
||||
|
||||
let panelsRegistered = false;
|
||||
|
||||
@@ -14,6 +15,7 @@ export function ensurePanelsRegistered(): void {
|
||||
}
|
||||
registerPanel(draftPanelRegistration);
|
||||
registerPanel(agentPanelRegistration);
|
||||
registerPanel(providerSubagentPanelRegistration);
|
||||
registerPanel(setupPanelRegistration);
|
||||
registerPanel(terminalPanelRegistration);
|
||||
registerPanel(browserPanelRegistration);
|
||||
|
||||
Reference in New Issue
Block a user