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:
@@ -228,13 +228,20 @@ export interface AgentStreamViewHandle {
|
||||
export interface AgentStreamViewProps {
|
||||
agentId: string;
|
||||
serverId?: string;
|
||||
agent: AgentScreenAgent;
|
||||
context: AgentScreenAgent;
|
||||
streamItems: StreamItem[];
|
||||
streamHead?: StreamItem[];
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
toast?: ToastApi | null;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
readOnly?: boolean;
|
||||
historyPagination?: {
|
||||
hasOlder: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
onLoadOlder: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
||||
@@ -306,13 +313,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
{
|
||||
agentId,
|
||||
serverId,
|
||||
agent,
|
||||
context,
|
||||
streamItems,
|
||||
streamHead: providedStreamHead,
|
||||
pendingPermissions,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
toast,
|
||||
onOpenWorkspaceFile,
|
||||
readOnly = false,
|
||||
historyPagination,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -337,27 +347,37 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
|
||||
const streamHead = useSessionStore((state) =>
|
||||
const sessionStreamHead = useSessionStore((state) =>
|
||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
|
||||
);
|
||||
const streamHead = providedStreamHead ?? sessionStreamHead;
|
||||
const supportsAgentForkContext = useSessionStore(
|
||||
(state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||
(state) =>
|
||||
!readOnly &&
|
||||
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||
);
|
||||
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const workspaceRoot = context.cwd?.trim() || "";
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: agent.workspaceId,
|
||||
workspaceId: context.workspaceId,
|
||||
workspaceRoot,
|
||||
});
|
||||
const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({
|
||||
const agentHistoryPagination = useLoadOlderAgentHistory({
|
||||
serverId: resolvedServerId,
|
||||
agentId,
|
||||
toast,
|
||||
});
|
||||
const { isLoadingOlder, hasOlder, loadOlder } = historyPagination
|
||||
? {
|
||||
isLoadingOlder: historyPagination.isLoadingOlder,
|
||||
hasOlder: historyPagination.hasOlder,
|
||||
loadOlder: historyPagination.onLoadOlder,
|
||||
}
|
||||
: agentHistoryPagination;
|
||||
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||
// tracked in react-native-reanimated#8422.
|
||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||
@@ -379,7 +399,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
|
||||
const normalized = normalizeInlinePathTarget(target.path, context.cwd);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
@@ -402,10 +422,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return;
|
||||
}
|
||||
|
||||
if (agent.workspaceId) {
|
||||
if (context.workspaceId) {
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: agent.workspaceId,
|
||||
workspaceId: context.workspaceId,
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
});
|
||||
}
|
||||
@@ -419,8 +439,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const checkout = {
|
||||
serverId: resolvedServerId,
|
||||
cwd: agent.cwd,
|
||||
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||
cwd: context.cwd,
|
||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
||||
};
|
||||
setExplorerTabForCheckout({ ...checkout, tab: "files" });
|
||||
openFileExplorerForCheckout({
|
||||
@@ -444,7 +464,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const draftSetup = buildForkDraftSetup(agent);
|
||||
const draftSetup = buildForkDraftSetup(context);
|
||||
const prepareForkDraft = async () => {
|
||||
const draftId = generateDraftId();
|
||||
const payload = await client.buildAgentForkContext(
|
||||
@@ -466,7 +486,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
};
|
||||
|
||||
if (target === "tab") {
|
||||
const workspaceId = agent.workspaceId;
|
||||
const workspaceId = context.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new Error(t("message.actions.forkMissingWorkspace"));
|
||||
}
|
||||
@@ -481,7 +501,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const draftId = await prepareForkDraft();
|
||||
const sourceDirectory =
|
||||
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
|
||||
context.projectPlacement?.checkout?.cwd?.trim() || context.cwd.trim() || undefined;
|
||||
if (draftSetup) {
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId,
|
||||
@@ -493,8 +513,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
buildNewWorkspaceRoute({
|
||||
serverId: resolvedServerId,
|
||||
sourceDirectory,
|
||||
displayName: agent.projectPlacement?.projectName,
|
||||
projectId: agent.projectPlacement?.projectKey,
|
||||
displayName: context.projectPlacement?.projectName,
|
||||
projectId: context.projectPlacement?.projectKey,
|
||||
draftId,
|
||||
}),
|
||||
);
|
||||
@@ -520,24 +540,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
agentStatus: agent.status,
|
||||
agentStatus: context.status,
|
||||
tail: effectiveStreamItems,
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
platform: isWeb ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [agent.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
|
||||
}, [context.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
|
||||
const streamLayout = useMemo(
|
||||
() =>
|
||||
layoutStream({
|
||||
strategy: streamRenderStrategy,
|
||||
agentStatus: agent.status,
|
||||
agentStatus: context.status,
|
||||
history: baseRenderModel.history,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||
}),
|
||||
[
|
||||
agent.status,
|
||||
context.status,
|
||||
baseRenderModel.history,
|
||||
baseRenderModel.segments.liveHead,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
@@ -590,14 +610,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
images={item.images}
|
||||
attachments={item.attachments}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
capabilities={agent.capabilities}
|
||||
capabilities={context.capabilities}
|
||||
client={client}
|
||||
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
||||
isLastInGroup={layoutItem.isLastInUserGroup}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[agent.capabilities, agentId, client, resolvedServerId],
|
||||
[context.capabilities, agentId, client, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -668,7 +688,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
error={data.error}
|
||||
status={data.status}
|
||||
detail={data.detail}
|
||||
cwd={agent.cwd}
|
||||
cwd={context.cwd}
|
||||
metadata={data.metadata}
|
||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
@@ -690,7 +710,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
/>
|
||||
);
|
||||
},
|
||||
[agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
);
|
||||
|
||||
const renderStreamItemContent = useCallback(
|
||||
@@ -747,10 +767,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
content,
|
||||
layoutItem,
|
||||
strategy: streamRenderStrategy,
|
||||
onForkAssistantTurn: handleForkAssistantTurn,
|
||||
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
|
||||
[handleForkAssistantTurn, readOnly, renderStreamItemContent, streamRenderStrategy],
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -758,7 +778,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[pendingPermissions, agentId],
|
||||
);
|
||||
|
||||
const showRunningTurnFooter = agent.status === "running";
|
||||
const showRunningTurnFooter = context.status === "running";
|
||||
const pendingPermissionsNode = useMemo(
|
||||
() =>
|
||||
renderPendingPermissionsNode({
|
||||
@@ -775,11 +795,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||
host={bottomTurnFooterHost}
|
||||
strategy={streamRenderStrategy}
|
||||
onForkAssistantTurn={handleForkAssistantTurn}
|
||||
onForkAssistantTurn={readOnly ? undefined : handleForkAssistantTurn}
|
||||
/>
|
||||
) : null,
|
||||
[
|
||||
handleForkAssistantTurn,
|
||||
readOnly,
|
||||
showRunningTurnFooter,
|
||||
baseRenderModel.turnTiming.runningStartedAt,
|
||||
bottomTurnFooterHost,
|
||||
@@ -1004,6 +1025,17 @@ function bottomAnchorRouteRequestsEqual(
|
||||
);
|
||||
}
|
||||
|
||||
function historyPaginationPropsEqual(
|
||||
left: AgentStreamViewProps["historyPagination"],
|
||||
right: AgentStreamViewProps["historyPagination"],
|
||||
): boolean {
|
||||
return (
|
||||
left?.hasOlder === right?.hasOlder &&
|
||||
left?.isLoadingOlder === right?.isLoadingOlder &&
|
||||
left?.onLoadOlder === right?.onLoadOlder
|
||||
);
|
||||
}
|
||||
|
||||
function agentStreamViewPropsEqual(
|
||||
left: AgentStreamViewProps,
|
||||
right: AgentStreamViewProps,
|
||||
@@ -1011,8 +1043,9 @@ function agentStreamViewPropsEqual(
|
||||
const reasons: string[] = [];
|
||||
if (left.agentId !== right.agentId) reasons.push("agentId");
|
||||
if (left.serverId !== right.serverId) reasons.push("serverId");
|
||||
reasons.push(...collectAgentScreenAgentDiffs(left.agent, right.agent));
|
||||
reasons.push(...collectAgentScreenAgentDiffs(left.context, right.context));
|
||||
if (left.streamItems !== right.streamItems) reasons.push("streamItems");
|
||||
if (left.streamHead !== right.streamHead) reasons.push("streamHead");
|
||||
if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
|
||||
if (
|
||||
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
|
||||
@@ -1024,6 +1057,10 @@ function agentStreamViewPropsEqual(
|
||||
}
|
||||
if (left.toast !== right.toast) reasons.push("toast");
|
||||
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
|
||||
if (left.readOnly !== right.readOnly) reasons.push("readOnly");
|
||||
if (!historyPaginationPropsEqual(left.historyPagination, right.historyPagination)) {
|
||||
reasons.push("historyPagination");
|
||||
}
|
||||
recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons);
|
||||
return reasons.length === 0;
|
||||
}
|
||||
|
||||
@@ -673,7 +673,7 @@ export function WorkspaceDraftAgentTab({
|
||||
<AgentStreamView
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
agent={draftAgent}
|
||||
context={draftAgent}
|
||||
streamItems={optimisticStreamItems}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
applyLegacyDaemonWorkspaceOwnership,
|
||||
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
|
||||
} from "@/workspace/legacy-daemon-workspaces";
|
||||
import { useProviderSubagentStore } from "@/subagents/provider-store";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -1349,6 +1350,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
applyTimelineResponse(message.payload);
|
||||
});
|
||||
|
||||
const unsubProviderSubagentUpdate = client.on("agent.provider_subagents.update", (message) => {
|
||||
if (message.type !== "agent.provider_subagents.update") return;
|
||||
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
|
||||
});
|
||||
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
@@ -1750,6 +1756,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
unsubAgentUpdate();
|
||||
unsubAgentStream();
|
||||
unsubAgentTimeline();
|
||||
unsubProviderSubagentUpdate();
|
||||
unsubWorkspaceUpdate();
|
||||
unsubScriptStatusUpdate();
|
||||
unsubCheckoutStatusUpdate();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -377,6 +377,9 @@ function getFallbackTabOptionDescription(
|
||||
if (tab.target.kind === "browser") {
|
||||
return labels.browser;
|
||||
}
|
||||
if (tab.target.kind === "provider_subagent") {
|
||||
return labels.agent;
|
||||
}
|
||||
return tab.target.path;
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "setup") {
|
||||
return `workspace-setup-close-${encodeWorkspaceIdForPathSegment(tab.target.workspaceId)}`;
|
||||
}
|
||||
if (tab.target.kind === "provider_subagent") {
|
||||
return `workspace-provider-subagent-close-${tab.target.subagentId}`;
|
||||
}
|
||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface WorkspaceDraftTabSetup {
|
||||
export type WorkspaceTabTarget =
|
||||
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "provider_subagent"; parentAgentId: string; subagentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "browser"; browserId: string }
|
||||
| WorkspaceFileTabTarget
|
||||
@@ -508,6 +509,17 @@ function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTar
|
||||
if (kind === "agent" && typeof raw.agentId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId });
|
||||
}
|
||||
if (
|
||||
kind === "provider_subagent" &&
|
||||
typeof raw.parentAgentId === "string" &&
|
||||
typeof raw.subagentId === "string"
|
||||
) {
|
||||
return normalizeWorkspaceTabTarget({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: raw.parentAgentId,
|
||||
subagentId: raw.subagentId,
|
||||
});
|
||||
}
|
||||
if (kind === "terminal" && typeof raw.terminalId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { providerSubagentKey, useProviderSubagentStore } from "./provider-store";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const PARENT_ID = "parent-1";
|
||||
const SUBAGENT_ID = "child-1";
|
||||
|
||||
afterEach(() => {
|
||||
useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() });
|
||||
});
|
||||
|
||||
describe("provider subagent client store", () => {
|
||||
test("builds a shared stream model from ordered provider updates", () => {
|
||||
const subagents = useProviderSubagentStore.getState();
|
||||
subagents.applyUpdate(SERVER_ID, {
|
||||
kind: "upsert",
|
||||
subagent: {
|
||||
id: SUBAGENT_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
provider: "codex",
|
||||
title: "Explore",
|
||||
description: "Inspect the repository",
|
||||
status: "running",
|
||||
createdAt: "2026-07-12T10:00:00.000Z",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
});
|
||||
subagents.applyUpdate(SERVER_ID, {
|
||||
kind: "timeline",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
epoch: "epoch-1",
|
||||
seq: 2,
|
||||
timestamp: "2026-07-12T10:00:02.000Z",
|
||||
item: { type: "assistant_message", text: "New live output." },
|
||||
});
|
||||
subagents.replaceTimeline(SERVER_ID, {
|
||||
requestId: "history-1",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
direction: "tail",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
hasOlder: false,
|
||||
hasNewer: true,
|
||||
rows: [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "Older history." },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
});
|
||||
const liveTimeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID));
|
||||
subagents.applyUpdate(SERVER_ID, {
|
||||
kind: "upsert",
|
||||
subagent: {
|
||||
id: SUBAGENT_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
provider: "codex",
|
||||
title: "Explore",
|
||||
description: "Inspect the repository",
|
||||
status: "running",
|
||||
createdAt: "2026-07-12T10:00:00.000Z",
|
||||
updatedAt: "2026-07-12T10:00:01.500Z",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)),
|
||||
).toBe(liveTimeline);
|
||||
subagents.applyUpdate(SERVER_ID, {
|
||||
kind: "upsert",
|
||||
subagent: {
|
||||
id: SUBAGENT_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
provider: "codex",
|
||||
title: "Explore",
|
||||
description: "Inspect the repository",
|
||||
status: "completed",
|
||||
createdAt: "2026-07-12T10:00:00.000Z",
|
||||
updatedAt: "2026-07-12T10:00:02.000Z",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
});
|
||||
|
||||
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
||||
const state = useProviderSubagentStore.getState();
|
||||
expect(state.descriptors.get(key)?.status).toBe("completed");
|
||||
expect(state.timelines.get(key)?.head).toEqual([]);
|
||||
expect(state.timelines.get(key)?.tail).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant_message",
|
||||
text: "Older history.New live output.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("removes timelines for children no longer returned by the provider", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.applyUpdate(SERVER_ID, {
|
||||
kind: "timeline",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
epoch: "epoch-1",
|
||||
seq: 1,
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "Removed child output." },
|
||||
});
|
||||
|
||||
store.replaceList(SERVER_ID, PARENT_ID, []);
|
||||
|
||||
expect(
|
||||
useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.has(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("applies terminal list status to a timeline received before its descriptor", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.applyUpdate(SERVER_ID, {
|
||||
kind: "timeline",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
epoch: "epoch-1",
|
||||
seq: 1,
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "Restored output." },
|
||||
});
|
||||
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
||||
expect(useProviderSubagentStore.getState().timelines.get(key)?.head).not.toEqual([]);
|
||||
|
||||
store.replaceList(SERVER_ID, PARENT_ID, [
|
||||
{
|
||||
id: SUBAGENT_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
provider: "codex",
|
||||
title: "Restored child",
|
||||
description: null,
|
||||
status: "completed",
|
||||
createdAt: "2026-07-12T10:00:00.000Z",
|
||||
updatedAt: "2026-07-12T10:00:02.000Z",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
]);
|
||||
|
||||
const timeline = useProviderSubagentStore.getState().timelines.get(key);
|
||||
expect(timeline?.head).toEqual([]);
|
||||
expect(timeline?.tail).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "Restored output." }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps late timeline rows terminal after the descriptor completes", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.applyUpdate(SERVER_ID, {
|
||||
kind: "upsert",
|
||||
subagent: {
|
||||
id: SUBAGENT_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
provider: "codex",
|
||||
title: "Restored child",
|
||||
description: null,
|
||||
status: "completed",
|
||||
createdAt: "2026-07-12T10:00:00.000Z",
|
||||
updatedAt: "2026-07-12T10:00:02.000Z",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
});
|
||||
store.applyUpdate(SERVER_ID, {
|
||||
kind: "timeline",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
epoch: "epoch-1",
|
||||
seq: 1,
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "Late restored output." },
|
||||
});
|
||||
|
||||
const timeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID));
|
||||
expect(timeline?.head).toEqual([]);
|
||||
expect(timeline?.tail).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "Late restored output." }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("merges bounded older pages and tracks whether more history remains", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
requestId: "tail-page",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
direction: "tail",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 2, maxSeq: 2, nextSeq: 3 },
|
||||
hasOlder: true,
|
||||
hasNewer: false,
|
||||
rows: [
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-07-12T10:00:02.000Z",
|
||||
item: { type: "assistant_message", text: "Recent output." },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
});
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
requestId: "older-page",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
direction: "before",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
|
||||
hasOlder: false,
|
||||
hasNewer: true,
|
||||
rows: [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "Older output." },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
});
|
||||
|
||||
const timeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID));
|
||||
expect(timeline?.hasOlder).toBe(false);
|
||||
expect([...timeline!.rows.keys()]).toEqual([2, 1]);
|
||||
expect(timeline?.head).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "Older output.Recent output." }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores delayed live updates from a stale timeline epoch", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
requestId: "current-page",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
direction: "tail",
|
||||
epoch: "epoch-current",
|
||||
reset: true,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 2, maxSeq: 2, nextSeq: 3 },
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
rows: [
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-07-12T10:00:02.000Z",
|
||||
item: { type: "assistant_message", text: "Current output." },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
});
|
||||
|
||||
store.applyUpdate(SERVER_ID, {
|
||||
kind: "timeline",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
epoch: "epoch-stale",
|
||||
seq: 3,
|
||||
timestamp: "2026-07-12T10:00:03.000Z",
|
||||
item: { type: "assistant_message", text: "Stale output." },
|
||||
});
|
||||
|
||||
const timeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID));
|
||||
expect(timeline?.epoch).toBe("epoch-current");
|
||||
expect([...timeline!.rows.keys()]).toEqual([2]);
|
||||
expect(timeline?.head).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "Current output." }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("replaces cached rows with an authoritative tail page after a reconnect gap", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
requestId: "old-tail",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
direction: "tail",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 500, nextSeq: 501 },
|
||||
hasOlder: true,
|
||||
hasNewer: false,
|
||||
rows: [
|
||||
{
|
||||
seq: 100,
|
||||
timestamp: "2026-07-12T10:00:00.000Z",
|
||||
item: { type: "assistant_message", text: "Old cached output." },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
});
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
requestId: "reconnect-tail",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
direction: "tail",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 500, nextSeq: 501 },
|
||||
hasOlder: true,
|
||||
hasNewer: false,
|
||||
rows: [
|
||||
{
|
||||
seq: 401,
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "Current tail output." },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
});
|
||||
|
||||
const timeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID));
|
||||
expect([...timeline!.rows.keys()]).toEqual([401]);
|
||||
expect(timeline?.head).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "Current tail output." }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import type {
|
||||
AgentStreamEventPayload,
|
||||
ProviderSubagentDescriptorPayload,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { create } from "zustand";
|
||||
import { applyStreamEvent } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
||||
|
||||
type ProviderSubagentTimelineItem = Extract<
|
||||
Extract<SessionOutboundMessage, { type: "agent.provider_subagents.update" }>["payload"],
|
||||
{ kind: "timeline" }
|
||||
>["item"];
|
||||
|
||||
interface ProviderSubagentTimelineRow {
|
||||
provider: ProviderSubagentDescriptorPayload["provider"];
|
||||
item: ProviderSubagentTimelineItem;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ProviderSubagentTimelineState {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
epoch: string | null;
|
||||
lastSeq: number;
|
||||
hasOlder: boolean;
|
||||
rows: Map<number, ProviderSubagentTimelineRow>;
|
||||
}
|
||||
|
||||
interface ProviderSubagentState {
|
||||
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
|
||||
timelines: Map<string, ProviderSubagentTimelineState>;
|
||||
replaceList(
|
||||
serverId: string,
|
||||
parentAgentId: string,
|
||||
subagents: ProviderSubagentDescriptorPayload[],
|
||||
): void;
|
||||
applyUpdate(
|
||||
serverId: string,
|
||||
payload: Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent.provider_subagents.update" }
|
||||
>["payload"],
|
||||
): void;
|
||||
replaceTimeline(
|
||||
serverId: string,
|
||||
payload: Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent.provider_subagents.timeline.get.response" }
|
||||
>["payload"],
|
||||
): void;
|
||||
}
|
||||
|
||||
export function providerSubagentKey(
|
||||
serverId: string,
|
||||
parentAgentId: string,
|
||||
subagentId: string,
|
||||
): string {
|
||||
return `${serverId}\0${parentAgentId}\0${subagentId}`;
|
||||
}
|
||||
|
||||
export function providerSubagentLifecycleStatus(
|
||||
status: ProviderSubagentDescriptorPayload["status"],
|
||||
): AgentLifecycleStatus {
|
||||
if (status === "running") return "running";
|
||||
if (status === "failed") return "error";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
type ProviderSubagentListClient = Pick<DaemonClient, "listProviderSubagents">;
|
||||
|
||||
const pendingListRequests = new WeakMap<ProviderSubagentListClient, Map<string, Promise<void>>>();
|
||||
|
||||
export function refreshProviderSubagents(
|
||||
client: ProviderSubagentListClient,
|
||||
serverId: string,
|
||||
parentAgentId: string,
|
||||
): Promise<void> {
|
||||
const requestKey = `${serverId}\0${parentAgentId}`;
|
||||
let clientRequests = pendingListRequests.get(client);
|
||||
if (!clientRequests) {
|
||||
clientRequests = new Map();
|
||||
pendingListRequests.set(client, clientRequests);
|
||||
}
|
||||
const pending = clientRequests.get(requestKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = client
|
||||
.listProviderSubagents(parentAgentId)
|
||||
.then((payload) => {
|
||||
useProviderSubagentStore.getState().replaceList(serverId, parentAgentId, payload.subagents);
|
||||
return undefined;
|
||||
})
|
||||
.finally(() => {
|
||||
clientRequests?.delete(requestKey);
|
||||
});
|
||||
clientRequests.set(requestKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function parentPrefix(serverId: string, parentAgentId: string): string {
|
||||
return `${serverId}\0${parentAgentId}\0`;
|
||||
}
|
||||
|
||||
const EMPTY_TIMELINE: ProviderSubagentTimelineState = {
|
||||
tail: [],
|
||||
head: [],
|
||||
epoch: null,
|
||||
lastSeq: 0,
|
||||
hasOlder: false,
|
||||
rows: new Map(),
|
||||
};
|
||||
|
||||
function providerSubagentTerminalEvent(
|
||||
subagent: ProviderSubagentDescriptorPayload,
|
||||
): AgentStreamEventPayload | null {
|
||||
if (subagent.status === "running") {
|
||||
return null;
|
||||
}
|
||||
if (subagent.status === "failed") {
|
||||
return { type: "turn_failed", provider: subagent.provider, error: "Subagent failed" };
|
||||
}
|
||||
if (subagent.status === "canceled") {
|
||||
return { type: "turn_canceled", provider: subagent.provider, reason: "canceled" };
|
||||
}
|
||||
return { type: "turn_completed", provider: subagent.provider };
|
||||
}
|
||||
|
||||
function buildTimelineState(
|
||||
rows: ProviderSubagentTimelineState["rows"],
|
||||
epoch: string | null,
|
||||
descriptor?: ProviderSubagentDescriptorPayload,
|
||||
hasOlder = false,
|
||||
): ProviderSubagentTimelineState {
|
||||
let timeline = { tail: [] as StreamItem[], head: [] as StreamItem[] };
|
||||
for (const [, row] of [...rows].sort(([left], [right]) => left - right)) {
|
||||
timeline = applyStreamEvent({
|
||||
...timeline,
|
||||
event: { type: "timeline", provider: row.provider, item: row.item },
|
||||
timestamp: new Date(row.timestamp),
|
||||
});
|
||||
}
|
||||
const terminalEvent = descriptor ? providerSubagentTerminalEvent(descriptor) : null;
|
||||
if (terminalEvent && descriptor) {
|
||||
timeline = applyStreamEvent({
|
||||
...timeline,
|
||||
event: terminalEvent,
|
||||
timestamp: new Date(descriptor.updatedAt),
|
||||
});
|
||||
}
|
||||
return {
|
||||
...timeline,
|
||||
epoch,
|
||||
lastSeq: rows.size ? Math.max(...rows.keys()) : 0,
|
||||
hasOlder,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTimelineResponseRows(
|
||||
existing: ProviderSubagentTimelineState | undefined,
|
||||
payload: Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent.provider_subagents.timeline.get.response" }
|
||||
>["payload"],
|
||||
provider: ProviderSubagentDescriptorPayload["provider"],
|
||||
): ProviderSubagentTimelineState["rows"] {
|
||||
const rows = new Map<number, ProviderSubagentTimelineRow>();
|
||||
for (const row of payload.rows) {
|
||||
rows.set(row.seq, { provider, item: row.item, timestamp: row.timestamp });
|
||||
}
|
||||
if (payload.reset || existing?.epoch !== payload.epoch) {
|
||||
return rows;
|
||||
}
|
||||
if (payload.direction !== "tail") {
|
||||
return new Map([...existing.rows, ...rows]);
|
||||
}
|
||||
|
||||
let nextSeq = payload.rows.length
|
||||
? Math.max(...payload.rows.map((row) => row.seq)) + 1
|
||||
: payload.window.maxSeq + 1;
|
||||
for (const [seq, row] of [...existing.rows].sort(([left], [right]) => left - right)) {
|
||||
if (seq < nextSeq) continue;
|
||||
if (seq !== nextSeq) break;
|
||||
rows.set(seq, row);
|
||||
nextSeq += 1;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export const useProviderSubagentStore = create<ProviderSubagentState>((set) => ({
|
||||
descriptors: new Map(),
|
||||
timelines: new Map(),
|
||||
replaceList(serverId, parentAgentId, subagents) {
|
||||
set((state) => {
|
||||
const prefix = parentPrefix(serverId, parentAgentId);
|
||||
const descriptors = new Map(
|
||||
[...state.descriptors].filter(([key]) => !key.startsWith(prefix)),
|
||||
);
|
||||
for (const subagent of subagents) {
|
||||
descriptors.set(providerSubagentKey(serverId, parentAgentId, subagent.id), subagent);
|
||||
}
|
||||
const retainedKeys = new Set(descriptors.keys());
|
||||
const timelines = new Map(
|
||||
[...state.timelines].filter(([key]) => !key.startsWith(prefix) || retainedKeys.has(key)),
|
||||
);
|
||||
for (const subagent of subagents) {
|
||||
const key = providerSubagentKey(serverId, parentAgentId, subagent.id);
|
||||
const current = timelines.get(key);
|
||||
const previous = state.descriptors.get(key);
|
||||
if (current && previous?.status !== subagent.status) {
|
||||
timelines.set(
|
||||
key,
|
||||
buildTimelineState(current.rows, current.epoch, subagent, current.hasOlder),
|
||||
);
|
||||
}
|
||||
}
|
||||
return { descriptors, timelines };
|
||||
});
|
||||
},
|
||||
applyUpdate(serverId, payload) {
|
||||
set((state) => {
|
||||
if (payload.kind === "upsert") {
|
||||
const key = providerSubagentKey(
|
||||
serverId,
|
||||
payload.subagent.parentAgentId,
|
||||
payload.subagent.id,
|
||||
);
|
||||
const descriptors = new Map(state.descriptors);
|
||||
const previous = descriptors.get(key);
|
||||
descriptors.set(key, payload.subagent);
|
||||
let timelines = state.timelines;
|
||||
const current = state.timelines.get(key);
|
||||
if (current && previous?.status !== payload.subagent.status) {
|
||||
timelines = new Map(state.timelines);
|
||||
timelines.set(
|
||||
key,
|
||||
buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder),
|
||||
);
|
||||
}
|
||||
return { descriptors, timelines };
|
||||
}
|
||||
if (payload.kind === "remove") {
|
||||
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
||||
const descriptors = new Map(state.descriptors);
|
||||
const timelines = new Map(state.timelines);
|
||||
descriptors.delete(key);
|
||||
timelines.delete(key);
|
||||
return { descriptors, timelines };
|
||||
}
|
||||
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
||||
const existing = state.timelines.get(key);
|
||||
if (existing?.epoch && existing.epoch !== payload.epoch) {
|
||||
return state;
|
||||
}
|
||||
const current = existing ?? EMPTY_TIMELINE;
|
||||
if (payload.seq <= current.lastSeq) {
|
||||
return state;
|
||||
}
|
||||
const rows = new Map(current.rows);
|
||||
rows.set(payload.seq, {
|
||||
provider: payload.provider,
|
||||
item: payload.item,
|
||||
timestamp: payload.timestamp,
|
||||
});
|
||||
const descriptor = state.descriptors.get(key);
|
||||
const next =
|
||||
descriptor && descriptor.status !== "running"
|
||||
? buildTimelineState(rows, payload.epoch, descriptor, current.hasOlder)
|
||||
: applyStreamEvent({
|
||||
tail: current.tail,
|
||||
head: current.head,
|
||||
event: { type: "timeline", provider: payload.provider, item: payload.item },
|
||||
timestamp: new Date(payload.timestamp),
|
||||
});
|
||||
const timelines = new Map(state.timelines);
|
||||
timelines.set(key, {
|
||||
...next,
|
||||
epoch: payload.epoch,
|
||||
lastSeq: payload.seq,
|
||||
hasOlder: current.hasOlder,
|
||||
rows,
|
||||
});
|
||||
return { timelines };
|
||||
});
|
||||
},
|
||||
replaceTimeline(serverId, payload) {
|
||||
const provider = payload.provider;
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
set((state) => {
|
||||
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
||||
const existing = state.timelines.get(key);
|
||||
const rows = buildTimelineResponseRows(existing, payload, provider);
|
||||
const descriptor = state.descriptors.get(key);
|
||||
const timelines = new Map(state.timelines);
|
||||
timelines.set(key, buildTimelineState(rows, payload.epoch, descriptor, payload.hasOlder));
|
||||
return { timelines };
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { selectSubagentsForParent } from "./select";
|
||||
import { selectProviderSubagentsForParent, selectSubagentsForParent } from "./select";
|
||||
import { useProviderSubagentStore } from "./provider-store";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
@@ -58,9 +59,37 @@ function setAgents(agents: Agent[]): void {
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() });
|
||||
});
|
||||
|
||||
describe("selectSubagentsForParent", () => {
|
||||
it("hides cached provider children when the host does not support them", () => {
|
||||
useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
|
||||
kind: "upsert",
|
||||
subagent: {
|
||||
id: "provider-child",
|
||||
parentAgentId: "parent-a",
|
||||
provider: "codex",
|
||||
title: "Provider child",
|
||||
description: null,
|
||||
status: "completed",
|
||||
createdAt: "2026-03-08T10:01:00.000Z",
|
||||
updatedAt: "2026-03-08T10:02:00.000Z",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
});
|
||||
const params = { serverId: SERVER_ID, parentAgentId: "parent-a" };
|
||||
|
||||
expect(
|
||||
selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, false),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, true).map(
|
||||
(row) => row.id,
|
||||
),
|
||||
).toEqual(["provider-child"]);
|
||||
});
|
||||
|
||||
it("returns only non-archived children for the requested parent", () => {
|
||||
setAgents([
|
||||
makeAgent({ id: "parent-a" }),
|
||||
@@ -194,6 +223,7 @@ describe("selectSubagentsForParent", () => {
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
kind: "paseo",
|
||||
id: "child",
|
||||
provider: "claude",
|
||||
title: "Review child",
|
||||
@@ -205,6 +235,7 @@ describe("selectSubagentsForParent", () => {
|
||||
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
|
||||
"createdAt",
|
||||
"id",
|
||||
"kind",
|
||||
"provider",
|
||||
"requiresAttention",
|
||||
"status",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent";
|
||||
import equal from "fast-deep-equal";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { refreshProviderSubagents, useProviderSubagentStore } from "./provider-store";
|
||||
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
|
||||
export interface SubagentRow {
|
||||
export interface PaseoSubagentRow {
|
||||
kind: "paseo";
|
||||
id: Agent["id"];
|
||||
provider: Agent["provider"];
|
||||
title: Agent["title"];
|
||||
@@ -12,7 +16,21 @@ export interface SubagentRow {
|
||||
createdAt: Agent["createdAt"];
|
||||
}
|
||||
|
||||
export interface ProviderSubagentRow {
|
||||
kind: "provider";
|
||||
id: string;
|
||||
parentAgentId: string;
|
||||
provider: ProviderSubagentDescriptorPayload["provider"];
|
||||
title: string | null;
|
||||
status: ProviderSubagentDescriptorPayload["status"];
|
||||
requiresAttention: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type SubagentRow = PaseoSubagentRow | ProviderSubagentRow;
|
||||
|
||||
type SessionStoreSnapshot = ReturnType<typeof useSessionStore.getState>;
|
||||
type ProviderSubagentStoreSnapshot = ReturnType<typeof useProviderSubagentStore.getState>;
|
||||
|
||||
interface SelectSubagentsParams {
|
||||
serverId: string;
|
||||
@@ -20,9 +38,11 @@ interface SelectSubagentsParams {
|
||||
}
|
||||
|
||||
const EMPTY_SUBAGENT_ROWS: SubagentRow[] = [];
|
||||
const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = [];
|
||||
|
||||
function toSubagentRow(agent: Agent): SubagentRow {
|
||||
return {
|
||||
kind: "paseo",
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
title: agent.title,
|
||||
@@ -62,11 +82,59 @@ export function selectSubagentsForParent(
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function selectProviderSubagentsForParent(
|
||||
state: ProviderSubagentStoreSnapshot,
|
||||
params: SelectSubagentsParams,
|
||||
supported: boolean,
|
||||
): ProviderSubagentRow[] {
|
||||
if (!supported) return EMPTY_PROVIDER_SUBAGENT_ROWS;
|
||||
const rows: ProviderSubagentRow[] = [];
|
||||
const prefix = `${params.serverId}\0${params.parentAgentId}\0`;
|
||||
for (const [key, subagent] of state.descriptors) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
rows.push({
|
||||
kind: "provider",
|
||||
id: subagent.id,
|
||||
parentAgentId: subagent.parentAgentId,
|
||||
provider: subagent.provider,
|
||||
title: subagent.title ?? subagent.description,
|
||||
status: subagent.status,
|
||||
requiresAttention: subagent.status === "failed",
|
||||
createdAt: new Date(subagent.createdAt),
|
||||
});
|
||||
}
|
||||
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
|
||||
const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId);
|
||||
return useStoreWithEqualityFn(
|
||||
const paseoRows = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => selectSubagentsForParent(state, params, pendingArchiveIds),
|
||||
equal,
|
||||
);
|
||||
const supported = useSessionStore(
|
||||
(state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true,
|
||||
);
|
||||
const providerRows = useStoreWithEqualityFn(
|
||||
useProviderSubagentStore,
|
||||
(state) => selectProviderSubagentsForParent(state, params, supported),
|
||||
equal,
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[params.serverId]?.client ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !supported) return;
|
||||
void refreshProviderSubagents(client, params.serverId, params.parentAgentId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}, [client, params.parentAgentId, params.serverId, supported]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (providerRows.length === 0) return paseoRows;
|
||||
const rows = [...paseoRows, ...providerRows];
|
||||
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
|
||||
return rows;
|
||||
}, [paseoRows, providerRows]);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SubagentRow } from "./select";
|
||||
import type { PaseoSubagentRow, SubagentRow } from "./select";
|
||||
import {
|
||||
buildSubagentRowPresentationData,
|
||||
formatHeaderLabel,
|
||||
resolveRowLabel,
|
||||
} from "./track-presentation";
|
||||
|
||||
function row(overrides: Partial<SubagentRow> & Pick<SubagentRow, "id">): SubagentRow {
|
||||
function row(
|
||||
overrides: Partial<PaseoSubagentRow> & Pick<PaseoSubagentRow, "id">,
|
||||
): PaseoSubagentRow {
|
||||
return {
|
||||
kind: "paseo",
|
||||
id: overrides.id,
|
||||
provider: overrides.provider ?? "codex",
|
||||
title: overrides.title ?? `Agent ${overrides.id}`,
|
||||
@@ -91,7 +94,9 @@ describe("resolveRowLabel", () => {
|
||||
|
||||
describe("buildSubagentRowPresentationData", () => {
|
||||
it("namespaces the key with a subagent prefix", () => {
|
||||
expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe("subagent_child-a");
|
||||
expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe(
|
||||
"paseo_subagent_child-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks the row ready when the title resolves to a real label", () => {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import type { SubagentRow } from "./select";
|
||||
import { providerSubagentLifecycleStatus } from "./provider-store";
|
||||
|
||||
function presentationStatus(row: SubagentRow) {
|
||||
if (row.kind === "paseo") return row.status;
|
||||
return providerSubagentLifecycleStatus(row.status);
|
||||
}
|
||||
|
||||
export interface SubagentRowPresentationData {
|
||||
key: string;
|
||||
@@ -13,14 +19,15 @@ export interface SubagentRowPresentationData {
|
||||
|
||||
export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowPresentationData {
|
||||
const label = resolveRowLabel(row.title);
|
||||
const status = presentationStatus(row);
|
||||
return {
|
||||
key: `subagent_${row.id}`,
|
||||
key: `${row.kind}_subagent_${row.id}`,
|
||||
kind: "agent",
|
||||
label: label ?? "",
|
||||
subtitle: "",
|
||||
titleState: label ? "ready" : "loading",
|
||||
statusBucket: deriveSidebarStateBucket({
|
||||
status: row.status,
|
||||
status,
|
||||
requiresAttention: false,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
export interface SubagentsTrackProps {
|
||||
rows: SubagentRow[];
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
@@ -44,6 +45,7 @@ function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
|
||||
export function SubagentsTrack({
|
||||
rows,
|
||||
onOpenSubagent,
|
||||
onOpenProviderSubagent,
|
||||
onArchiveSubagent,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackProps): ReactElement | null {
|
||||
@@ -105,6 +107,7 @@ export function SubagentsTrack({
|
||||
key={row.id}
|
||||
row={row}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||
onArchiveSubagent={onArchiveSubagent}
|
||||
onDetachSubagent={onDetachSubagent}
|
||||
/>
|
||||
@@ -120,6 +123,7 @@ export function SubagentsTrack({
|
||||
interface SubagentsTrackRowProps {
|
||||
row: SubagentRow;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
@@ -127,6 +131,7 @@ interface SubagentsTrackRowProps {
|
||||
function SubagentsTrackRow({
|
||||
row,
|
||||
onOpenSubagent,
|
||||
onOpenProviderSubagent,
|
||||
onArchiveSubagent,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackRowProps): ReactElement {
|
||||
@@ -137,8 +142,12 @@ function SubagentsTrackRow({
|
||||
const displayLabel =
|
||||
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
||||
const handlePress = useCallback(() => {
|
||||
onOpenSubagent(row.id);
|
||||
}, [onOpenSubagent, row.id]);
|
||||
if (row.kind === "provider") {
|
||||
onOpenProviderSubagent(row.parentAgentId, row.id);
|
||||
} else {
|
||||
onOpenSubagent(row.id);
|
||||
}
|
||||
}, [onOpenProviderSubagent, onOpenSubagent, row]);
|
||||
const handleArchivePress = useCallback(() => {
|
||||
onArchiveSubagent(row.id);
|
||||
}, [onArchiveSubagent, row.id]);
|
||||
@@ -167,13 +176,15 @@ function SubagentsTrackRow({
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||
{displayLabel}
|
||||
</Text>
|
||||
<SubagentRowActions
|
||||
rowId={row.id}
|
||||
displayLabel={displayLabel}
|
||||
visible={actionsVisible}
|
||||
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
||||
onArchivePress={handleArchivePress}
|
||||
/>
|
||||
{row.kind === "paseo" ? (
|
||||
<SubagentRowActions
|
||||
rowId={row.id}
|
||||
displayLabel={displayLabel}
|
||||
visible={actionsVisible}
|
||||
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
||||
onArchivePress={handleArchivePress}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "./identity";
|
||||
|
||||
describe("provider subagent tab identity", () => {
|
||||
test("normalizes and compares the parent and provider child as one tab identity", () => {
|
||||
const target = normalizeWorkspaceTabTarget({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: " parent-a ",
|
||||
subagentId: " child-a ",
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: "parent-a",
|
||||
subagentId: "child-a",
|
||||
});
|
||||
expect(
|
||||
target &&
|
||||
workspaceTabTargetsEqual(target, {
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: "parent-a",
|
||||
subagentId: "child-a",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("does not collide when parent and child ids contain separators", () => {
|
||||
const first = buildDeterministicWorkspaceTabId({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: "a_b",
|
||||
subagentId: "c",
|
||||
});
|
||||
const second = buildDeterministicWorkspaceTabId({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: "a",
|
||||
subagentId: "b_c",
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,13 @@ export function normalizeWorkspaceTabTarget(
|
||||
const agentId = trimNonEmpty(value.agentId);
|
||||
return agentId ? { kind: "agent", agentId } : null;
|
||||
}
|
||||
if (value.kind === "provider_subagent") {
|
||||
const parentAgentId = trimNonEmpty(value.parentAgentId);
|
||||
const subagentId = trimNonEmpty(value.subagentId);
|
||||
return parentAgentId && subagentId
|
||||
? { kind: "provider_subagent", parentAgentId, subagentId }
|
||||
: null;
|
||||
}
|
||||
if (value.kind === "terminal") {
|
||||
const terminalId = trimNonEmpty(value.terminalId);
|
||||
return terminalId ? { kind: "terminal", terminalId } : null;
|
||||
@@ -76,6 +83,9 @@ export function workspaceTabTargetsEqual(
|
||||
if (left.kind === "agent" && right.kind === "agent") {
|
||||
return left.agentId === right.agentId;
|
||||
}
|
||||
if (left.kind === "provider_subagent" && right.kind === "provider_subagent") {
|
||||
return left.parentAgentId === right.parentAgentId && left.subagentId === right.subagentId;
|
||||
}
|
||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||
return left.terminalId === right.terminalId;
|
||||
}
|
||||
@@ -131,6 +141,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
|
||||
if (target.kind === "agent") {
|
||||
return `agent_${target.agentId}`;
|
||||
}
|
||||
if (target.kind === "provider_subagent") {
|
||||
return `provider_subagent_${target.parentAgentId.length}_${target.parentAgentId}_${target.subagentId.length}_${target.subagentId}`;
|
||||
}
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user