mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 10:59:06 +02:00
* feat(usage): add error/failure visibility to the Usage dashboard The Usage page could only answer "how much did we spend"; nothing on it showed how often agents fail, what kind of failure it was, or which agent is responsible. Operators had to open failed tasks one at a time to spot a pattern. `agent_task_queue.failure_reason` already carries the refined 21-value taxonomy from server/pkg/taskfailure, so this is a read path over data that already exists. Backend — two rollups, both scoped by workspace/project/window like the existing dashboard endpoints: GET /api/dashboard/failures/daily per-(date, failure_reason) GET /api/dashboard/failures/by-agent per-(agent, failure_reason) They return every terminal task, not just failures: the `failure_reason: ""` row carries the succeeded count. That is what makes the error rate's denominator share filters with its numerator. The run-time rollups can't serve as that denominator — they require `started_at IS NOT NULL`, so a task that expired in the queue (the signature of a runtime outage) contributes nothing to their failed_count. A failed row with an empty reason column lands in an `unclassified` bucket rather than being mistaken for a success. Frontend: - "Errors" joins the trend toggle, daily and weekly, stacked by failure class with the bucket's error rate in the tooltip. - An Errors card breaks the window down by class and by agent, with the raw failure_reason strings behind a disclosure (unlocalised — an operator pastes them into a log search). Each agent row links to its Work tab, which lists the actual failed runs. - The 21 backend reasons fold into 7 display classes in @multica/core/dashboard. Unknown reasons — including ones from a backend newer than the client — land in "other" instead of being dropped, so the class totals always reconcile with the failure count. The Tasks KPI tile is deliberately left alone: its value counts started tasks only, so quoting the failure rollup's larger count there would put two denominators in one tile. The Errors card states its rate with the denominator spelled out instead. Migration 225 adds a partial index on agent_task_queue(completed_at) for terminal statuses. The table had no completed_at index at all, so the two pre-existing run-time rollups were already scanning it; these two new queries would have doubled that. Closes #4429 (MUL-5352) Co-authored-by: multica-agent <github@multica.ai> * fix(usage): correct the Errors drill-down, window and agent exposure Review findings on PR #5991. 1. The drill-down pointed at the wrong page. `?view=work` renders ActorIssuesPanel — the issues assigned to the agent — while its runs live in the Overview pane's ActivityTab. Link to Overview. That page also could not show why a run failed: `failureReasonLabel` was a `Record<TaskFailureReason, string>` indexed with a cast to the old 6-value coarse enum, so every refined reason the backend has written since MUL-1949 resolved to `undefined`. It is now a function over the full 21-value taxonomy plus the legacy coarse values, falling back to the raw wire string for anything newer than the client. Fixes the issue execution log too, which had the same cast. 2. The Errors card covered one more calendar day than the chart above it. `parseSinceParamInTZ` returns N+1 days of headroom on purpose and the dashboard trims the surplus client-side — but only a series carrying a date can be trimmed that way. Totals / classes / reasons now derive from the date-bucketed rollup after that trim, and the per-agent rollup (which has no date to trim on) closes its window server-side via a new `parseExactSinceParamInTZ`. At days=1 the card previously reported yesterday's failures beside a chart showing none. 3. The top-offenders list leaked agents the viewer cannot see. The failure rollups are workspace-scoped and deliberately skip per-agent visibility, but the agent list they are joined against does not — members only see a private agent when they own it or are owner/admin. `name ?? row.agentId` therefore rendered a bare UUID along with that agent's failure count, rate and dominant error class. Unresolvable agents now fold into one anonymous row, and the renderer never falls back to an id. Stricter than `bucketUnknownAgentRows` while the agent list loads: a transient flash of UUIDs is the leak, not a cosmetic glitch. Also from the review: the Errors tooltip echoed the raw Recharts dataKey ("rate_limit") instead of the translated label the legend already carries. Not changed — the schema's `failure_reason` default stays `""`. Defaulting a missing field to a failure bucket guards against a deflated rate, but the realistic drift is `omitempty` on the Go struct tag, which would strip the field from exactly the SUCCESS rows and read as a 100% error rate. Added TestDashboardFailureWireContractKeepsEmptyReason to pin that the server always emits the field, which is the assumption the default rests on. Co-authored-by: multica-agent <github@multica.ai> * fix(usage): renumber migration and fix the anonymous bucket's failure class Review findings on PR #5991, round 2. 1. Migration prefix 225 collided with `225_chat_message_channel_media_pending`, which landed on main while this branch was open — backend CI failed on TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Merged main and renumbered to 231; main now carries 225 through 230, so 226 is taken too. 2. The anonymous "Other agents" bucket could announce the wrong failure class. It merged rows that had ALREADY collapsed to one dominant class per agent, then credited each agent's entire failure count to that class. An agent failing auth 6 / timeout 5 contributed 11 to auth and 0 to timeout, so a bucket whose real composition was timeout 15 / auth 6 rendered as Auth. Fixed by anonymizing the raw per-(agent, reason) rows instead: the sentinel becomes just another agent_id and `aggregateAgentFailures` computes its classes from real counts. That also deletes the parallel bucketing pass — one identity rewrite replaces it. `knownAgentIds` moves up to where both consumers can see it. Also from the review: - The wire-contract test decoded both payloads into one map. json.Unmarshal merges into a non-nil map rather than resetting it, so a residual failure_reason from the first case could have masked an omitempty regression in the second — exactly what the test is meant to catch. Now table-driven with a fresh map per case. - A test comment still described the drill-down as pointing at the Work tab. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
556 lines
21 KiB
TypeScript
556 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Ban, CheckCircle2, ChevronRight, Loader2, RotateCcw, Square, XCircle } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { api, dispatchReasonCode } from "@multica/core/api";
|
|
import { issueKeys } from "@multica/core/issues/queries";
|
|
import type { AgentTask } from "@multica/core/types";
|
|
import { useTimeAgo } from "../../i18n";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@multica/ui/components/ui/tooltip";
|
|
import { ActorAvatar } from "../../common/actor-avatar";
|
|
import { formatDuration } from "../../agents/components/agent-activity-hover-content";
|
|
import { TranscriptButton } from "../../common/task-transcript";
|
|
import { failureReasonLabel } from "../../agents/components/tabs/task-failure";
|
|
import { useT } from "../../i18n";
|
|
import { TerminateTaskConfirmDialog } from "./terminate-task-confirm-dialog";
|
|
|
|
// 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 the in-body live
|
|
// card (the live "agent is working" signal now lives in the header via
|
|
// IssueAgentHeaderChip)
|
|
// - the standalone <TaskRunHistory> below the main content
|
|
//
|
|
// Row layout — simple left/right flex:
|
|
// 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 flexes and truncates
|
|
// 3. Status is a normal shrink-0 right column; on hover it is replaced
|
|
// in place by the action buttons (status is removed, not covered).
|
|
// Left text keeps flex-1 so the row never shows a mid-row gap. Do
|
|
// not use masks/padding gymnastics here.
|
|
//
|
|
// 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: newest first by timestamp. When two runs
|
|
// share the same timestamp, failed ranks above cancelled, which ranks
|
|
// above completed.
|
|
const PAST_STATUS_RANK: Record<string, number> = {
|
|
failed: 0,
|
|
cancelled: 1,
|
|
completed: 2,
|
|
};
|
|
|
|
export function ExecutionLogSection({ issueId }: ExecutionLogSectionProps) {
|
|
const { t } = useT("issues");
|
|
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" ||
|
|
// Daemon-parked task on a busy local_directory — still active
|
|
// (waiting on a path lock), not terminal. Surfacing it here is
|
|
// what tells the user the agent is alive and will resume.
|
|
t.status === "waiting_local_directory" ||
|
|
t.status === "running",
|
|
),
|
|
[tasks],
|
|
);
|
|
|
|
const pastTasks = useMemo(() => {
|
|
const past = tasks.filter(
|
|
(t) =>
|
|
t.status === "completed" ||
|
|
t.status === "failed" ||
|
|
t.status === "cancelled",
|
|
);
|
|
return past.toSorted((a, b) => {
|
|
const at = a.completed_at ?? a.created_at;
|
|
const bt = b.completed_at ?? b.created_at;
|
|
const timeDiff = new Date(bt).getTime() - new Date(at).getTime();
|
|
if (timeDiff !== 0) return timeDiff;
|
|
return (
|
|
(PAST_STATUS_RANK[a.status] ?? 99) -
|
|
(PAST_STATUS_RANK[b.status] ?? 99)
|
|
);
|
|
});
|
|
}, [tasks]);
|
|
|
|
if (activeTasks.length === 0 && pastTasks.length === 0) return null;
|
|
|
|
return (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
className={`flex w-full items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors mb-2 hover:bg-accent/70 ${
|
|
open ? "" : "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
onClick={() => setOpen(!open)}
|
|
>
|
|
{t(($) => $.execution_log.section)}
|
|
<ChevronRight
|
|
className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${
|
|
open ? "rotate-90" : ""
|
|
}`}
|
|
/>
|
|
{activeTasks.length > 0 && (
|
|
<span className="ml-auto inline-flex items-center gap-1 text-info">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-info animate-pulse" />
|
|
<span className="font-mono tabular-nums">{activeTasks.length}</span>
|
|
</span>
|
|
)}
|
|
</button>
|
|
{open && (
|
|
<div className="space-y-0.5 pl-2">
|
|
{activeTasks.map((task) => (
|
|
<ActiveTaskRow key={task.id} task={task} issueId={issueId} />
|
|
))}
|
|
|
|
{pastTasks.length > 0 && (
|
|
<>
|
|
{activeTasks.length > 0 && (
|
|
<div className="my-1.5 border-t border-border/60" />
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPast(!showPast)}
|
|
className="flex w-full items-center gap-1 rounded px-1 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
|
|
>
|
|
<ChevronRight
|
|
className={`!size-3 shrink-0 stroke-[2.5] transition-transform ${
|
|
showPast ? "rotate-90" : ""
|
|
}`}
|
|
/>
|
|
{showPast
|
|
? t(($) => $.execution_log.hide_past, { count: pastTasks.length })
|
|
: t(($) => $.execution_log.show_past, { count: pastTasks.length })}
|
|
</button>
|
|
{showPast && (
|
|
<div className="mt-0.5 space-y-0.5">
|
|
{pastTasks.map((task) => (
|
|
<PastRow key={task.id} task={task} issueId={issueId} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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.
|
|
|
|
// ─── Row visual config ─────────────────────────────────────────────────────
|
|
|
|
const STATUS_TONE: Record<AgentTask["status"], string> = {
|
|
queued: "text-warning",
|
|
dispatched: "text-warning",
|
|
// Same tone as queued/dispatched — visually "stopped" so users see the
|
|
// task is parked, but distinguished by the status label.
|
|
waiting_local_directory: "text-warning",
|
|
running: "text-info",
|
|
completed: "text-success",
|
|
failed: "text-destructive",
|
|
cancelled: "text-muted-foreground",
|
|
};
|
|
|
|
// ─── Active row ────────────────────────────────────────────────────────────
|
|
|
|
import { stripMentionMarkdown } from "../utils/strip-mention-markdown";
|
|
|
|
function useTriggerText(task: AgentTask): string {
|
|
const { t } = useT("issues");
|
|
const isRetry = !!task.parent_task_id;
|
|
const retryPrefix = isRetry
|
|
? task.attempt && task.attempt > 1
|
|
? t(($) => $.execution_log.trigger_retry_attempt_prefix, { attempt: task.attempt })
|
|
: t(($) => $.execution_log.trigger_retry_prefix)
|
|
: "";
|
|
|
|
if (task.trigger_summary) return retryPrefix + stripMentionMarkdown(task.trigger_summary);
|
|
if (isRetry) {
|
|
return task.attempt && task.attempt > 1
|
|
? t(($) => $.execution_log.trigger_retry_attempt, { attempt: task.attempt })
|
|
: t(($) => $.execution_log.trigger_retry);
|
|
}
|
|
if (task.autopilot_run_id) return t(($) => $.execution_log.trigger_autopilot);
|
|
if (task.trigger_comment_id) return t(($) => $.execution_log.trigger_comment);
|
|
// Assignment-triggered run that carried a handoff note: show the note inline
|
|
// (truncated by TriggerText) the way comment triggers show their text, so the
|
|
// row reads as the handoff instead of the generic "initial run".
|
|
if (task.handoff_note) {
|
|
return retryPrefix + t(($) => $.execution_log.trigger_handoff_prefix) + stripMentionMarkdown(task.handoff_note);
|
|
}
|
|
return t(($) => $.execution_log.trigger_initial);
|
|
}
|
|
|
|
function useStatusLabel(status: AgentTask["status"]): string {
|
|
const { t } = useT("issues");
|
|
switch (status) {
|
|
case "queued": return t(($) => $.execution_log.status_queued);
|
|
case "dispatched": return t(($) => $.execution_log.status_dispatched);
|
|
case "waiting_local_directory":
|
|
return t(($) => $.execution_log.status_waiting_local_directory);
|
|
case "running": return t(($) => $.execution_log.status_running);
|
|
case "completed": return t(($) => $.execution_log.status_completed);
|
|
case "failed": return t(($) => $.execution_log.status_failed);
|
|
case "cancelled": return t(($) => $.execution_log.status_cancelled);
|
|
}
|
|
}
|
|
|
|
// One active (running / queued / dispatched / parked) task row. Running rows
|
|
// keep status to a single live elapsed timer; transcript and stop stay available
|
|
// as hover actions. Transcript content lazy-loads on click via TranscriptButton,
|
|
// so the row no longer fetches task messages just to render a count.
|
|
export function ActiveTaskRow({
|
|
task,
|
|
issueId,
|
|
}: {
|
|
task: AgentTask;
|
|
issueId: string;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const [cancelling, setCancelling] = useState(false);
|
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
const tone = STATUS_TONE[task.status];
|
|
const label = useStatusLabel(task.status);
|
|
const trigger = useTriggerText(task);
|
|
|
|
// Running rows show a live-ticking elapsed timer (the ticking digits carry
|
|
// "alive", the duration carries "how long"). Only running rows tick.
|
|
const [now, setNow] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
if (task.status !== "running") return;
|
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
return () => clearInterval(id);
|
|
}, [task.status]);
|
|
const elapsed =
|
|
task.status === "running"
|
|
? formatDuration(
|
|
task.started_at ?? task.dispatched_at ?? task.created_at,
|
|
now,
|
|
)
|
|
: "";
|
|
|
|
// Transcript only meaningful once messages exist — pure-queued and
|
|
// waiting_local_directory tasks haven't streamed any agent output yet.
|
|
const showTranscript =
|
|
task.status !== "queued" && task.status !== "waiting_local_directory";
|
|
|
|
const handleCancel = async () => {
|
|
if (cancelling) return;
|
|
setCancelling(true);
|
|
try {
|
|
await api.cancelTask(issueId, task.id);
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : t(($) => $.execution_log.cancel_failed));
|
|
setCancelling(false);
|
|
}
|
|
};
|
|
|
|
const requestCancel = () => {
|
|
if (cancelling) return;
|
|
setConfirmOpen(true);
|
|
};
|
|
|
|
return (
|
|
<RowShell task={task}>
|
|
<TriggerText text={trigger} />
|
|
<TaskCommentCoverage task={task} />
|
|
<RowStatus title={label}>
|
|
{task.status === "running" ? (
|
|
<>
|
|
<span className="text-info tabular-nums">{elapsed}</span>
|
|
<span className="sr-only">{label}</span>
|
|
</>
|
|
) : (
|
|
<span className={`${tone} min-w-0 truncate`}>{label}</span>
|
|
)}
|
|
</RowStatus>
|
|
<RowActions>
|
|
{showTranscript && (
|
|
<TranscriptButton
|
|
task={task}
|
|
agentName=""
|
|
isLive={task.status === "running"}
|
|
title={t(($) => $.execution_log.transcript_tooltip)}
|
|
/>
|
|
)}
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<button
|
|
type="button"
|
|
onClick={requestCancel}
|
|
disabled={cancelling}
|
|
aria-label={t(($) => $.execution_log.cancel_task_aria)}
|
|
/>
|
|
}
|
|
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 ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Square className="h-3.5 w-3.5" />
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t(($) => $.execution_log.cancel_task_tooltip)}</TooltipContent>
|
|
</Tooltip>
|
|
</RowActions>
|
|
<TerminateTaskConfirmDialog
|
|
open={confirmOpen}
|
|
onOpenChange={setConfirmOpen}
|
|
onConfirm={() => void handleCancel()}
|
|
showRunningNote={
|
|
task.status === "running" ||
|
|
task.status === "dispatched" ||
|
|
task.status === "waiting_local_directory"
|
|
}
|
|
/>
|
|
</RowShell>
|
|
);
|
|
}
|
|
|
|
// ─── Past row ──────────────────────────────────────────────────────────────
|
|
|
|
function PastRow({ task, issueId }: { task: AgentTask; issueId: string }) {
|
|
const { t } = useT("issues");
|
|
const timeAgo = useTimeAgo();
|
|
const [retrying, setRetrying] = useState(false);
|
|
const label = useStatusLabel(task.status);
|
|
const trigger = useTriggerText(task);
|
|
const time = task.completed_at ? timeAgo(task.completed_at) : "—";
|
|
const failureLabel =
|
|
task.status === "failed" ? failureReasonLabel(task.failure_reason) : null;
|
|
|
|
// Retry only makes sense for terminal-but-not-success rows. Passing
|
|
// task.id targets this specific row's agent — without it, the rerun
|
|
// endpoint would fall back to the issue's current assignee and the
|
|
// wrong agent would fire on rows whose agent has since been displaced
|
|
// (e.g. reassignment, squad worker, or a one-off @-mention agent).
|
|
const canRetry = task.status === "failed" || task.status === "cancelled";
|
|
|
|
const handleRetry = async () => {
|
|
if (retrying) return;
|
|
setRetrying(true);
|
|
try {
|
|
await api.rerunIssue(issueId, task.id);
|
|
} catch (e) {
|
|
// A rerun is now re-gated on the operator's invoke permission (MUL-4525):
|
|
// a structured 403 means the agent can't be triggered, not a transient
|
|
// failure — localize it instead of echoing the server's generic message.
|
|
toast.error(
|
|
dispatchReasonCode(e) === "invocation_not_allowed"
|
|
? t(($) => $.execution_log.retry_blocked)
|
|
: e instanceof Error
|
|
? e.message
|
|
: t(($) => $.execution_log.retry_failed),
|
|
);
|
|
} finally {
|
|
// Reset on both success and failure: the past row stays mounted
|
|
// (its task.id is unchanged), so leaving `retrying` true on success
|
|
// would pin the button as a permanent spinner.
|
|
setRetrying(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<RowShell task={task}>
|
|
<TriggerText text={trigger} />
|
|
<TaskCommentCoverage task={task} />
|
|
<RowStatus title={failureLabel ?? label}>
|
|
<TaskStatusIcon status={task.status} />
|
|
<span className="sr-only">{failureLabel ?? label}</span>
|
|
<span className="text-muted-foreground">{time}</span>
|
|
</RowStatus>
|
|
<RowActions>
|
|
<TranscriptButton task={task} agentName="" title={t(($) => $.execution_log.transcript_tooltip)} />
|
|
{canRetry && (
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<button
|
|
type="button"
|
|
onClick={handleRetry}
|
|
disabled={retrying}
|
|
aria-label={t(($) => $.execution_log.retry_task_aria)}
|
|
/>
|
|
}
|
|
className="flex items-center justify-center rounded p-1 text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{retrying ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t(($) => $.execution_log.retry_task_tooltip)}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</RowActions>
|
|
</RowShell>
|
|
);
|
|
}
|
|
|
|
// ─── Shared row chrome ─────────────────────────────────────────────────────
|
|
|
|
function RowShell({
|
|
task,
|
|
children,
|
|
}: {
|
|
task: AgentTask;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="group/execution-log-row flex items-center gap-2 overflow-hidden rounded px-1 py-1.5 transition-colors hover:bg-accent/40">
|
|
{task.agent_id ? (
|
|
<ActorAvatar
|
|
actorType="agent"
|
|
actorId={task.agent_id}
|
|
size="sm"
|
|
enableHoverCard
|
|
/>
|
|
) : (
|
|
<span className="inline-block h-5 w-5 shrink-0 rounded-full bg-muted" />
|
|
)}
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TriggerText({ text }: { text: string }) {
|
|
return <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{text}</span>;
|
|
}
|
|
|
|
function supportsCommentCoverage(status: AgentTask["status"]): boolean {
|
|
switch (status) {
|
|
case "queued":
|
|
case "dispatched":
|
|
case "waiting_local_directory":
|
|
case "running":
|
|
case "completed":
|
|
case "failed":
|
|
case "cancelled":
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function TaskCommentCoverage({ task }: { task: AgentTask }) {
|
|
const { t } = useT("issues");
|
|
if (!supportsCommentCoverage(task.status)) return null;
|
|
|
|
// Queued rows show the planned coverage: coalesced_comment_ids deliberately
|
|
// excludes the newest trigger. Once claimed, prefer the server's actual
|
|
// delivery receipt. Only legacy rows where that field is absent fall back to
|
|
// the plan; an explicit [] means the claim delivered no comments.
|
|
const plannedCommentIds = [
|
|
task.trigger_comment_id,
|
|
...(task.coalesced_comment_ids ?? []),
|
|
];
|
|
const coverageIds =
|
|
task.status !== "queued" && task.delivered_comment_ids !== undefined
|
|
? task.delivered_comment_ids
|
|
: plannedCommentIds;
|
|
const commentIds = new Set(
|
|
coverageIds.filter((id): id is string => Boolean(id)),
|
|
);
|
|
if (commentIds.size <= 1) return null;
|
|
|
|
return (
|
|
<span className="shrink-0 whitespace-nowrap text-[11px] text-muted-foreground">
|
|
{t(($) => $.execution_log.included_comments, { count: commentIds.size })}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function RowStatus({
|
|
children,
|
|
title,
|
|
}: {
|
|
children: React.ReactNode;
|
|
title?: string;
|
|
}) {
|
|
return (
|
|
<div
|
|
title={title}
|
|
className="flex h-7 shrink-0 items-center justify-end gap-1 overflow-hidden whitespace-nowrap text-xs [@media(hover:hover)]:group-hover/execution-log-row:hidden"
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskStatusIcon({ status }: { status: AgentTask["status"] }) {
|
|
switch (status) {
|
|
case "completed":
|
|
return <CheckCircle2 aria-hidden="true" className="h-3.5 w-3.5 text-success" />;
|
|
case "failed":
|
|
return <XCircle aria-hidden="true" className="h-3.5 w-3.5 text-destructive" />;
|
|
case "cancelled":
|
|
return <Ban aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Action slot — visible by default for touch devices. On hover-capable
|
|
// surfaces, it replaces the status column in place on row hover.
|
|
function RowActions({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex h-7 items-center gap-0.5 [@media(hover:hover)]:hidden [@media(hover:hover)]:group-hover/execution-log-row:flex">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|