🐛 fix: fix multi agent tasks issue (#11672)

* improve run multi tasks ui

* improve group mode

* 🐛 fix: remove unused isCompleted variable in TaskTitle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-01-21 16:11:53 +08:00
committed by GitHub
parent 8443904600
commit 9de773ba7d
17 changed files with 744 additions and 150 deletions

View File

@@ -118,10 +118,11 @@ export interface ExecutorResultSupervisorDecided {
* - 'speak': Call a single agent
* - 'broadcast': Call multiple agents in parallel
* - 'delegate': Delegate to another agent
* - 'execute_task': Execute an async task
* - 'execute_task': Execute a single async task
* - 'execute_tasks': Execute multiple async tasks in parallel
* - 'finish': End the orchestration
*/
decision: 'speak' | 'broadcast' | 'delegate' | 'execute_task' | 'finish';
decision: 'speak' | 'broadcast' | 'delegate' | 'execute_task' | 'execute_tasks' | 'finish';
/**
* Parameters for the decision
*/

View File

@@ -0,0 +1,117 @@
'use client';
import { DEFAULT_AVATAR } from '@lobechat/const';
import type { AgentGroupMember, BuiltinRenderProps } from '@lobechat/types';
import { Avatar, Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles, useTheme } from 'antd-style';
import { Clock } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentGroupStore } from '@/store/agentGroup';
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
import type { ExecuteTasksParams } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
display: flex;
flex-direction: column;
gap: 12px;
padding-block: 12px;
`,
taskCard: css`
padding: 12px;
border-radius: 8px;
background: ${cssVar.colorFillQuaternary};
`,
taskContent: css`
padding-block: 8px;
padding-inline: 12px;
border-radius: ${cssVar.borderRadius};
background: ${cssVar.colorFillTertiary};
`,
taskHeader: css`
font-size: 13px;
font-weight: 500;
color: ${cssVar.colorText};
`,
timeout: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
}));
/**
* ExecuteTasks Render component for Group Management tool
* Read-only display of multiple task execution requests
*/
const ExecuteTasksRender = memo<BuiltinRenderProps<ExecuteTasksParams>>(({ args }) => {
const { t } = useTranslation('tool');
const theme = useTheme();
const { tasks } = args || {};
// Get active group ID and agents from store
const activeGroupId = useAgentGroupStore(agentGroupSelectors.activeGroupId);
const groupAgents = useAgentGroupStore((s) =>
activeGroupId ? agentGroupSelectors.getGroupAgents(activeGroupId)(s) : [],
);
// Get agent details for each task
const tasksWithAgents = useMemo(() => {
if (!tasks?.length || !groupAgents.length) return [];
return tasks.map((task) => ({
...task,
agent: groupAgents.find((agent) => agent.id === task.agentId) as AgentGroupMember | undefined,
}));
}, [tasks, groupAgents]);
if (!tasksWithAgents.length) return null;
return (
<div className={styles.container}>
{tasksWithAgents.map((task, index) => {
const timeoutMinutes = task.timeout ? Math.round(task.timeout / 60_000) : 30;
return (
<div className={styles.taskCard} key={task.agentId || index}>
<Flexbox gap={12}>
{/* Header: Agent info + Timeout */}
<Flexbox align={'center'} gap={12} horizontal justify={'space-between'}>
<Flexbox align={'center'} flex={1} gap={8} horizontal style={{ minWidth: 0 }}>
<Avatar
avatar={task.agent?.avatar || DEFAULT_AVATAR}
background={task.agent?.backgroundColor || theme.colorBgContainer}
shape={'square'}
size={20}
/>
<span className={styles.taskHeader}>
{task.title || task.agent?.title || 'Task'}
</span>
</Flexbox>
<Flexbox align="center" className={styles.timeout} gap={4} horizontal>
<Clock size={14} />
<span>
{timeoutMinutes}{' '}
{t('agentGroupManagement.executeTask.intervention.timeoutUnit')}
</span>
</Flexbox>
</Flexbox>
{/* Task content (read-only) */}
{task.instruction && (
<Text className={styles.taskContent} style={{ margin: 0 }}>
{task.instruction}
</Text>
)}
</Flexbox>
</div>
);
})}
</div>
);
});
ExecuteTasksRender.displayName = 'ExecuteTasksRender';
export default ExecuteTasksRender;

View File

@@ -1,6 +1,7 @@
import { GroupManagementApiName } from '../../types';
import BroadcastRender from './Broadcast';
import ExecuteTaskRender from './ExecuteTask';
import ExecuteTasksRender from './ExecuteTasks';
import SpeakRender from './Speak';
/**
@@ -9,9 +10,11 @@ import SpeakRender from './Speak';
export const GroupManagementRenders = {
[GroupManagementApiName.broadcast]: BroadcastRender,
[GroupManagementApiName.executeAgentTask]: ExecuteTaskRender,
[GroupManagementApiName.executeAgentTasks]: ExecuteTasksRender,
[GroupManagementApiName.speak]: SpeakRender,
};
export { default as BroadcastRender } from './Broadcast';
export { default as ExecuteTaskRender } from './ExecuteTask';
export { default as ExecuteTasksRender } from './ExecuteTasks';
export { default as SpeakRender } from './Speak';

View File

@@ -75,6 +75,7 @@ describe('GroupManagementExecutor', () => {
triggerBroadcast: vi.fn(),
triggerDelegate: vi.fn(),
triggerExecuteTask: vi.fn(),
triggerExecuteTasks: vi.fn(),
triggerSpeak,
},
'supervisor-agent',
@@ -122,6 +123,7 @@ describe('GroupManagementExecutor', () => {
triggerBroadcast: vi.fn(),
triggerDelegate: vi.fn(),
triggerExecuteTask: vi.fn(),
triggerExecuteTasks: vi.fn(),
triggerSpeak,
},
'supervisor-agent',
@@ -171,6 +173,7 @@ describe('GroupManagementExecutor', () => {
triggerBroadcast,
triggerDelegate: vi.fn(),
triggerExecuteTask: vi.fn(),
triggerExecuteTasks: vi.fn(),
triggerSpeak: vi.fn(),
},
'supervisor-agent',
@@ -238,6 +241,7 @@ describe('GroupManagementExecutor', () => {
triggerBroadcast: vi.fn(),
triggerDelegate,
triggerExecuteTask: vi.fn(),
triggerExecuteTasks: vi.fn(),
triggerSpeak: vi.fn(),
},
'supervisor-agent',
@@ -308,6 +312,7 @@ describe('GroupManagementExecutor', () => {
triggerBroadcast: vi.fn(),
triggerDelegate: vi.fn(),
triggerExecuteTask,
triggerExecuteTasks: vi.fn(),
triggerSpeak: vi.fn(),
},
'supervisor-agent',

View File

@@ -11,6 +11,7 @@ import {
CreateWorkflowParams,
DelegateParams,
ExecuteTaskParams,
ExecuteTasksParams,
GroupManagementApiName,
GroupManagementIdentifier,
InterruptParams,
@@ -153,6 +154,38 @@ class GroupManagementExecutor extends BaseExecutor<typeof GroupManagementApiName
};
};
executeAgentTasks = async (
params: ExecuteTasksParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
// Register afterCompletion callback to trigger parallel task execution after AgentRuntime completes
// This follows the same pattern as executeAgentTask - trigger mode, not blocking
if (ctx.groupOrchestration && ctx.agentId && ctx.registerAfterCompletion) {
ctx.registerAfterCompletion(() =>
ctx.groupOrchestration!.triggerExecuteTasks({
skipCallSupervisor: params.skipCallSupervisor,
supervisorAgentId: ctx.agentId!,
tasks: params.tasks,
toolMessageId: ctx.messageId,
}),
);
}
const agentIds = params.tasks.map((t) => t.agentId).join(', ');
// Returns stop: true to indicate the supervisor should stop and let the tasks execute
return {
content: `Triggered ${params.tasks.length} parallel tasks for agents: ${agentIds}.`,
state: {
skipCallSupervisor: params.skipCallSupervisor,
tasks: params.tasks,
type: 'executeAgentTasks',
},
stop: true,
success: true,
};
};
interrupt = async (
params: InterruptParams,
_ctx: BuiltinToolContext,

View File

@@ -121,52 +121,53 @@ export const GroupManagementManifest: BuiltinToolManifest = {
type: 'object',
},
},
{
description:
'Assign multiple tasks to different agents to run in parallel. Each agent works independently in their own context. Use this when you need multiple agents to work on different parts of a problem simultaneously.',
name: GroupManagementApiName.executeAgentTasks,
humanIntervention: 'required',
parameters: {
properties: {
tasks: {
description: 'Array of tasks, each assigned to a specific agent.',
items: {
properties: {
agentId: {
description: 'The ID of the agent to execute this task.',
type: 'string',
},
title: {
description: 'Brief title describing what this task does (shown in UI).',
type: 'string',
},
instruction: {
description:
'Detailed instruction/prompt for the task execution. Be specific about expected deliverables.',
type: 'string',
},
timeout: {
description:
'Optional timeout in milliseconds for this task (default: 1800000, 30 minutes).',
type: 'number',
},
},
required: ['agentId', 'title', 'instruction'],
type: 'object',
},
type: 'array',
},
skipCallSupervisor: {
default: false,
description:
'If true, the orchestration will end after all tasks complete, without calling the supervisor again.',
type: 'boolean',
},
},
required: ['tasks'],
type: 'object',
},
},
// TODO: Enable executeAgentTasks when ready
// {
// description:
// 'Assign multiple tasks to different agents to run in parallel. Each agent works independently in their own context. Use this when you need multiple agents to work on different parts of a problem simultaneously.',
// name: GroupManagementApiName.executeAgentTasks,
// humanIntervention: 'required',
// parameters: {
// properties: {
// tasks: {
// description: 'Array of tasks, each assigned to a specific agent.',
// items: {
// properties: {
// agentId: {
// description: 'The ID of the agent to execute this task.',
// type: 'string',
// },
// title: {
// description: 'Brief title describing what this task does (shown in UI).',
// type: 'string',
// },
// instruction: {
// description:
// 'Detailed instruction/prompt for the task execution. Be specific about expected deliverables.',
// type: 'string',
// },
// timeout: {
// description:
// 'Optional timeout in milliseconds for this task (default: 1800000, 30 minutes).',
// type: 'number',
// },
// },
// required: ['agentId', 'title', 'instruction'],
// type: 'object',
// },
// type: 'array',
// },
// skipCallSupervisor: {
// default: false,
// description:
// 'If true, the orchestration will end after all tasks complete, without calling the supervisor again.',
// type: 'boolean',
// },
// },
// required: ['tasks'],
// type: 'object',
// },
// },
{
description:
'Interrupt a running agent task. Use this to stop a task that is taking too long or is no longer needed.',

View File

@@ -155,8 +155,7 @@ When a user's request is broad or unclear, ask 1-2 focused questions to understa
- **broadcast**: Multiple agents respond in parallel in group context
**Task Execution (Independent Context, With Tools):**
- **executeAgentTask**: Assign a single task to one agent in isolated context
- **executeAgentTasks**: Assign multiple tasks to different agents in parallel (each with isolated context)
- **executeAgentTask**: Assign a task to one agent in isolated context
- **interrupt**: Stop a running task
**Flow Control:**
@@ -176,19 +175,17 @@ Analysis: Opinion-based, no tools needed
Action: broadcast to [Architect, DevOps, Backend] - share perspectives
\`\`\`
### Pattern 2: Independent Research (Parallel Tasks)
When multiple agents need to research/work independently using their tools.
### Pattern 2: Independent Research (Task)
When an agent needs to research/work independently using their tools.
\`\`\`
User: "Research the pros and cons of React vs Vue vs Svelte"
Analysis: Requires web search, agents work independently
Action: executeAgentTasks with parallel assignments
executeAgentTasks({
tasks: [
{ agentId: "frontend-expert", title: "Research React", instruction: "Research React ecosystem, performance benchmarks, community size, and typical use cases. Provide pros and cons." },
{ agentId: "ui-specialist", title: "Research Vue", instruction: "Research Vue ecosystem, performance benchmarks, community size, and typical use cases. Provide pros and cons." },
{ agentId: "tech-analyst", title: "Research Svelte", instruction: "Research Svelte ecosystem, performance benchmarks, community size, and typical use cases. Provide pros and cons." }
]
User: "Research the pros and cons of React"
Analysis: Requires web search, agent works independently
Action: executeAgentTask to frontend expert
executeAgentTask({
agentId: "frontend-expert",
title: "Research React",
task: "Research React ecosystem, performance benchmarks, community size, and typical use cases. Provide pros and cons."
})
\`\`\`
@@ -211,28 +208,25 @@ When you need facts first, then discussion.
User: "Should we migrate to Kubernetes? Research and discuss."
Analysis: First gather facts (tools), then discuss (no tools)
Action:
1. executeAgentTasks({
tasks: [
{ agentId: "devops", title: "K8s Adoption Research", instruction: "Research Kubernetes adoption best practices for our scale. Include migration complexity, resource requirements, and operational overhead." },
{ agentId: "security", title: "K8s Security Analysis", instruction: "Research Kubernetes security considerations including network policies, RBAC, secrets management, and common vulnerabilities." }
]
1. executeAgentTask({
agentId: "devops",
title: "K8s Adoption Research",
task: "Research Kubernetes adoption best practices for our scale. Include migration complexity, resource requirements, operational overhead, and security considerations."
})
2. [Wait for results]
3. broadcast: "Based on the research, share your recommendations"
\`\`\`
### Pattern 5: Collaborative Implementation (Parallel Tasks)
When multiple agents create deliverables using their tools.
### Pattern 5: Implementation Task
When an agent needs to create deliverables using their tools.
\`\`\`
User: "Create a landing page - need copy, design specs, and code"
Analysis: Each agent produces artifacts using their tools
Action: executeAgentTasks({
tasks: [
{ agentId: "copywriter", title: "Write Copy", instruction: "Write compelling landing page copy for [product]. Include headline, subheadline, feature descriptions, and CTA text." },
{ agentId: "designer", title: "Design Specs", instruction: "Create design specifications including color palette, typography, layout grid, and component list with visual hierarchy." },
{ agentId: "frontend-dev", title: "Implement Page", instruction: "Implement the landing page using React. Include responsive design, animations, and SEO-friendly markup." }
]
User: "Write the landing page copy"
Analysis: Agent produces artifacts using their tools
Action: executeAgentTask({
agentId: "copywriter",
title: "Write Copy",
task: "Write compelling landing page copy for [product]. Include headline, subheadline, feature descriptions, and CTA text."
})
\`\`\`
</workflow_patterns>
@@ -243,8 +237,7 @@ Action: executeAgentTasks({
- broadcast: \`agentIds\` (array), \`instruction\` (optional shared guidance)
**Task Execution:**
- executeAgentTask: \`agentId\`, \`task\` (clear deliverable description), \`timeout\` (optional, default 30min)
- executeAgentTasks: \`tasks\` (array of {agentId, title, instruction, timeout?}) - **Use this for parallel task execution across multiple agents**
- executeAgentTask: \`agentId\`, \`title\`, \`task\` (clear deliverable description), \`timeout\` (optional, default 30min)
- interrupt: \`taskId\`
**Flow Control:**

View File

@@ -452,6 +452,47 @@ export interface TriggerExecuteTaskParams extends GroupOrchestrationBaseParams {
toolMessageId: string;
}
/**
* Task item for triggerExecuteTasks callback
*/
export interface TriggerExecuteTaskItem {
/**
* The agent ID to execute this task
*/
agentId: string;
/**
* Detailed instruction/prompt for the task execution
*/
instruction: string;
/**
* Optional timeout in milliseconds for this specific task
*/
timeout?: number;
/**
* Brief title describing what this task does (shown in UI)
*/
title: string;
}
/**
* Params for triggerExecuteTasks callback (multiple tasks)
*/
export interface TriggerExecuteTasksParams extends GroupOrchestrationBaseParams {
/**
* If true, the orchestration will end after all tasks complete,
* without calling the supervisor again.
*/
skipCallSupervisor?: boolean;
/**
* Array of tasks to execute, each assigned to a specific agent
*/
tasks: TriggerExecuteTaskItem[];
/**
* The tool message ID that triggered the tasks
*/
toolMessageId: string;
}
/**
* Group Orchestration callbacks for group management tools
* These callbacks are used to trigger the next phase in multi-agent orchestration
@@ -472,6 +513,11 @@ export interface GroupOrchestrationCallbacks {
*/
triggerExecuteTask: (params: TriggerExecuteTaskParams) => Promise<void>;
/**
* Trigger async execution of multiple tasks in parallel
*/
triggerExecuteTasks: (params: TriggerExecuteTasksParams) => Promise<void>;
/**
* Trigger speak to a specific agent
*/

View File

@@ -0,0 +1,115 @@
'use client';
import { AccordionItem, Block, Text } from '@lobehub/ui';
import { memo, useMemo, useState } from 'react';
import { ThreadStatus } from '@/types/index';
import type { UIChatMessage } from '@/types/index';
import {
CompletedState,
ErrorState,
InitializingState,
ProcessingState,
isProcessingStatus,
} from '../shared';
import TaskTitle, { type TaskMetrics } from './TaskTitle';
import { useClientTaskStats } from './useClientTaskStats';
interface ClientTaskItemProps {
item: UIChatMessage;
}
const ClientTaskItem = memo<ClientTaskItemProps>(({ item }) => {
const { id, content, metadata, taskDetail } = item;
const [expanded, setExpanded] = useState(false);
const title = taskDetail?.title || metadata?.taskTitle;
const instruction = metadata?.instruction;
const status = taskDetail?.status;
const isProcessing = isProcessingStatus(status);
const isCompleted = status === ThreadStatus.Completed;
const isError = status === ThreadStatus.Failed || status === ThreadStatus.Cancel;
const isInitializing = !taskDetail || !status;
// Fetch client task stats when processing
const clientStats = useClientTaskStats({
enabled: isProcessing,
threadId: taskDetail?.threadId,
});
// Build metrics for TaskTitle
const metrics: TaskMetrics | undefined = useMemo(() => {
if (isProcessing) {
return {
isLoading: clientStats.isLoading,
startTime: clientStats.startTime,
steps: clientStats.steps,
toolCalls: clientStats.toolCalls,
};
}
if (isCompleted || isError) {
return {
duration: taskDetail?.duration,
steps: taskDetail?.totalSteps,
toolCalls: taskDetail?.totalToolCalls,
};
}
return undefined;
}, [
isProcessing,
isCompleted,
isError,
clientStats,
taskDetail?.duration,
taskDetail?.totalSteps,
taskDetail?.totalToolCalls,
]);
return (
<AccordionItem
expand={expanded}
itemKey={id}
onExpandChange={setExpanded}
paddingBlock={4}
paddingInline={4}
title={<TaskTitle metrics={metrics} status={status} title={title} />}
>
<Block gap={16} padding={12} style={{ marginBlock: 8 }} variant={'outlined'}>
{instruction && (
<Block padding={12}>
<Text fontSize={13} type={'secondary'}>
{instruction}
</Text>
</Block>
)}
{/* Initializing State - no taskDetail yet */}
{isInitializing && <InitializingState />}
{/* Processing State */}
{!isInitializing && isProcessing && taskDetail && (
<ProcessingState messageId={id} taskDetail={taskDetail} variant="compact" />
)}
{/* Error State */}
{!isInitializing && isError && taskDetail && <ErrorState taskDetail={taskDetail} />}
{/* Completed State */}
{!isInitializing && isCompleted && taskDetail && (
<CompletedState
content={content}
expanded={expanded}
taskDetail={taskDetail}
variant="compact"
/>
)}
</Block>
</AccordionItem>
);
}, Object.is);
ClientTaskItem.displayName = 'ClientTaskItem';
export default ClientTaskItem;

View File

@@ -0,0 +1,92 @@
'use client';
import { AccordionItem, Block, Text } from '@lobehub/ui';
import { memo, useMemo, useState } from 'react';
import { ThreadStatus } from '@/types/index';
import type { UIChatMessage } from '@/types/index';
import {
CompletedState,
ErrorState,
InitializingState,
ProcessingState,
isProcessingStatus,
} from '../shared';
import TaskTitle, { type TaskMetrics } from './TaskTitle';
interface ServerTaskItemProps {
item: UIChatMessage;
}
const ServerTaskItem = memo<ServerTaskItemProps>(({ item }) => {
const { id, content, metadata, taskDetail } = item;
const [expanded, setExpanded] = useState(false);
const title = taskDetail?.title || metadata?.taskTitle;
const instruction = metadata?.instruction;
const status = taskDetail?.status;
const isProcessing = isProcessingStatus(status);
const isCompleted = status === ThreadStatus.Completed;
const isError = status === ThreadStatus.Failed || status === ThreadStatus.Cancel;
const isInitializing = !taskDetail || !status;
// Build metrics for TaskTitle (only for completed/error states)
const metrics: TaskMetrics | undefined = useMemo(() => {
if (isCompleted || isError) {
return {
duration: taskDetail?.duration,
steps: taskDetail?.totalSteps,
toolCalls: taskDetail?.totalToolCalls,
};
}
return undefined;
}, [isCompleted, isError, taskDetail?.duration, taskDetail?.totalSteps, taskDetail?.totalToolCalls]);
return (
<AccordionItem
expand={expanded}
itemKey={id}
onExpandChange={setExpanded}
paddingBlock={4}
paddingInline={4}
title={<TaskTitle metrics={metrics} status={status} title={title} />}
>
<Block gap={16} padding={12} style={{ marginBlock: 8 }} variant={'outlined'}>
{instruction && (
<Block padding={12}>
<Text fontSize={13} type={'secondary'}>
{instruction}
</Text>
</Block>
)}
{/* Initializing State - no taskDetail yet */}
{isInitializing && <InitializingState />}
{/* Processing State */}
{!isInitializing && isProcessing && taskDetail && (
<ProcessingState messageId={id} taskDetail={taskDetail} variant="compact" />
)}
{/* Error State */}
{!isInitializing && isError && taskDetail && <ErrorState taskDetail={taskDetail} />}
{/* Completed State */}
{!isInitializing && isCompleted && taskDetail && (
<CompletedState
content={content}
expanded={expanded}
taskDetail={taskDetail}
variant="compact"
/>
)}
</Block>
</AccordionItem>
);
}, Object.is);
ServerTaskItem.displayName = 'ServerTaskItem';
export default ServerTaskItem;

View File

@@ -2,15 +2,31 @@
import { Block, Flexbox, Icon, Text } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { ListChecksIcon, XIcon } from 'lucide-react';
import { memo } from 'react';
import { Footprints, ListChecksIcon, Wrench, XIcon } from 'lucide-react';
import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
import { ThreadStatus } from '@/types/index';
import { isProcessingStatus } from '../shared';
import { formatDuration, formatElapsedTime, isProcessingStatus } from '../shared';
export interface TaskMetrics {
/** Task duration in milliseconds (for completed tasks) */
duration?: number;
/** Whether metrics are still loading */
isLoading?: boolean;
/** Start time timestamp for elapsed time calculation */
startTime?: number;
/** Number of execution steps/blocks */
steps?: number;
/** Total tool calls count */
toolCalls?: number;
}
interface TaskTitleProps {
/** Metrics to display (steps, tool calls, elapsed time) */
metrics?: TaskMetrics;
status?: ThreadStatus;
title?: string;
}
@@ -54,13 +70,90 @@ const TaskStatusIndicator = memo<{ status?: ThreadStatus }>(({ status }) => {
TaskStatusIndicator.displayName = 'TaskStatusIndicator';
const TaskTitle = memo<TaskTitleProps>(({ title, status }) => {
interface MetricsDisplayProps {
metrics: TaskMetrics;
status?: ThreadStatus;
}
const MetricsDisplay = memo<MetricsDisplayProps>(({ metrics, status }) => {
const { t } = useTranslation('chat');
const { steps, toolCalls, startTime, duration, isLoading } = metrics;
const [elapsedTime, setElapsedTime] = useState(0);
const isProcessing = status ? isProcessingStatus(status) : false;
// Calculate initial elapsed time
useEffect(() => {
if (startTime && isProcessing) {
setElapsedTime(Math.max(0, Date.now() - startTime));
}
}, [startTime, isProcessing]);
// Timer for updating elapsed time every second (only when processing)
useEffect(() => {
if (!startTime || !isProcessing) return;
const timer = setInterval(() => {
setElapsedTime(Math.max(0, Date.now() - startTime));
}, 1000);
return () => clearInterval(timer);
}, [startTime, isProcessing]);
// Don't show metrics if loading or no data
if (isLoading) return null;
const hasSteps = steps !== undefined && steps > 0;
const hasToolCalls = toolCalls !== undefined && toolCalls > 0;
const hasTime = isProcessing ? startTime !== undefined : duration !== undefined;
// Don't render if no metrics to show
if (!hasSteps && !hasToolCalls && !hasTime) return null;
return (
<Flexbox align={'center'} gap={6} horizontal>
<Flexbox align="center" gap={8} horizontal>
{/* Steps */}
{hasSteps && (
<Flexbox align="center" gap={2} horizontal>
<Icon color={cssVar.colorTextTertiary} icon={Footprints} size={12} />
<Text fontSize={12} type="secondary">
{steps}
</Text>
</Flexbox>
)}
{/* Tool calls */}
{hasToolCalls && (
<Flexbox align="center" gap={2} horizontal>
<Icon color={cssVar.colorTextTertiary} icon={Wrench} size={12} />
<Text fontSize={12} type="secondary">
{toolCalls}
</Text>
</Flexbox>
)}
{/* Time */}
{hasTime && (
<Text fontSize={12} type="secondary">
{isProcessing
? formatElapsedTime(elapsedTime)
: duration
? t('task.metrics.duration', { duration: formatDuration(duration) })
: null}
</Text>
)}
</Flexbox>
);
});
MetricsDisplay.displayName = 'MetricsDisplay';
const TaskTitle = memo<TaskTitleProps>(({ title, status, metrics }) => {
return (
<Flexbox align="center" gap={6} horizontal>
<TaskStatusIndicator status={status} />
<Text ellipsis fontSize={14}>
{title}
</Text>
{metrics && <MetricsDisplay metrics={metrics} status={status} />}
</Flexbox>
);
});

View File

@@ -1,81 +1,26 @@
'use client';
import { AccordionItem, Block, Text } from '@lobehub/ui';
import { memo, useState } from 'react';
import isEqual from 'fast-deep-equal';
import { memo } from 'react';
import { ThreadStatus } from '@/types/index';
import type { UIChatMessage } from '@/types/index';
import {
CompletedState,
ErrorState,
InitializingState,
ProcessingState,
isProcessingStatus,
} from '../shared';
import TaskTitle from './TaskTitle';
import ClientTaskItem from './ClientTaskItem';
import ServerTaskItem from './ServerTaskItem';
interface TaskItemProps {
item: UIChatMessage;
}
const TaskItem = memo<TaskItemProps>(({ item }) => {
const { id, content, metadata, taskDetail } = item;
const [expanded, setExpanded] = useState(false);
const isClientMode = item.taskDetail?.clientMode;
const title = taskDetail?.title || metadata?.taskTitle;
const instruction = metadata?.instruction;
const status = taskDetail?.status;
if (isClientMode) {
return <ClientTaskItem item={item} />;
}
// Check if task is processing using shared utility
const isProcessing = isProcessingStatus(status);
const isCompleted = status === ThreadStatus.Completed;
const isError = status === ThreadStatus.Failed || status === ThreadStatus.Cancel;
const isInitializing = !taskDetail || !status;
return (
<AccordionItem
expand={expanded}
itemKey={id}
onExpandChange={setExpanded}
paddingBlock={4}
paddingInline={4}
title={<TaskTitle status={status} title={title} />}
>
<Block gap={16} padding={12} style={{ marginBlock: 8 }} variant={'outlined'}>
{instruction && (
<Block padding={12}>
<Text fontSize={13} type={'secondary'}>
{instruction}
</Text>
</Block>
)}
{/* Initializing State - no taskDetail yet */}
{isInitializing && <InitializingState />}
{/* Processing State */}
{!isInitializing && isProcessing && taskDetail && (
<ProcessingState messageId={id} taskDetail={taskDetail} variant="compact" />
)}
{/* Error State */}
{!isInitializing && isError && taskDetail && <ErrorState taskDetail={taskDetail} />}
{/* Completed State */}
{!isInitializing && isCompleted && taskDetail && (
<CompletedState
content={content}
expanded={expanded}
taskDetail={taskDetail}
variant="compact"
/>
)}
</Block>
</AccordionItem>
);
}, Object.is);
return <ServerTaskItem item={item} />;
}, isEqual);
TaskItem.displayName = 'TaskItem';

View File

@@ -0,0 +1,82 @@
'use client';
import { useMemo } from 'react';
import { useChatStore } from '@/store/chat';
import { displayMessageSelectors } from '@/store/chat/selectors';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
export interface ClientTaskStats {
isLoading: boolean;
startTime?: number;
steps: number;
toolCalls: number;
}
interface UseClientTaskStatsOptions {
enabled?: boolean;
threadId?: string;
}
/**
* Hook to fetch thread messages and compute task statistics for client mode tasks.
* Used in TaskItem to display progress metrics (steps, tool calls, elapsed time).
*/
export const useClientTaskStats = ({
threadId,
enabled = true,
}: UseClientTaskStatsOptions): ClientTaskStats => {
const [activeAgentId, activeTopicId, useFetchMessages] = useChatStore((s) => [
s.activeAgentId,
s.activeTopicId,
s.useFetchMessages,
]);
const threadContext = useMemo(
() => ({
agentId: activeAgentId,
scope: 'thread' as const,
threadId,
topicId: activeTopicId,
}),
[activeAgentId, activeTopicId, threadId],
);
const threadMessageKey = useMemo(
() => (threadId ? messageMapKey(threadContext) : null),
[threadId, threadContext],
);
// Fetch thread messages (skip when disabled or no threadId)
useFetchMessages(threadContext, !enabled || !threadId);
// Get thread messages from store using selector
const threadMessages = useChatStore((s) =>
threadMessageKey
? displayMessageSelectors.getDisplayMessagesByKey(threadMessageKey)(s)
: undefined,
);
// Compute stats from thread messages
return useMemo(() => {
if (!threadMessages || !enabled) {
return { isLoading: true, steps: 0, toolCalls: 0 };
}
// Find the assistantGroup message which contains the children blocks
const assistantGroupMessage = threadMessages.find((item) => item.role === 'assistantGroup');
const blocks = assistantGroupMessage?.children ?? [];
// Calculate stats
const steps = blocks.length;
const toolCalls = blocks.reduce((sum, block) => sum + (block.tools?.length || 0), 0);
const startTime = assistantGroupMessage?.createdAt;
return {
isLoading: false,
startTime,
steps,
toolCalls,
};
}, [threadMessages, enabled]);
};

View File

@@ -0,0 +1,18 @@
/**
* Preload Tool Render components to avoid Suspense flash on first expand
*
* These components are dynamically imported in Tool/Tool/index.tsx.
* By preloading them when tool calls are detected, we can avoid
* the loading skeleton flash when user first expands the tool result.
*/
let preloaded = false;
export const preloadToolRenderComponents = () => {
if (preloaded) return;
preloaded = true;
// Preload Detail and Debug components (dynamic imports in Tool/Tool/index.tsx)
import('../AssistantGroup/Tool/Detail');
import('../AssistantGroup/Tool/Debug');
};

View File

@@ -73,6 +73,12 @@ export interface GroupOrchestrationAction {
*/
triggerExecuteTask: GroupOrchestrationCallbacks['triggerExecuteTask'];
/**
* Trigger execute tasks - called by executeTasks tool when supervisor decides to execute multiple async tasks in parallel
* This starts the group orchestration loop with supervisor_decided result
*/
triggerExecuteTasks: GroupOrchestrationCallbacks['triggerExecuteTasks'];
/**
* Enable polling for task status
* Used by ProcessingState component to poll for real-time task updates
@@ -240,6 +246,42 @@ export const groupOrchestrationSlice: StateCreator<
});
},
/**
* Trigger execute tasks - Entry point when supervisor calls executeTasks tool
* Creates a supervisor_decided result with decision='execute_tasks' and starts orchestration
*/
triggerExecuteTasks: async (params) => {
const { supervisorAgentId, tasks, toolMessageId, skipCallSupervisor } = params;
log(
'[triggerExecuteTasks] Starting orchestration with execute_tasks: supervisorAgentId=%s, tasks=%d, toolMessageId=%s, skipCallSupervisor=%s',
supervisorAgentId,
tasks.length,
toolMessageId,
skipCallSupervisor,
);
const groupId = get().activeGroupId;
if (!groupId) {
log('[triggerExecuteTasks] No active group, skipping');
return;
}
// Start orchestration loop with supervisor_decided result (decision=execute_tasks)
await get().internal_execGroupOrchestration({
groupId,
supervisorAgentId,
topicId: get().activeTopicId,
initialResult: {
type: 'supervisor_decided',
payload: {
decision: 'execute_tasks',
params: { tasks, toolMessageId },
skipCallSupervisor: skipCallSupervisor ?? false,
},
},
});
},
/**
* Get group orchestration callbacks
* These are the action methods that tools can call to trigger orchestration
@@ -250,6 +292,7 @@ export const groupOrchestrationSlice: StateCreator<
triggerBroadcast: get().triggerBroadcast,
triggerDelegate: get().triggerDelegate,
triggerExecuteTask: get().triggerExecuteTask,
triggerExecuteTasks: get().triggerExecuteTasks,
};
},

View File

@@ -736,8 +736,13 @@ export const streamingExecutor: StateCreator<
// After parallel tool batch completes, refresh messages to ensure all tool results are synced
// This fixes the race condition where each tool's replaceMessages may overwrite others
// REMEMBER: There is no test for it (too hard to add), if you want to change it , ask @arvinxx first
if (result.nextContext?.phase === 'tools_batch_result') {
log('[internal_execAgentRuntime] Tools batch completed, refreshing messages to sync state');
if (
result.nextContext?.phase &&
['tasks_batch_result', 'tools_batch_result'].includes(result.nextContext?.phase)
) {
log(
`[internal_execAgentRuntime] ${result.nextContext?.phase} completed, refreshing messages to sync state`,
);
await get().refreshMessages(context);
}

View File

@@ -16,6 +16,8 @@ export type {
IBuiltinToolExecutor,
TriggerBroadcastParams,
TriggerDelegateParams,
TriggerExecuteTaskItem,
TriggerExecuteTasksParams,
TriggerSpeakParams,
} from '@lobechat/types';