feat(agent,ui): nested subagent timeline + always-on plan panel

This commit is contained in:
matevip 2026-05-22 09:48:06 +08:00
parent 66af70388b
commit 8bd8a02cd0
7 changed files with 267 additions and 78 deletions

View File

@ -394,6 +394,11 @@ public class ChatStreamTracker {
|| "error".equals(eventName)
|| "tool_approval_requested".equals(eventName)
|| "phase".equals(eventName)
// Plan lifecycle events from a child agent: flush buffered
// tool calls first so the parent timeline preserves order.
|| "plan_created".equals(eventName)
|| "plan_step_started".equals(eventName)
|| "plan_step_completed".equals(eventName)
|| "done".equals(eventName);
}

View File

@ -890,6 +890,11 @@ public class DelegateAgentTool {
private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task,
String parentConversationId, String childConversationId,
ChatOrigin parentOrigin) {
boolean relayChildEvents = parentConversationId != null && streamTracker.isRunning(parentConversationId);
if (relayChildEvents) {
streamTracker.register(childConversationId);
streamTracker.incrementFlux(childConversationId);
}
DelegationContext.enter(parentConversationId, deniedToolsForChild());
try {
long startTime = System.currentTimeMillis();
@ -909,6 +914,9 @@ public class DelegateAgentTool {
taskIndex, target.getName(), e.getMessage());
return ChildResult.ofError(taskIndex, target.getName(), e.getMessage());
} finally {
if (relayChildEvents) {
streamTracker.complete(childConversationId);
}
DelegationContext.exit();
}
}
@ -1109,9 +1117,14 @@ public class DelegateAgentTool {
return childConvId;
}
/** Child event types that are relayed to the parent for the nested delegation timeline. */
private static final Set<String> RELAYED_CHILD_EVENTS = Set.of(
"tool_call_started", "tool_call_completed", "phase",
"plan_created", "plan_step_started", "plan_step_completed");
private Runnable registerRelay(String childConvId, String parentConvId, String childAgentName) {
return streamTracker.addEventRelay(childConvId, (eventName, jsonData) -> {
if ("tool_call_started".equals(eventName) || "tool_call_completed".equals(eventName) || "phase".equals(eventName)) {
if (RELAYED_CHILD_EVENTS.contains(eventName)) {
try {
// Parse jsonData into a plain Object so the frontend receives a proper
// JSON object under "data", not a string containing serialized JSON.
@ -1149,28 +1162,65 @@ public class DelegateAgentTool {
private Runnable registerBatchedRelay(String childConvId, String parentConvId, String childAgentName) {
return streamTracker.addBatchedEventRelay(childConvId, parentConvId, 5, 500L,
(eventName, jsonData) -> {
if ("tool_call_started".equals(eventName)
|| "tool_call_completed".equals(eventName)
|| "phase".equals(eventName)) {
try {
Object parsedData;
try {
parsedData = objectMapper.readValue(jsonData, Object.class);
} catch (Exception ignored) {
parsedData = jsonData;
}
streamTracker.broadcastObject(parentConvId, "delegation_progress", Map.of(
"childConversationId", childConvId,
"childAgentName", childAgentName,
"originalEvent", eventName,
"data", parsedData));
} catch (Exception e) {
log.debug("Batched relay error: {}", e.getMessage());
}
// The batched relay delivers (1) pass-through events directly
// (plan/phase/error) and (2) batched tool-calls as a
// "delegation_batch" envelope. Unpack each form to a stream
// of delegation_progress events on the parent so the frontend
// only handles a single event shape (see useChat delegation_progress).
if ("delegation_batch".equals(eventName)) {
relayBatchEnvelope(jsonData, childConvId, parentConvId, childAgentName);
} else if (RELAYED_CHILD_EVENTS.contains(eventName)) {
relayChildEvent(eventName, jsonData, childConvId, parentConvId, childAgentName);
}
});
}
/** Forward one child event to the parent as a delegation_progress envelope. */
private void relayChildEvent(String eventName, String jsonData,
String childConvId, String parentConvId, String childAgentName) {
try {
Object parsedData;
try {
parsedData = objectMapper.readValue(jsonData, Object.class);
} catch (Exception ignored) {
parsedData = jsonData;
}
streamTracker.broadcastObject(parentConvId, "delegation_progress", Map.of(
"childConversationId", childConvId,
"childAgentName", childAgentName,
"originalEvent", eventName,
"data", parsedData));
} catch (Exception e) {
log.debug("Child event relay error: {}", e.getMessage());
}
}
/** Unpack a delegation_batch envelope and replay each entry as delegation_progress. */
@SuppressWarnings("unchecked")
private void relayBatchEnvelope(String envelopeJson, String childConvId,
String parentConvId, String childAgentName) {
try {
Map<String, Object> envelope = objectMapper.readValue(envelopeJson, Map.class);
Object eventsObj = envelope.get("events");
if (!(eventsObj instanceof List<?> events)) return;
for (Object entryObj : events) {
if (!(entryObj instanceof Map<?, ?> entry)) continue;
Object name = entry.get("event");
Object payload = entry.get("data");
if (name == null) continue;
if (!RELAYED_CHILD_EVENTS.contains(name.toString())) continue;
String payloadJson = payload == null
? "{}"
: (payload instanceof String s ? s : objectMapper.writeValueAsString(payload));
relayChildEvent(name.toString(),
payloadJson,
childConvId, parentConvId, childAgentName);
}
} catch (Exception e) {
log.debug("Batch envelope relay error: {}", e.getMessage());
}
}
private void broadcastEnd(String parentConvId, String childConvId, String agentName, ChildResult result) {
streamTracker.broadcastObject(parentConvId, "delegation_end", Map.of(
"childConversationId", childConvId,

View File

@ -30,11 +30,14 @@
<!-- 消息体 -->
<div class="msg-body" :class="`${role}-body`">
<div class="msg-bubble" :class="`${role}-bubble`">
<!-- Plan-step panel always rendered at the top of the bubble whenever
this turn has a plan, in both the segmented and fallback render
paths, so plan-mode progress is never buried in a collapsed panel. -->
<PlanStepsPanel v-if="planMeta" :plan="planMeta" :is-generating="isGenerating" />
<!-- ===== 分段式渲染模式Claude Code 风格===== -->
<template v-if="useSegmentedView">
<div class="segments-view">
<!-- 计划步骤面板始终显示在 segments 之上 -->
<PlanStepsPanel v-if="planMeta" :plan="planMeta" :is-generating="isGenerating" />
<template v-for="iter in groupedIterations" :key="iter.key">
<!-- Iteration interrupted before any output landed surface a chip
so the user knows the agent moved on instead of silently
@ -117,9 +120,6 @@
<Transition name="thinking-slide">
<div v-if="executionExpanded" class="execution-content">
<!-- Plan 步骤进度 -->
<PlanStepsPanel v-if="planMeta" :plan="planMeta" :is-generating="isGenerating" />
<!-- 工具调用列表 -->
<div v-if="toolCallsMeta.length" class="tool-calls">
<div
@ -139,7 +139,7 @@
</div>
</div>
<div v-if="!toolCallsMeta.length && !planMeta" class="execution-empty">
<div v-if="!toolCallsMeta.length" class="execution-empty">
{{ currentPhaseName }}...
</div>
</div>
@ -974,8 +974,18 @@ const segments = computed<MessageSegment[]>(() => {
return segs
})
/** 是否使用分段模式渲染(有 segments 数据且包含多个分段) */
const useSegmentedView = computed(() => segments.value.length > 1)
/**
* Use segmented rendering when there are multiple segments, OR when the turn
* contains a delegation segment. Delegations live in `segments` but not in
* `metadata.toolCalls`, so the fallback path (which only reads toolCalls)
* renders nothing for them a single-step plan that delegates to a subagent
* would otherwise show the subagent call as completely invisible. Forcing
* segmented view here makes delegation surface as a timeline entry.
*/
const useSegmentedView = computed(() =>
segments.value.length > 1 ||
segments.value.some(s => s.type === 'tool_call' && (s.toolName || '').startsWith('→'))
)
/**
* Group segments by iterationIndex so each ReAct iteration renders as its own
@ -1168,8 +1178,9 @@ const executionPhaseLabel = computed(() => {
const showExecutionPanel = computed(() => {
if (role.value !== 'assistant') return false
// execution panel
return toolCallsMeta.value.length > 0 || !!planMeta.value
// The plan-step panel renders top-level outside this execution panel,
// so plan presence alone no longer keeps an (otherwise empty) panel open.
return toolCallsMeta.value.length > 0
|| (isGenerating.value && parsedMetadata.value?.currentPhase)
|| !!pendingApproval.value
})

View File

@ -56,11 +56,45 @@ const isRead = computed(() => {
const isSuccess = computed(() => props.segment.status === 'completed' && props.segment.toolSuccess !== false)
const isError = computed(() => props.segment.status === 'error' || props.segment.toolSuccess === false)
const isRunning = computed(() => props.segment.status === 'running')
// Nested subagent timeline relayed from the child conversation: the child's own
// plan checklist + the tools it called. Only present on delegation segments.
const childTimeline = computed(() => isDelegation.value ? props.segment.childTimeline : undefined)
const childPlan = computed(() => childTimeline.value?.plan)
const childTools = computed(() => childTimeline.value?.tools || [])
const hasChildActivity = computed(() =>
!!childPlan.value || childTools.value.length > 0
)
// The body is expandable when there's any nested detail to show either the
// child's activity timeline or the final result preview.
const hasBody = computed(() => hasChildActivity.value || !!props.segment.toolResult)
function childStepStatus(i: number): 'pending' | 'running' | 'completed' {
const plan = childPlan.value
if (!plan) return 'pending'
const done = plan.stepResults?.[i]
if (done?.status === 'completed') return 'completed'
if (i === plan.currentStep) return 'running'
return 'pending'
}
// Compact progress hint shown in the delegation header (e.g. "2/3" plan steps,
// or "4 tools") so collapsed delegations still convey what the subagent did.
const childProgress = computed(() => {
const plan = childPlan.value
if (plan?.steps?.length) {
const done = plan.stepResults?.filter(r => r?.status === 'completed').length || 0
return `${done}/${plan.steps.length}`
}
const n = childTools.value.length
return n ? `${n} ${n === 1 ? 'tool' : 'tools'}` : ''
})
</script>
<template>
<div class="seg-tool" :class="{ 'is-running': isRunning, 'is-success': isSuccess, 'is-error': isError }">
<div class="seg-tool__header" @click="segment.toolResult ? (expanded = !expanded) : null">
<div class="seg-tool__header" @click="hasBody ? (expanded = !expanded) : null">
<span class="seg-tool__status">
<el-icon v-if="isRunning" class="is-loading" :size="13"><Loading /></el-icon>
<el-icon v-else-if="isSuccess" :size="13"><Select /></el-icon>
@ -72,17 +106,50 @@ const isRunning = computed(() => props.segment.status === 'running')
<el-icon v-else :size="12"><Setting /></el-icon>
</span>
<span class="seg-tool__name">{{ displayName }}</span>
<span v-if="isDelegation && childProgress" class="seg-tool__badge">{{ childProgress }}</span>
<span v-if="truncatedArgs" class="seg-tool__args">{{ truncatedArgs }}</span>
<el-icon
v-if="segment.toolResult"
v-if="hasBody"
class="seg-tool__arrow"
:class="{ 'is-open': expanded }"
:size="11"
><ArrowDown /></el-icon>
</div>
<Transition name="seg-slide">
<div v-if="expanded && segment.toolResult" class="seg-tool__body">
<pre>{{ resultPreview }}</pre>
<div v-if="expanded && hasBody" class="seg-tool__body">
<!-- Nested subagent timeline (delegation segments) -->
<div v-if="hasChildActivity" class="seg-child">
<!-- The child agent's own plan checklist, if it ran in plan mode -->
<div v-if="childPlan" class="seg-child__plan">
<div
v-for="(step, i) in childPlan.steps"
:key="i"
class="seg-child__step"
:class="`is-${childStepStatus(i)}`"
>
<el-icon v-if="childStepStatus(i) === 'running'" class="is-loading" :size="11"><Loading /></el-icon>
<el-icon v-else-if="childStepStatus(i) === 'completed'" :size="11"><Select /></el-icon>
<span v-else class="seg-child__dot"></span>
<span class="seg-child__step-text">{{ step }}</span>
</div>
</div>
<!-- The tools the child agent called -->
<div v-if="childTools.length" class="seg-child__tools">
<div
v-for="(t, i) in childTools"
:key="i"
class="seg-child__tool"
:class="`is-${t.status}`"
>
<el-icon v-if="t.status === 'running'" class="is-loading" :size="11"><Loading /></el-icon>
<el-icon v-else-if="t.status === 'completed'" :size="11"><Select /></el-icon>
<el-icon v-else :size="11"><CloseBold /></el-icon>
<span class="seg-child__tool-name">{{ getToolLabel(t.name) }}</span>
</div>
</div>
</div>
<!-- Final tool/agent result preview -->
<pre v-if="segment.toolResult">{{ resultPreview }}</pre>
</div>
</Transition>
</div>
@ -177,6 +244,51 @@ const isRunning = computed(() => props.segment.status === 'running')
.seg-tool__body {
padding: 0 10px 6px 22px;
}
.seg-tool__badge {
flex-shrink: 0;
font-size: 11px;
color: var(--mc-text-tertiary);
background: var(--mc-bg-muted);
border-radius: 8px;
padding: 0 6px;
line-height: 16px;
}
/* Nested subagent timeline */
.seg-child {
margin-bottom: 6px;
}
.seg-child__plan {
margin-bottom: 6px;
padding-left: 4px;
border-left: 2px solid var(--mc-border-light);
}
.seg-child__step,
.seg-child__tool {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
line-height: 1.7;
color: var(--mc-text-tertiary);
}
.seg-child__step.is-running,
.seg-child__tool.is-running { color: var(--mc-primary); }
.seg-child__step.is-completed,
.seg-child__tool.is-completed { color: var(--mc-text-secondary); }
.seg-child__tool.is-error { color: var(--mc-danger); }
.seg-child__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--mc-text-quaternary, #c0c0c0);
flex-shrink: 0;
margin: 0 3px;
}
.seg-child__tools {
padding-left: 6px;
}
.seg-tool__body pre {
margin: 0;
padding: 8px 10px;

View File

@ -746,8 +746,8 @@ export function useChat(options: UseChatOptions): UseChatReturn {
// ===== Agent event handlers =====
// Body of tool_call_started — extracted so delegation_batch can replay the
// same behavior for buffered child events without duplicating logic.
// Body of tool_call_started. Used directly and reused once (split out as a
// function ⟶ no logic duplication).
function handleToolCallStarted(data: any) {
if (isStaleEvent(data)) return
streamPhase.value = 'executing_tool'
@ -1001,31 +1001,48 @@ export function useChat(options: UseChatOptions): UseChatReturn {
? rawPayload
: (() => { try { return JSON.parse(String(rawPayload || '{}')) } catch { return {} } })()
if (data.originalEvent === 'tool_call_started') {
const toolName = childData?.toolName || ''
if (toolName) {
delegSeg.toolArgs = (delegSeg.toolArgs || '') + `\n → ${toolName}`
// Build a structured child timeline on the delegation segment instead of
// jamming tool names into toolArgs as text. The timeline holds the child
// agent's own plan checklist + the tools it called, so the UI can render
// a proper nested view (see ToolCallSegment.vue delegation branch).
const timeline = (delegSeg.childTimeline ||= { tools: [] })
if (!timeline.tools) timeline.tools = []
switch (data.originalEvent) {
case 'tool_call_started': {
const name = childData?.toolName || ''
if (name) timeline.tools.push({ name, status: 'running' })
break
}
} else if (data.originalEvent === 'tool_call_completed') {
const toolName = childData?.toolName || ''
const success = childData?.success !== false
if (toolName) {
// Replace the matching "→ toolName" hint with "✓/✗ toolName"
delegSeg.toolArgs = (delegSeg.toolArgs || '').replace(
new RegExp(`\\n → ${toolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`),
`\n ${success ? '✓' : '✗'} ${toolName}`)
case 'tool_call_completed': {
const name = childData?.toolName || ''
const ok = childData?.success !== false
// Match the most recent running entry with this name.
const entry = [...timeline.tools].reverse()
.find(t => t.name === name && t.status === 'running')
if (entry) entry.status = ok ? 'completed' : 'error'
break
}
} else if (data.originalEvent === 'phase') {
const phase = childData?.phase || String(rawPayload || '')
const phaseHints: Record<string, string> = {
reasoning: '…',
executing_tool: '→',
planning: '📋',
summarizing: '✍',
case 'plan_created': {
const steps = childData?.steps
if (Array.isArray(steps)) {
timeline.plan = { planId: childData?.planId ?? '', steps, currentStep: 0, stepResults: [] }
}
break
}
const hint = phaseHints[phase]
if (hint && !delegSeg.toolArgs?.endsWith(hint)) {
delegSeg.toolArgs = (delegSeg.toolArgs || '').trimEnd() + ' ' + hint
case 'plan_step_started': {
if (timeline.plan && typeof childData?.index === 'number') {
timeline.plan.currentStep = childData.index
}
break
}
case 'plan_step_completed': {
if (timeline.plan && typeof childData?.index === 'number') {
const results = [...(timeline.plan.stepResults || [])]
results[childData.index] = { result: childData.result ?? '', status: 'completed' }
timeline.plan.stepResults = results
}
break
}
}
flushSegmentsToMessage()
@ -1106,6 +1123,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (delegSeg) {
delegSeg.status = data.success ? 'completed' : 'error'
delegSeg.toolSuccess = data.success
if (data.resultPreview) {
delegSeg.toolResult = data.resultPreview
}
if (data.durationMs) {
delegSeg.toolArgs = (delegSeg.toolArgs || '').trimEnd() + ` (${Math.round(data.durationMs / 1000)}s)`
}
@ -1211,26 +1231,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
flushSegmentsToMessage()
})
stream.on('delegation_batch', (data) => {
if (isStaleEvent(data)) return
// Buffered child events from a delegated subagent. Replay them in order
// through the same handlers as live events so segment state stays
// consistent with the rest of the timeline.
const events = Array.isArray(data?.events) ? data.events : []
for (const ev of events) {
const evData = ev?.data ?? {}
switch (ev?.event) {
case 'tool_call_started':
handleToolCallStarted(evData)
break
case 'tool_call_completed':
handleToolCallCompleted(evData)
break
// Other event kinds (phase / thinking_delta / content_delta / etc.)
// are not currently produced inside batches; extend here when added.
}
}
})
// Delegation batch envelopes are unpacked server-side into individual
// delegation_progress events (see DelegateAgentTool.relayBatchEnvelope),
// so the frontend only handles delegation_progress — no batch handler needed.
stream.on('plan_created', (data) => {
if (isStaleEvent(data)) return

View File

@ -67,7 +67,6 @@ export type SSEEventType =
| 'iteration_end'
| 'content_truncated'
| 'tool_result_chunk'
| 'delegation_batch'
// Recovery affordance for non-transient errors (ERROR_FALLBACK turns)
| 'feedback_event'
// Context compaction lifecycle. Fired by ConversationWindowManager

View File

@ -168,6 +168,15 @@ export interface MessageSegment {
approval?: PendingApprovalMeta
/** type=plan */
plan?: PlanMeta
/**
* For delegation segments (toolName starts with "→"): the subagent's own
* activity, relayed from the child conversation. Renders as a nested timeline
* (its plan checklist + the tools it called) instead of jammed text in toolArgs.
*/
childTimeline?: {
plan?: PlanMeta
tools?: { name: string; status: 'running' | 'completed' | 'error' }[]
}
/** 时间戳 */
timestamp?: number
/**