diff --git a/apps/desktop/src/main/electron-env.d.ts b/apps/desktop/src/main/electron-env.d.ts index 432437e9..0d52ba52 100644 --- a/apps/desktop/src/main/electron-env.d.ts +++ b/apps/desktop/src/main/electron-env.d.ts @@ -101,11 +101,11 @@ interface LocalChatEvent { streamId?: string type?: 'error' content?: string - event?: { - type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_end' | 'compaction_start' | 'compaction_end' - id?: string - message?: { - role: string + event?: { + type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_update' | 'tool_execution_end' | 'compaction_start' | 'compaction_end' + id?: string + message?: { + role: string content?: Array<{ type: string; text?: string }> } [key: string]: unknown diff --git a/apps/desktop/src/main/ipc/hub.ts b/apps/desktop/src/main/ipc/hub.ts index 35441a1b..93c4a505 100644 --- a/apps/desktop/src/main/ipc/hub.ts +++ b/apps/desktop/src/main/ipc/hub.ts @@ -290,6 +290,7 @@ export function registerHubIpcHandlers(): void { const shouldForward = ((event.type === 'message_start' || event.type === 'message_update' || event.type === 'message_end') && isAssistantMessage) || event.type === 'tool_execution_start' + || event.type === 'tool_execution_update' || event.type === 'tool_execution_end' if (!shouldForward) return diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 3e282bee..07e6067a 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -70,11 +70,11 @@ export interface LocalChatEvent { streamId?: string type?: 'error' content?: string - event?: { - type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_end' | 'compaction_start' | 'compaction_end' - id?: string - message?: { - role: string + event?: { + type: 'message_start' | 'message_update' | 'message_end' | 'tool_execution_start' | 'tool_execution_update' | 'tool_execution_end' | 'compaction_start' | 'compaction_end' + id?: string + message?: { + role: string content?: Array<{ type: string; text?: string }> } [key: string]: unknown diff --git a/packages/core/src/agent/tools/delegate.ts b/packages/core/src/agent/tools/delegate.ts index db3e2907..d206f929 100644 --- a/packages/core/src/agent/tools/delegate.ts +++ b/packages/core/src/agent/tools/delegate.ts @@ -51,6 +51,17 @@ type TaskResult = { error?: string; }; +type DelegateTaskProgressStatus = "pending" | "running" | "success" | "error" | "timeout"; + +type DelegateTaskProgress = { + index: number; + label: string; + status: DelegateTaskProgressStatus; + startedAtMs?: number; + durationMs?: number; + error?: string; +}; + export type DelegateResult = { taskCount: number; ok: number; @@ -60,6 +71,20 @@ export type DelegateResult = { tasks: TaskResult[]; }; +export type DelegateProgress = { + kind: "delegate_progress"; + taskCount: number; + completed: number; + running: number; + ok: number; + errors: number; + timeouts: number; + tasks: DelegateTaskProgress[]; + updatedAtMs: number; +}; + +export type DelegateToolDetails = DelegateResult | DelegateProgress; + export interface CreateDelegateToolOptions { /** Whether the current agent is itself a subagent */ isSubagent?: boolean; @@ -101,6 +126,7 @@ async function runSubagentTask( timeoutMs: number, parentOptions: CreateDelegateToolOptions, runLog?: RunLog, + onTaskStateChange?: (task: DelegateTaskProgress) => void, ): Promise { const label = taskDef.label || `Task ${index + 1}`; const start = Date.now(); @@ -110,6 +136,12 @@ async function runSubagentTask( label, task: taskDef.task.slice(0, 200), }); + onTaskStateChange?.({ + index, + label, + status: "running", + startedAtMs: start, + }); const childAgent = new Agent({ provider: parentOptions.provider, @@ -176,6 +208,14 @@ async function runSubagentTask( findings_chars: taskResult.findings.length, error: taskResult.error, }); + onTaskStateChange?.({ + index, + label, + status: status === "ok" ? "success" : status, + startedAtMs: start, + durationMs, + error: taskResult.error, + }); return taskResult; } catch (err) { @@ -199,6 +239,14 @@ async function runSubagentTask( findings_chars: 0, error: message, }); + onTaskStateChange?.({ + index, + label, + status: "error", + startedAtMs: start, + durationMs, + error: message, + }); return taskResult; } finally { @@ -219,7 +267,7 @@ async function runSubagentTask( export function createDelegateTool( options: CreateDelegateToolOptions, -): AgentTool { +): AgentTool { return { name: "delegate", label: "Delegate Tasks", @@ -230,7 +278,7 @@ export function createDelegateTool( "Use this for parallelizable work: multi-stock research, comparative analysis, " + "data collection from multiple sources, or any task that benefits from parallel execution.", parameters: DelegateSchema, - execute: async (_toolCallId, args) => { + execute: async (_toolCallId, args, _signal, onUpdate) => { const { tasks, timeoutSeconds } = args as DelegateArgs; const timeoutMs = (timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1000; @@ -250,6 +298,40 @@ export function createDelegateTool( } const labels = tasks.map((t, i) => t.label || `Task ${i + 1}`); + const progressTasks: DelegateTaskProgress[] = labels.map((label, index) => ({ + index, + label, + status: "pending", + })); + + const emitProgress = () => { + if (!onUpdate) return; + const completed = progressTasks.filter((t) => t.status !== "pending" && t.status !== "running").length; + const running = progressTasks.filter((t) => t.status === "running").length; + const ok = progressTasks.filter((t) => t.status === "success").length; + const errors = progressTasks.filter((t) => t.status === "error").length; + const timeouts = progressTasks.filter((t) => t.status === "timeout").length; + + const snapshot: DelegateProgress = { + kind: "delegate_progress", + taskCount: tasks.length, + completed, + running, + ok, + errors, + timeouts, + tasks: progressTasks.map((task) => ({ ...task })), + updatedAtMs: Date.now(), + }; + + onUpdate({ + content: [{ + type: "text", + text: `Tasks: ${completed}/${tasks.length} completed (${ok} success, ${errors} failed, ${timeouts} timed out)`, + }], + details: snapshot, + }); + }; options.runLog?.log("delegate_start", { task_count: tasks.length, @@ -262,7 +344,17 @@ export function createDelegateTool( // Run all tasks in parallel const results = await Promise.all( tasks.map((taskDef, index) => - runSubagentTask(taskDef, index, timeoutMs, options, options.runLog), + runSubagentTask(taskDef, index, timeoutMs, options, options.runLog, (taskProgress) => { + progressTasks[index] = { + index: taskProgress.index, + label: taskProgress.label, + status: taskProgress.status, + startedAtMs: taskProgress.startedAtMs, + durationMs: taskProgress.durationMs, + error: taskProgress.error, + }; + emitProgress(); + }), ), ); diff --git a/packages/core/src/hub/hub.ts b/packages/core/src/hub/hub.ts index 2032e72a..bafa2553 100644 --- a/packages/core/src/hub/hub.ts +++ b/packages/core/src/hub/hub.ts @@ -478,6 +478,7 @@ export class Hub { const shouldForward = ((item.type === "message_start" || item.type === "message_update" || item.type === "message_end") && isAssistantMessage) || item.type === "tool_execution_start" + || item.type === "tool_execution_update" || item.type === "tool_execution_end"; if (!shouldForward) continue; diff --git a/packages/hooks/src/use-chat.ts b/packages/hooks/src/use-chat.ts index 9c414a8e..3bf344e2 100644 --- a/packages/hooks/src/use-chat.ts +++ b/packages/hooks/src/use-chat.ts @@ -22,6 +22,29 @@ export interface CompactionInfo { reason: string; } +export type DelegateTaskStatus = "pending" | "running" | "success" | "error" | "timeout"; + +export interface DelegateTaskProgress { + index: number; + label: string; + status: DelegateTaskStatus; + startedAtMs?: number; + durationMs?: number; + error?: string; +} + +export interface DelegateToolProgress { + kind: "delegate_progress"; + taskCount: number; + completed: number; + running: number; + ok: number; + errors: number; + timeouts: number; + tasks: DelegateTaskProgress[]; + updatedAtMs: number; +} + /** Message source: where did this message come from? */ export type MessageSource = | { type: "local" } @@ -38,6 +61,7 @@ export interface Message { toolName?: string; toolArgs?: Record; toolStatus?: ToolStatus; + toolProgress?: DelegateToolProgress; isError?: boolean; systemType?: "compaction"; compaction?: CompactionInfo; @@ -74,6 +98,68 @@ function extractContent(event: AgentEvent): ContentBlock[] { return Array.isArray(content) ? (content as ContentBlock[]) : []; } +function toTextContentBlock(value: unknown): ContentBlock[] { + if (value == null) return []; + return [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value) }]; +} + +function extractToolResultContent(result: unknown): ContentBlock[] { + if (result == null || typeof result !== "object") return toTextContentBlock(result); + const content = (result as { content?: unknown }).content; + if (Array.isArray(content)) return content as ContentBlock[]; + return toTextContentBlock(result); +} + +function extractDelegateProgress(partialResult: unknown): DelegateToolProgress | undefined { + if (!partialResult || typeof partialResult !== "object") return undefined; + const details = (partialResult as { details?: unknown }).details; + if (!details || typeof details !== "object") return undefined; + if ((details as { kind?: unknown }).kind !== "delegate_progress") return undefined; + + const toSafeNumber = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0); + const rawTasks = (details as { tasks?: unknown }).tasks; + const tasks: DelegateTaskProgress[] = Array.isArray(rawTasks) + ? rawTasks.flatMap((task, fallbackIndex) => { + if (!task || typeof task !== "object") return []; + const status = (task as { status?: unknown }).status; + if ( + status !== "pending" + && status !== "running" + && status !== "success" + && status !== "error" + && status !== "timeout" + ) { + return []; + } + const index = (task as { index?: unknown }).index; + const label = (task as { label?: unknown }).label; + const startedAtMs = (task as { startedAtMs?: unknown }).startedAtMs; + const durationMs = (task as { durationMs?: unknown }).durationMs; + const error = (task as { error?: unknown }).error; + return [{ + index: typeof index === "number" && Number.isFinite(index) ? index : fallbackIndex, + label: typeof label === "string" && label.length > 0 ? label : `Task ${fallbackIndex + 1}`, + status, + startedAtMs: typeof startedAtMs === "number" && Number.isFinite(startedAtMs) ? startedAtMs : undefined, + durationMs: typeof durationMs === "number" && Number.isFinite(durationMs) ? durationMs : undefined, + error: typeof error === "string" ? error : undefined, + }]; + }) + : []; + + return { + kind: "delegate_progress", + taskCount: toSafeNumber((details as { taskCount?: unknown }).taskCount) || tasks.length, + completed: toSafeNumber((details as { completed?: unknown }).completed), + running: toSafeNumber((details as { running?: unknown }).running), + ok: toSafeNumber((details as { ok?: unknown }).ok), + errors: toSafeNumber((details as { errors?: unknown }).errors), + timeouts: toSafeNumber((details as { timeouts?: unknown }).timeouts), + tasks, + updatedAtMs: toSafeNumber((details as { updatedAtMs?: unknown }).updatedAtMs) || Date.now(), + }; +} + // --------------------------------------------------------------------------- // useChat — pure state hook, no IO, no side effects // --------------------------------------------------------------------------- @@ -237,18 +323,32 @@ export function useChat() { ...m, toolStatus: (event.isError ? "error" : "success") as ToolStatus, isError: event.isError ?? false, - content: - event.result != null - ? [{ type: "text" as const, text: typeof event.result === "string" ? event.result : JSON.stringify(event.result) }] - : [], + content: extractToolResultContent(event.result), } : m, ), ); break; } - case "tool_execution_update": + case "tool_execution_update": { + const partialContent = extractToolResultContent(event.partialResult); + const delegateProgress = event.toolName === "delegate" + ? extractDelegateProgress(event.partialResult) + : undefined; + + setMessages((prev) => + prev.map((m) => + m.role === "toolResult" && m.toolCallId === event.toolCallId + ? { + ...m, + content: partialContent.length > 0 ? partialContent : m.content, + toolProgress: delegateProgress ?? m.toolProgress, + } + : m, + ), + ); break; + } case "compaction_end": { const ce = event as CompactionEndEvent; setMessages((prev) => [ diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index fca0d40b..bc788065 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1,3 +1,10 @@ -export type { Message, MessageSource, ToolStatus } from "./types" +export type { + Message, + MessageSource, + ToolStatus, + DelegateTaskStatus, + DelegateTaskProgress, + DelegateToolProgress, +} from "./types" export { parseConnectionCode } from "./connection" export type { ConnectionInfo } from "./connection" diff --git a/packages/store/src/types.ts b/packages/store/src/types.ts index 858b740e..213b3046 100644 --- a/packages/store/src/types.ts +++ b/packages/store/src/types.ts @@ -2,6 +2,29 @@ import type { ContentBlock } from "@multica/sdk" export type ToolStatus = "running" | "success" | "error" | "interrupted" +export type DelegateTaskStatus = "pending" | "running" | "success" | "error" | "timeout" + +export interface DelegateTaskProgress { + index: number + label: string + status: DelegateTaskStatus + startedAtMs?: number + durationMs?: number + error?: string +} + +export interface DelegateToolProgress { + kind: "delegate_progress" + taskCount: number + completed: number + running: number + ok: number + errors: number + timeouts: number + tasks: DelegateTaskProgress[] + updatedAtMs: number +} + /** Message source: where did this message come from? */ export type MessageSource = | { type: "local" } @@ -26,6 +49,7 @@ export interface Message { toolName?: string toolArgs?: Record toolStatus?: ToolStatus + toolProgress?: DelegateToolProgress isError?: boolean systemType?: "compaction" compaction?: CompactionInfo diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 07623ea7..bbf154f4 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -161,6 +161,7 @@ export type StreamEventType = | 'message_update' | 'message_end' | 'tool_execution_start' + | 'tool_execution_update' | 'tool_execution_end' | 'compaction_start' | 'compaction_end' diff --git a/packages/ui/src/components/tool-call-item.tsx b/packages/ui/src/components/tool-call-item.tsx index 51b5279c..ad258b51 100644 --- a/packages/ui/src/components/tool-call-item.tsx +++ b/packages/ui/src/components/tool-call-item.tsx @@ -1,6 +1,6 @@ "use client" -import { memo, useState } from "react" +import { memo, useEffect, useState } from "react" import { File, Save, @@ -16,7 +16,7 @@ import { type LucideIcon, } from "lucide-react" import { cn, getTextContent } from "@multica/ui/lib/utils" -import type { Message } from "@multica/store" +import type { DelegateTaskProgress, DelegateTaskStatus, DelegateToolProgress, Message } from "@multica/store" // --------------------------------------------------------------------------- // Tool display config @@ -134,13 +134,83 @@ function getStats(toolName: string, toolStatus: string, resultText: string): str } } +function getDelegateProgress(message: Message): DelegateToolProgress | undefined { + const progress = message.toolProgress + if (!progress) return undefined + if (progress.kind !== "delegate_progress") return undefined + return progress +} + +function formatElapsed(ms?: number): string { + if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0) return "" + const seconds = Math.round(ms / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + return remainingSeconds > 0 ? `${minutes}m${remainingSeconds}s` : `${minutes}m` +} + +function delegateTaskStatusLabel(task: DelegateTaskProgress, nowMs: number): string { + const elapsed = task.status === "running" + ? formatElapsed( + typeof task.startedAtMs === "number" && Number.isFinite(task.startedAtMs) + ? Math.max(0, nowMs - task.startedAtMs) + : undefined, + ) + : formatElapsed(task.durationMs) + + switch (task.status) { + case "pending": + return "pending" + case "running": + return "running" + case "success": + return elapsed ? `success · ${elapsed}` : "success" + case "error": + return elapsed ? `error · ${elapsed}` : "error" + case "timeout": + return elapsed ? `timeout · ${elapsed}` : "timeout" + default: + return task.status + } +} + +function delegateTaskStatusDotClass(status: DelegateTaskStatus): string { + switch (status) { + case "pending": + return "bg-muted-foreground/40" + case "running": + return "bg-[var(--tool-running)] motion-safe:animate-pulse" + case "success": + return "bg-[var(--tool-success)]" + case "error": + return "bg-[var(--tool-error)]" + case "timeout": + return "bg-amber-500" + default: + return "bg-muted-foreground/40" + } +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export const ToolCallItem = memo(function ToolCallItem({ message }: { message: Message }) { const [expanded, setExpanded] = useState(false) + const [nowMs, setNowMs] = useState(() => Date.now()) const { toolName = "", toolStatus = "running", toolArgs, content } = message + const delegateProgress = toolName === "delegate" ? getDelegateProgress(message) : undefined + const hasRunningDelegateTask = delegateProgress?.tasks.some((task) => task.status === "running") ?? false + + useEffect(() => { + if (!hasRunningDelegateTask) return + setNowMs(Date.now()) + const timer = globalThis.setInterval(() => { + setNowMs(Date.now()) + }, 1000) + return () => globalThis.clearInterval(timer) + }, [hasRunningDelegateTask]) const display = TOOL_DISPLAY[toolName] ?? { label: toolName, icon: Terminal } const isFinished = toolStatus !== "running" @@ -223,6 +293,35 @@ export const ToolCallItem = memo(function ToolCallItem({ message }: { message: M )} + {/* Delegate task statuses */} + {delegateProgress && delegateProgress.tasks.length > 0 && ( +
+
+ {delegateProgress.completed}/{delegateProgress.taskCount} completed + {" · "} + {delegateProgress.running} running + {" · "} + {delegateProgress.errors} failed + {" · "} + {delegateProgress.timeouts} timed out +
+
+ {delegateProgress.tasks.map((task) => ( +
+ + {task.label} + + {delegateTaskStatusLabel(task, nowMs)} + +
+ ))} +
+
+ )} + {/* Expanded result */} {expanded && resultText && (