Merge pull request #220 from multica-ai/codex/delegate-progress-timer

feat(desktop): show delegate sub-task progress and running timers
This commit is contained in:
Jiayuan Zhang 2026-02-17 03:34:52 +08:00 committed by GitHub
commit f4bd5b7bbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 346 additions and 21 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<TaskResult> {
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<typeof DelegateSchema, DelegateResult> {
): AgentTool<typeof DelegateSchema, DelegateToolDetails> {
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();
}),
),
);

View file

@ -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;

View file

@ -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<string, unknown>;
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) => [

View file

@ -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"

View file

@ -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<string, unknown>
toolStatus?: ToolStatus
toolProgress?: DelegateToolProgress
isError?: boolean
systemType?: "compaction"
compaction?: CompactionInfo

View file

@ -161,6 +161,7 @@ export type StreamEventType =
| 'message_update'
| 'message_end'
| 'tool_execution_start'
| 'tool_execution_update'
| 'tool_execution_end'
| 'compaction_start'
| 'compaction_end'

View file

@ -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
)}
</button>
{/* Delegate task statuses */}
{delegateProgress && delegateProgress.tasks.length > 0 && (
<div className="px-2.5 pb-2">
<div className="px-2.5 py-1 text-xs text-muted-foreground/70 font-[tabular-nums]">
{delegateProgress.completed}/{delegateProgress.taskCount} completed
{" · "}
{delegateProgress.running} running
{" · "}
{delegateProgress.errors} failed
{" · "}
{delegateProgress.timeouts} timed out
</div>
<div className="space-y-0.5 px-2.5">
{delegateProgress.tasks.map((task) => (
<div key={`delegate-task-${task.index}`} className="flex items-center gap-2 text-xs">
<span className={cn("size-1.5 rounded-full shrink-0", delegateTaskStatusDotClass(task.status))} />
<span className="truncate min-w-0">{task.label}</span>
<span className={cn(
"ml-auto shrink-0 text-muted-foreground/70 font-[tabular-nums]",
task.status === "running" && "motion-safe:animate-pulse",
)}>
{delegateTaskStatusLabel(task, nowMs)}
</span>
</div>
))}
</div>
</div>
)}
{/* Expanded result */}
{expanded && resultText && (
<div