"use client"; import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { ChevronRight, Loader2, Square } from "lucide-react"; import { toast } from "sonner"; import { api } from "@multica/core/api"; import { issueKeys } from "@multica/core/issues/queries"; import type { AgentTask, TaskFailureReason } from "@multica/core/types"; import { timeAgo } from "@multica/core/utils"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@multica/ui/components/ui/tooltip"; import { ActorAvatar } from "../../common/actor-avatar"; import { TranscriptButton } from "../../common/task-transcript"; import { failureReasonLabel } from "../../agents/components/tabs/task-failure"; // Mask gradient that fades the trigger-summary text into transparency at // the right edge. Mirrors the pattern used by the desktop tab bar // (apps/desktop/.../tab-bar.tsx) and the sidebar pin item // (packages/views/layout/app-sidebar.tsx) — gives the row a smooth // visual ramp toward the trailing actions instead of a hard truncate + // ellipsis cut. const TRIGGER_MASK_STYLE: React.CSSProperties = { maskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)", WebkitMaskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)", }; // Right-panel section that lists every agent run for this issue. Active // runs sit at the top (always visible when present); past runs (terminal // statuses) collapse behind a "Show past runs (N)" toggle. // // Replaces: // - the click-to-expand timeline that used to live inside AgentLiveCard // (sticky card stays as a header-only banner) // - the standalone below the main content // // Row layout — three columns, left to right: // 1. Agent avatar (no status dot — agent availability is not the // story here; the row's right column carries the task status) // 2. Trigger description (e.g. "From comment", "Autopilot", "Retry"), // truncated with ellipsis when narrow // 3. Status + relative time, swapped to hover actions (cancel / // transcript) on hover // // One query (`listTasksByIssue`) drives both buckets — the back-end // returns every status, the front-end filters into active vs past on the // client. WS task:* events for this issue trigger an invalidate so the // list updates without polling. interface ExecutionLogSectionProps { issueId: string; } // Past-runs sort priority: failed first (needs attention), then // cancelled (procedural noise), then completed (the boring 'done' // case sinks to the bottom). Within each group, newest first. const PAST_STATUS_RANK: Record = { failed: 0, cancelled: 1, completed: 2, }; export function ExecutionLogSection({ issueId }: ExecutionLogSectionProps) { const [open, setOpen] = useState(true); const [showPast, setShowPast] = useState(false); // Cache key registered in `issueKeys.tasks` (packages/core/issues/queries.ts) // so the global useRealtimeSync `task:` prefix path invalidates it via // a `["issues", "tasks"]` prefix-match — no local WS subscriptions // needed, and the cache stays fresh even when this component isn't // mounted (e.g. user cancels from agent-side, then navigates here). const { data: tasks = [] } = useQuery({ queryKey: issueKeys.tasks(issueId), queryFn: () => api.listTasksByIssue(issueId), staleTime: 30_000, refetchOnWindowFocus: true, }); const activeTasks = useMemo( () => tasks.filter( (t) => t.status === "queued" || t.status === "dispatched" || t.status === "running", ), [tasks], ); const pastTasks = useMemo(() => { const past = tasks.filter( (t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled", ); // Stable sort: failed first, cancelled second, completed last. // Within group: newest completed_at first (fall back to created_at // for malformed rows missing completed_at). return [...past].sort((a, b) => { const rankDiff = (PAST_STATUS_RANK[a.status] ?? 99) - (PAST_STATUS_RANK[b.status] ?? 99); if (rankDiff !== 0) return rankDiff; const at = a.completed_at ?? a.created_at; const bt = b.completed_at ?? b.created_at; return new Date(bt).getTime() - new Date(at).getTime(); }); }, [tasks]); if (activeTasks.length === 0 && pastTasks.length === 0) return null; return (
{open && (
{activeTasks.map((task) => ( ))} {pastTasks.length > 0 && ( <> {activeTasks.length > 0 && (
)} {showPast && (
{pastTasks.map((task) => ( ))}
)} )}
)}
); } // ─── Trigger description ──────────────────────────────────────────────────── // Primary source: the canonical snapshot taken at task creation time // (comment text / autopilot title). Survives source edits/deletes and // is information-dense — far better than a structural label. // // Retry tasks inherit the parent's trigger_summary on the DB side (so the // snapshot survives across attempts), but a row that just shows the // inherited summary is indistinguishable from its parent. We prepend // "Retry #N" when parent_task_id is set so retries are scannable as // retries even when their summary is inherited. // // Fallback chain for legacy tasks created before the snapshot field // shipped, OR for sources we don't snapshot (direct assignment / chat): // degrade to a short structural label by trigger source. New tasks // (post-061 migration) almost always hit the snapshot path. function buildTriggerText(task: AgentTask): string { const isRetry = !!task.parent_task_id; const retryPrefix = isRetry ? task.attempt && task.attempt > 1 ? `Retry #${task.attempt} · ` : "Retry · " : ""; if (task.trigger_summary) return retryPrefix + task.trigger_summary; if (isRetry) { return task.attempt && task.attempt > 1 ? `Retry #${task.attempt}` : "Retry"; } if (task.autopilot_run_id) return "Autopilot run"; if (task.trigger_comment_id) return "Comment trigger"; return "Initial run"; } // ─── Row visual config ───────────────────────────────────────────────────── const STATUS_VISUAL: Record< AgentTask["status"], { label: string; tone: string } > = { queued: { label: "Queued", tone: "text-warning" }, dispatched: { label: "Starting", tone: "text-warning" }, running: { label: "Working", tone: "text-info" }, completed: { label: "Completed", tone: "text-success" }, failed: { label: "Failed", tone: "text-destructive" }, cancelled: { label: "Cancelled", tone: "text-muted-foreground" }, }; // Time anchor depends on status. Active rows want "Started 2m ago" / // "Queued 30s ago" — what's happening now. Past rows want "5m ago" — when // the verdict landed. function activeTimeText(task: AgentTask): string { if (task.status === "running" && task.started_at) { return timeAgo(task.started_at); } if (task.status === "dispatched" && task.dispatched_at) { return timeAgo(task.dispatched_at); } return timeAgo(task.created_at); } // ─── Active row ──────────────────────────────────────────────────────────── function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) { const [cancelling, setCancelling] = useState(false); const cfg = STATUS_VISUAL[task.status]; const trigger = buildTriggerText(task); const time = activeTimeText(task); // Transcript only meaningful once messages exist — pure-queued tasks // have nothing to show yet. const showTranscript = task.status !== "queued"; const handleCancel = async () => { if (cancelling) return; setCancelling(true); try { await api.cancelTask(issueId, task.id); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to cancel task"); setCancelling(false); } }; return ( {/* Status + time always visible — actions append on hover, never replace. Same pattern as desktop tab bar / sidebar pins. */} {cfg.label} · {time} {showTranscript && ( )} } className="flex items-center justify-center rounded p-1 text-destructive transition-colors hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-50" > {cancelling ? ( ) : ( )} Cancel task ); } // ─── Past row ────────────────────────────────────────────────────────────── function PastRow({ task }: { task: AgentTask }) { const cfg = STATUS_VISUAL[task.status]; const trigger = buildTriggerText(task); const time = task.completed_at ? timeAgo(task.completed_at) : "—"; const failureLabel = task.status === "failed" && task.failure_reason ? failureReasonLabel[task.failure_reason as TaskFailureReason] : null; return ( {failureLabel ?? cfg.label} · {time} ); } // ─── Shared row chrome ───────────────────────────────────────────────────── function RowShell({ task, children, }: { task: AgentTask; children: React.ReactNode; }) { // `relative` so the absolute-positioned RowActions slot anchors to this // row instead of an outer container. return (
{task.agent_id ? ( ) : ( )} {children}
); } // Trigger description with a mask-gradient right edge — text fades into // transparency in the trailing 12px for the same reason desktop tab / // sidebar pin do it: avoids a hard truncate cut against neighbouring // content. function TriggerText({ text }: { text: string }) { return ( {text} ); } // Hover-only action slot — absolute-positioned over the row's right edge. // Status + time stay anchored in the layout; on hover the action buttons // fade in on top of them with a left-fading gradient backdrop, so the // status copy is gracefully covered (not hard-clipped) and the row // content never reflows. Mirrors the "actions sticky over content" idiom // used by GitHub PR rows, Linear issue rows, etc. function RowActions({ children }: { children: React.ReactNode }) { return (
{children}
); }