multica/apps/web/features/issues/components/agent-live-card.tsx
Jiayuan 0431ee2ee0 fix(issues): show execution logs for mention-triggered agent tasks
AgentLiveCard and TaskRunHistory were gated on assigneeType === "agent",
so mention-triggered tasks on non-agent-assigned issues never showed
their execution logs. Remove that guard so any issue with agent tasks
displays live output and execution history.

Also populate IssueID in ListTaskMessages response so the live card's
WS event filtering works correctly on catch-up after reconnect.
2026-03-31 18:27:37 +08:00

500 lines
18 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Bot, ChevronRight, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle } from "lucide-react";
import { api } from "@/shared/api";
import { useWSEvent } from "@/features/realtime";
import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload } from "@/shared/types/events";
import type { AgentTask } from "@/shared/types/agent";
import { cn } from "@/lib/utils";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { useActorName } from "@/features/workspace";
import { redactSecrets } from "../utils/redact";
// ─── Shared types & helpers ─────────────────────────────────────────────────
/** A unified timeline entry: tool calls, thinking, text, and errors in chronological order. */
interface TimelineItem {
seq: number;
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
tool?: string;
content?: string;
input?: Record<string, unknown>;
output?: string;
}
function formatElapsed(startedAt: string): string {
const elapsed = Date.now() - new Date(startedAt).getTime();
const seconds = Math.floor(elapsed / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}m ${secs}s`;
}
function formatDuration(start: string, end: string): string {
const ms = new Date(end).getTime() - new Date(start).getTime();
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}m ${secs}s`;
}
function shortenPath(p: string): string {
const parts = p.split("/");
if (parts.length <= 3) return p;
return ".../" + parts.slice(-2).join("/");
}
function getToolSummary(item: TimelineItem): string {
if (!item.input) return "";
const inp = item.input as Record<string, string>;
// WebSearch / web search
if (inp.query) return inp.query;
// File operations
if (inp.file_path) return shortenPath(inp.file_path);
if (inp.path) return shortenPath(inp.path);
if (inp.pattern) return inp.pattern;
// Bash
if (inp.description) return String(inp.description);
if (inp.command) {
const cmd = String(inp.command);
return cmd.length > 100 ? cmd.slice(0, 100) + "..." : cmd;
}
// Agent
if (inp.prompt) {
const p = String(inp.prompt);
return p.length > 100 ? p.slice(0, 100) + "..." : p;
}
// Skill
if (inp.skill) return String(inp.skill);
// Fallback: show first string value
for (const v of Object.values(inp)) {
if (typeof v === "string" && v.length > 0 && v.length < 120) return v;
}
return "";
}
/** Build a chronologically ordered timeline from raw messages. */
function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
const items: TimelineItem[] = [];
for (const msg of msgs) {
items.push({
seq: msg.seq,
type: msg.type,
tool: msg.tool,
content: msg.content ? redactSecrets(msg.content) : msg.content,
input: msg.input,
output: msg.output ? redactSecrets(msg.output) : msg.output,
});
}
return items.sort((a, b) => a.seq - b.seq);
}
// ─── AgentLiveCard (real-time view) ────────────────────────────────────────
interface AgentLiveCardProps {
issueId: string;
agentName?: string;
}
export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
const { getActorName } = useActorName();
const [activeTask, setActiveTask] = useState<AgentTask | null>(null);
const [items, setItems] = useState<TimelineItem[]>([]);
const [elapsed, setElapsed] = useState("");
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const seenSeqs = useRef(new Set<string>());
// Check for active task on mount
useEffect(() => {
let cancelled = false;
api.getActiveTaskForIssue(issueId).then(({ task }) => {
if (!cancelled) {
setActiveTask(task);
if (task) {
api.listTaskMessages(task.id).then((msgs) => {
if (!cancelled) {
const timeline = buildTimeline(msgs);
setItems(timeline);
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
}
}).catch(() => {});
}
}
}).catch(() => {});
return () => { cancelled = true; };
}, [issueId]);
// Handle real-time task messages
useWSEvent(
"task:message",
useCallback((payload: unknown) => {
const msg = payload as TaskMessagePayload;
if (msg.issue_id !== issueId) return;
const key = `${msg.task_id}:${msg.seq}`;
if (seenSeqs.current.has(key)) return;
seenSeqs.current.add(key);
setItems((prev) => {
const item: TimelineItem = {
seq: msg.seq,
type: msg.type,
tool: msg.tool,
content: msg.content,
input: msg.input,
output: msg.output,
};
const next = [...prev, item];
next.sort((a, b) => a.seq - b.seq);
return next;
});
}, [issueId]),
);
// Handle task completion/failure
useWSEvent(
"task:completed",
useCallback((payload: unknown) => {
const p = payload as TaskCompletedPayload;
if (p.issue_id !== issueId) return;
setActiveTask(null);
setItems([]);
seenSeqs.current.clear();
}, [issueId]),
);
useWSEvent(
"task:failed",
useCallback((payload: unknown) => {
const p = payload as TaskFailedPayload;
if (p.issue_id !== issueId) return;
setActiveTask(null);
setItems([]);
seenSeqs.current.clear();
}, [issueId]),
);
// Pick up new tasks
useWSEvent(
"task:dispatch",
useCallback(() => {
api.getActiveTaskForIssue(issueId).then(({ task }) => {
if (task) {
setActiveTask(task);
setItems([]);
seenSeqs.current.clear();
}
}).catch(() => {});
}, [issueId]),
);
// Elapsed time
useEffect(() => {
if (!activeTask?.started_at && !activeTask?.dispatched_at) return;
const ref = activeTask.started_at ?? activeTask.dispatched_at!;
setElapsed(formatElapsed(ref));
const interval = setInterval(() => setElapsed(formatElapsed(ref)), 1000);
return () => clearInterval(interval);
}, [activeTask?.started_at, activeTask?.dispatched_at]);
// Auto-scroll
useEffect(() => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [items, autoScroll]);
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
setAutoScroll(scrollHeight - scrollTop - clientHeight < 40);
}, []);
if (!activeTask) return null;
const toolCount = items.filter((i) => i.type === "tool_use").length;
return (
<div className="rounded-lg border border-info/20 bg-info/5">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex items-center justify-center h-5 w-5 rounded-full bg-info/10 text-info shrink-0">
<Bot className="h-3 w-3" />
</div>
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
<Loader2 className="h-3 w-3 animate-spin text-info shrink-0" />
<span className="truncate">{(activeTask?.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"} is working</span>
</div>
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
{toolCount > 0 && (
<span className="text-xs text-muted-foreground shrink-0">
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
</span>
)}
</div>
{/* Timeline content */}
{items.length > 0 && (
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
>
{items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
))}
{!autoScroll && (
<button
onClick={() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setAutoScroll(true);
}
}}
className="sticky bottom-0 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground shadow-sm"
>
<ArrowDown className="h-3 w-3" />
Latest
</button>
)}
</div>
)}
</div>
);
}
// ─── TaskRunHistory (past execution logs) ──────────────────────────────────
interface TaskRunHistoryProps {
issueId: string;
}
export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
const [tasks, setTasks] = useState<AgentTask[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
}, [issueId]);
// Refresh when a task completes
useWSEvent(
"task:completed",
useCallback((payload: unknown) => {
const p = payload as TaskCompletedPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
}, [issueId]),
);
useWSEvent(
"task:failed",
useCallback((payload: unknown) => {
const p = payload as TaskFailedPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
}, [issueId]),
);
const completedTasks = tasks.filter((t) => t.status === "completed" || t.status === "failed");
if (completedTasks.length === 0) return null;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors py-1">
<ChevronRight className={cn("h-3 w-3 transition-transform", open && "rotate-90")} />
<Clock className="h-3 w-3" />
<span>Execution history ({completedTasks.length})</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-1 space-y-2">
{completedTasks.map((task) => (
<TaskRunEntry key={task.id} task={task} />
))}
</div>
</CollapsibleContent>
</Collapsible>
);
}
function TaskRunEntry({ task }: { task: AgentTask }) {
const [open, setOpen] = useState(false);
const [items, setItems] = useState<TimelineItem[] | null>(null);
const loadMessages = useCallback(() => {
if (items !== null) return; // already loaded
api.listTaskMessages(task.id).then((msgs) => {
setItems(buildTimeline(msgs));
}).catch(() => setItems([]));
}, [task.id, items]);
useEffect(() => {
if (open) loadMessages();
}, [open, loadMessages]);
const duration = task.started_at && task.completed_at
? formatDuration(task.started_at, task.completed_at)
: null;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/30 transition-colors border border-transparent hover:border-border">
<ChevronRight className={cn("h-3 w-3 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")} />
{task.status === "completed" ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : (
<XCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />
)}
<span className="text-muted-foreground">
{new Date(task.created_at).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}
</span>
{duration && <span className="text-muted-foreground">{duration}</span>}
<span className={cn("ml-auto capitalize", task.status === "completed" ? "text-success" : "text-destructive")}>
{task.status}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-5 mt-1 max-h-64 overflow-y-auto rounded border bg-muted/30 px-3 py-2 space-y-0.5">
{items === null ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<Loader2 className="h-3 w-3 animate-spin" />
Loading...
</div>
) : items.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">No execution data recorded.</p>
) : (
items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
))
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
// ─── Shared timeline row rendering ──────────────────────────────────────────
function TimelineRow({ item }: { item: TimelineItem }) {
switch (item.type) {
case "tool_use":
return <ToolCallRow item={item} />;
case "tool_result":
return <ToolResultRow item={item} />;
case "thinking":
return <ThinkingRow item={item} />;
case "text":
return <TextRow item={item} />;
case "error":
return <ErrorRow item={item} />;
default:
return null;
}
}
function ToolCallRow({ item }: { item: TimelineItem }) {
const [open, setOpen] = useState(false);
const summary = getToolSummary(item);
const hasInput = item.input && Object.keys(item.input).length > 0;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded px-1 -mx-1 py-0.5 text-xs hover:bg-accent/30 transition-colors">
<ChevronRight
className={cn(
"h-3 w-3 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
!hasInput && "invisible",
)}
/>
<span className="font-medium text-foreground shrink-0">{item.tool}</span>
{summary && <span className="truncate text-muted-foreground">{summary}</span>}
</CollapsibleTrigger>
{hasInput && (
<CollapsibleContent>
<pre className="ml-[18px] mt-0.5 max-h-32 overflow-auto rounded bg-muted/50 p-2 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
{redactSecrets(JSON.stringify(item.input, null, 2))}
</pre>
</CollapsibleContent>
)}
</Collapsible>
);
}
function ToolResultRow({ item }: { item: TimelineItem }) {
const [open, setOpen] = useState(false);
const output = item.output ?? "";
if (!output) return null;
const preview = output.length > 120 ? output.slice(0, 120) + "..." : output;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-start gap-1.5 rounded px-1 -mx-1 py-0.5 text-xs hover:bg-accent/30 transition-colors">
<ChevronRight
className={cn("h-3 w-3 shrink-0 text-muted-foreground transition-transform mt-0.5", open && "rotate-90")}
/>
<span className="text-muted-foreground/70 truncate">
{item.tool ? `${item.tool} result: ` : "result: "}{preview}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded bg-muted/50 p-2 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
{output.length > 4000 ? output.slice(0, 4000) + "\n... (truncated)" : output}
</pre>
</CollapsibleContent>
</Collapsible>
);
}
function ThinkingRow({ item }: { item: TimelineItem }) {
const [open, setOpen] = useState(false);
const text = item.content ?? "";
if (!text) return null;
const preview = text.length > 150 ? text.slice(0, 150) + "..." : text;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-start gap-1.5 rounded px-1 -mx-1 py-0.5 text-xs hover:bg-accent/30 transition-colors">
<Brain className="h-3 w-3 shrink-0 text-info/60 mt-0.5" />
<span className="text-muted-foreground italic truncate">{preview}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded bg-info/5 p-2 text-[11px] text-muted-foreground whitespace-pre-wrap break-words">
{text}
</pre>
</CollapsibleContent>
</Collapsible>
);
}
function TextRow({ item }: { item: TimelineItem }) {
const text = item.content ?? "";
if (!text.trim()) return null;
const lines = text.trim().split("\n").filter(Boolean);
const last = lines[lines.length - 1] ?? "";
if (!last) return null;
return (
<div className="flex items-start gap-1.5 px-1 -mx-1 py-0.5 text-xs">
<span className="h-3 w-3 shrink-0" />
<span className="text-muted-foreground/60 truncate">{last}</span>
</div>
);
}
function ErrorRow({ item }: { item: TimelineItem }) {
return (
<div className="flex items-start gap-1.5 px-1 -mx-1 py-0.5 text-xs">
<AlertCircle className="h-3 w-3 shrink-0 text-destructive mt-0.5" />
<span className="text-destructive">{item.content}</span>
</div>
);
}