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:
commit
f4bd5b7bbc
10 changed files with 346 additions and 21 deletions
10
apps/desktop/src/main/electron-env.d.ts
vendored
10
apps/desktop/src/main/electron-env.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ export type StreamEventType =
|
|||
| 'message_update'
|
||||
| 'message_end'
|
||||
| 'tool_execution_start'
|
||||
| 'tool_execution_update'
|
||||
| 'tool_execution_end'
|
||||
| 'compaction_start'
|
||||
| 'compaction_end'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue