Files
multica/packages/views/issues/components/execution-log-section.tsx
Naiyuan Qing f745a3bbbe feat(agent): presence v3 + execution log + trigger summary (#1823)
* refactor(views): migrate agent/runtime/skill lists to TanStack DataTable

Replace the per-page CSS Grid + minmax(min, fr) + sticky-first-col + truncate
implementation with a TanStack Table backend rendered through a Dice UI-style
DataTable shell. Column widths are now px-based via column.size, so cells
no longer shrink or auto-truncate as the viewport narrows; when the sum of
columns exceeds the viewport, the container scrolls horizontally instead.

- Add @tanstack/react-table to the catalog (8.21.3) and wire it into
  packages/ui (dep) and packages/views (peerDep).
- packages/ui: new DataTable + DataTableColumnHeader + lib/data-table.ts
  (getColumnPinningStyle), adapted from Dice UI's registry. The shell
  renders <table> directly (skipping shadcn's <Table> wrapper) so its own
  outer overflow controls both axes — no nested overflow conflicts.
- packages/views: each list now declares ColumnDef[] with explicit
  cell renderers. Row click navigates to detail via onRowClick (instead of
  wrapping <tr> in <a>, which is invalid HTML); kebab dropdowns
  stopPropagation so they don't trigger the row navigation.
- Drop the previous AGENT_LIST_GRID / GRID_WITH_OWNER / ROW_GRID
  templates and the sticky-first-col / subgrid mechanics that came with
  them. agent-list-item.tsx is removed; runtime-list.tsx and
  skills-page.tsx are trimmed to thin wrappers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): cap description at 255 chars (db + api + ui)

Symmetric enforcement across DB, server, and UI:

- Migration 060: pre-flight truncate of any oversize rows, then ADD
  CONSTRAINT NOT VALID + VALIDATE CONSTRAINT so the new check doesn't
  block writes during validation.
- Server handler validates utf8.RuneCountInString on Create/Update and
  rejects over-limit input with 400.
- Front-end gets AGENT_DESCRIPTION_MAX_LENGTH in core/agents/constants
  (single source of truth shared by the create dialog + edit modal +
  test suite) and a CharCounter component that warns at 90% and errors
  past the cap.
- Description editor moves from a 288px popover to a roomy modal.
  Editor body is mounted only while the dialog is open, so the local
  draft state is locked in at mount time and never reset by an external
  WS update — the React-recommended replacement for the
  useEffect(reset, [value]) anti-pattern.

Counted in code points everywhere (rune count / spread length /
char_length) so multibyte input agrees across all three layers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(views): data-table polish across runtime + skill lists

Builds on the DataTable migration in 2be0f287:

- Add ColumnMeta.grow flag — declared via TanStack module augmentation
  in ui/lib/data-table.ts. Columns marked meta.grow skip their inline
  width so fixed table-layout assigns them the leftover container space
  (no spacer column). The Title-grows / others-fixed pattern from
  Linear / GitHub PR rows.
- Authoritative table min-width = sum of column.size, applied to the
  <table> itself (fixed-layout ignores cell-level min-width per spec,
  so the floor has to live on the table).
- Header tightens to h-8 + uppercase + tracking-wider; pinned cells
  switch to opaque bg + group-hover so they cover content scrolling
  beneath them and follow row hover state.
- Toolbar slot removed from DataTable (callers wrap the toolbar
  themselves now — keeps DataTable single-purpose).

Also: hover-card popup stops contextmenu / auxclick / dblclick from
bubbling out (in addition to click). Stops the popup from triggering
ancestor handlers (e.g. issue list rows) on right-click / middle-click
without breaking Base UI's outside-click dismiss, which listens to
pointerdown — pointerdown is deliberately NOT stopped.

Runtime + skill list pages updated to use the new sizing model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(agent): drop LastTaskState, introduce 3-state Workload

Continues the presence-model rework started in #1794 / #1798.

The previous LastTaskState union (running / completed / failed /
cancelled / idle) carried historical outcome at the list level — a
runtime-healthy agent whose last task failed showed a sticky red dot
indistinguishable from a daemon-dead agent.

New model: presence is two orthogonal "right-now" dimensions:

  AgentAvailability — runtime reachability only (online / unstable /
                      offline). Drives the dot colour everywhere.
  Workload          — current load (working / queued / idle). Three
                      states, never historical. Failure / completion /
                      cancellation are surfaced via Recent Work + Inbox,
                      not list-level state.

`queued` (= nothing running, ≥1 queued) is an honest "stuck on offline
runtime" signal. To avoid amber flashes during the brief enqueue→claim
race on healthy runtimes, the queued chip composes with availability:
muted on online, warning amber otherwise.

Activity tab cleanup that follows from the new model:
  - failureReasonLabel relocated from agents/presence.ts to
    tabs/task-failure.ts (presence no longer owns historical state).
  - Recent Work paginates (5 initial, +20 per "Show more"); chat-session
    tasks are filtered out of every Agent-scoped surface to keep
    "team work" separate from private chat.
  - Agents page drops the lastTaskFilter chip group; users find broken
    agents via Inbox / Recent Work, not a list-level filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(task): trigger summary snapshot + task:queued lifecycle event

Two task-lifecycle improvements that ship together because they share
the same enqueue/retry hot paths and changes interleave inside task.go:

1. trigger_summary snapshot (migration 061)

   New nullable column on agent_task_queue. Comment-triggered tasks
   snapshot the comment content; autopilot tasks snapshot the run title.
   Truncated to 200 runes via strings.Builder so multibyte input counts
   correctly without O(N²) concatenation. Snapshot survives source
   edits/deletes — every task row self-describes across surfaces (issue
   detail Execution log, agent activity tooltip, inbox) without joining
   back to the originating row.

   Retry rows inherit the parent's snapshot (CreateRetryTask SELECT) so
   the description stays meaningful across attempts. The UI is
   responsible for stacking "Retry #N" context on top.

2. task:queued WS event

   New protocol event covering the ∅ → queued transition. Front-end
   types/events.ts registers it; use-realtime-sync's task: prefix path
   already invalidates task caches via onAny, so old clients without
   this exact-match subscription still refresh correctly. Specific
   subscribers (sticky banner) get sub-second updates instead of
   waiting for daemon claim.

   Retry path now broadcasts task:queued (not task:dispatch) — same
   status transition shape as enqueue, so all "new task created" paths
   agree on one event type.

   Ordering: broadcastTaskEvent runs *before* notifyTaskAvailable so
   the queued event is published into the WS bus before the daemon is
   poked. Without this, a fast daemon could claim and emit task:dispatch
   over the wire before the in-process queued broadcast fan-out reached
   clients — race window is tiny but unsafe-by-construction.

   Per-agent task list (agentTasksKeys.all) and per-issue task list
   (["issues","tasks"]) added to the task: invalidation set so Activity
   tab Recent Work and the Execution log section stay fresh.

Type contracts: AgentTask gains parent_task_id / attempt /
trigger_comment_id (already returned by the API, just missing from TS)
plus the new trigger_summary field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(issue): ExecutionLogSection — unified active+past runs panel

Replaces two pieces:
  - the click-to-expand timeline that lived inside AgentLiveCard
  - the standalone TaskRunHistory below the main content

with a single right-panel section that lists every agent run for the
issue. Active runs sit at the top (always visible when present); past
runs collapse behind a "Show past runs (N)" toggle, sorted failed →
cancelled → completed within group.

Active rows show the trigger summary, status + relative time, and
Cancel / Transcript actions on hover (gradient backdrop fades the
status text rather than hard-clipping). Past rows show the same
shape minus Cancel.

Retry tasks prepend "Retry #N · " to the inherited summary so they're
distinguishable from their parent (which would otherwise share the
exact same trigger text).

Cache key registered as issueKeys.tasks(issueId); the global
useRealtimeSync task: prefix path already invalidates ["issues","tasks"]
on every task lifecycle event, so the section stays fresh without
local WS subscriptions.

AgentLiveCard slims down to a header-only "agent is working" sticky
banner — keeps the at-a-glance "is anyone working on this right now"
signal and the Stop / Transcript actions, drops the inline timeline
that ExecutionLogSection now owns. Subscribes to both task:queued and
task:dispatch so retries (which only emit queued) land in the banner
without waiting for daemon claim.

issue-detail mounts ExecutionLogSection in the right panel and removes
the now-defunct TaskRunHistory call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:50:58 +08:00

393 lines
15 KiB
TypeScript

"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 <TaskRunHistory> 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<string, number> = {
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 (
<div>
<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)}
>
Execution log
<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) => (
<ActiveRow 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 ? "Hide" : "Show"} past runs ({pastTasks.length})
</button>
{showPast && (
<div className="mt-0.5 space-y-0.5">
{pastTasks.map((task) => (
<PastRow key={task.id} task={task} />
))}
</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.
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 (
<RowShell task={task}>
<TriggerText text={trigger} />
{/* Status + time always visible — actions append on hover, never
replace. Same pattern as desktop tab bar / sidebar pins. */}
<span className="shrink-0 whitespace-nowrap text-xs">
<span className={cfg.tone}>{cfg.label}</span>
<span className="text-muted-foreground"> · {time}</span>
</span>
<RowActions>
{showTranscript && (
<TranscriptButton
task={task}
agentName=""
isLive
title="View transcript"
/>
)}
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={handleCancel}
disabled={cancelling}
aria-label="Cancel task"
/>
}
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>Cancel task</TooltipContent>
</Tooltip>
</RowActions>
</RowShell>
);
}
// ─── 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 (
<RowShell task={task}>
<TriggerText text={trigger} />
<span className="shrink-0 whitespace-nowrap text-xs">
<span className={cfg.tone}>{failureLabel ?? cfg.label}</span>
<span className="text-muted-foreground"> · {time}</span>
</span>
<RowActions>
<TranscriptButton task={task} agentName="" title="View transcript" />
</RowActions>
</RowShell>
);
}
// ─── 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 (
<div className="group relative flex items-center gap-2 rounded px-1 py-1.5 transition-colors hover:bg-accent/40">
{task.agent_id ? (
<ActorAvatar
actorType="agent"
actorId={task.agent_id}
size={20}
enableHoverCard
/>
) : (
<span className="inline-block h-5 w-5 shrink-0 rounded-full bg-muted" />
)}
{children}
</div>
);
}
// 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 (
<span
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-xs text-muted-foreground"
style={TRIGGER_MASK_STYLE}
>
{text}
</span>
);
}
// 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 (
<div
className={[
"pointer-events-none absolute inset-y-0 right-1 flex items-center gap-0.5 pl-6 opacity-0 transition-opacity",
// The gradient backdrop blends the row's hover background (accent/40)
// from the right and fades to transparent on the left, so the
// status text underneath is dimmed gracefully rather than cut.
"bg-gradient-to-l from-accent/95 via-accent/80 to-transparent",
"group-hover:pointer-events-auto group-hover:opacity-100",
"group-focus-within:pointer-events-auto group-focus-within:opacity-100",
].join(" ")}
>
{children}
</div>
);
}