Compare commits

...

2 Commits

Author SHA1 Message Date
Naiyuan Qing
835bc7c8ff feat(issues): live event count on the agent chip + execution-log rows
Show a live "N events (elapsed)" on running agents, consistent across the
header chip, its popover rows, and the right-panel execution log.

- Read the shared per-task message cache (taskMessagesOptions, kept live by
  useRealtimeSync's global task:message handler) instead of a bespoke
  subscription — one source of truth, deduped across chip / popover / panel /
  transcript, no extra WS wiring.
- Extract <RunningStat> (event count in info-blue + elapsed in muted parens)
  so all surfaces render the running stat identically.
- ExecutionLogSection running rows now show the same "N events (elapsed)";
  the transcript opened from them streams live from the shared cache.
- Chip: single running shows events (elapsed); multiple shows "N working".
- i18n: add agent_live.event_count (4 locales).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:36:56 +08:00
Naiyuan Qing
34b6cb7aae feat(issues): move agent live signal into the issue-detail header
Replace the in-body sticky "agent is working" card (AgentLiveCard) with a
compact chip in the issue-detail header, so the live signal sits in one
fixed place and never competes with sticky banners in the content column.

- New IssueAgentHeaderChip: avatar(s) + live-ticking blue elapsed time;
  click opens a popover listing every active task.
- Popover reuses ExecutionLogSection's ActiveTaskRow (now exported) so the
  popover and the right panel are literally the same row — no duplication.
- PopoverContent gains an optional keepMounted so the row's confirm dialog
  survives the popover closing on Stop.
- Running rows in ExecutionLogSection drop the blue spinner for a
  live-ticking blue elapsed timer (panel + popover share this).
- Source the chip from the workspace agent-task snapshot filtered by issue
  (same source as board/list indicators, zero extra network); delete the
  old AgentLiveCard + its test and its heavy per-issue WS machinery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:48:27 +08:00
13 changed files with 291 additions and 850 deletions

View File

@@ -19,14 +19,20 @@ function PopoverContent({
alignOffset = 0,
side = "bottom",
sideOffset = 4,
keepMounted,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
> &
// Keep the popup in the DOM while closed. Needed when the content hosts a
// modal (confirm dialog / transcript) that opens on click: without it the
// popup unmounts the moment focus leaves for the dialog, tearing the dialog
// down with it.
Pick<PopoverPrimitive.Portal.Props, "keepMounted">) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Portal keepMounted={keepMounted}>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}

View File

@@ -136,7 +136,10 @@ function runtimeFrom<T extends { id: string }>(
// Capped at hours — anything over a day for a running task is a sign of a
// stuck runtime, but the hover card is not the place to relitigate that;
// the row will read as `26h 12m` and the user can act.
function formatDuration(fromIso: string, nowMs: number): string {
//
// Exported so the issue-detail header live chip formats its collapsed
// single-agent elapsed with the same `2m 14s` / `1h 03m` rule used here.
export function formatDuration(fromIso: string, nowMs: number): string {
const start = new Date(fromIso).getTime();
if (!Number.isFinite(start)) return "";
const sec = Math.max(0, Math.round((nowMs - start) / 1000));

View File

@@ -1,371 +0,0 @@
import { useEffect } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent as rtlFireEvent, render, screen, waitFor } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import type { AgentTask } from "@multica/core/types/agent";
import enCommon from "../../locales/en/common.json";
import enIssues from "../../locales/en/issues.json";
const TEST_RESOURCES = { en: { common: enCommon, issues: enIssues } };
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
// Capture WS event handlers so the test can drive them directly. The card
// subscribes to task:queued, task:dispatch, task:completed, task:failed,
// task:cancelled, and task:message via useWSEvent. We mirror the real
// hook's useEffect-based subscription so stale subscriptions clean up
// across re-renders (otherwise every render would stack a duplicate
// handler and one event would fan out into many reconcile calls).
type EventHandler = (payload: unknown) => void;
const wsHandlers = vi.hoisted(() => new Map<string, Set<EventHandler>>());
const wsReconnectCallbacks = vi.hoisted(() => new Set<() => void>());
vi.mock("@multica/core/realtime", () => ({
useWSEvent: (event: string, handler: EventHandler) => {
useEffect(() => {
const set = wsHandlers.get(event) ?? new Set<EventHandler>();
set.add(handler);
wsHandlers.set(event, set);
return () => {
set.delete(handler);
};
}, [event, handler]);
},
useWSReconnect: (cb: () => void) => {
useEffect(() => {
wsReconnectCallbacks.add(cb);
return () => {
wsReconnectCallbacks.delete(cb);
};
}, [cb]);
},
}));
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({
getActorName: (_: string, id: string) => (id ? `Agent ${id}` : "Agent"),
getActorInitials: (_: string, id: string) =>
id ? id.slice(0, 2).toUpperCase() : "AG",
getActorAvatarUrl: () => null,
}),
}));
vi.mock("../../common/actor-avatar", () => ({
ActorAvatar: ({ actorId }: { actorId: string }) => (
<span data-testid="actor-avatar">{actorId}</span>
),
}));
vi.mock("../../common/task-transcript", async () => {
const buildTimeline = vi.fn().mockReturnValue([]);
const coalesceTimelineItems = vi.fn((items) => items);
const appendTimelineItem = vi.fn((items, item) => [...items, item]);
return {
TranscriptButton: () => <button data-testid="transcript-button">transcript</button>,
appendTimelineItem,
buildTimeline,
coalesceTimelineItems,
};
});
const mockApi = vi.hoisted(() => ({
getActiveTasksForIssue: vi.fn(),
listTaskMessages: vi.fn(),
cancelTask: vi.fn(),
}));
vi.mock("@multica/core/api", () => ({
api: mockApi,
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
import { AgentLiveCard } from "./agent-live-card";
function makeTask(id: string, overrides: Partial<AgentTask> = {}): AgentTask {
return {
id,
agent_id: "agent-1",
runtime_id: "rt-1",
issue_id: "issue-1",
status: "running",
priority: 0,
dispatched_at: "2026-01-01T00:00:00Z",
started_at: "2026-01-01T00:00:00Z",
completed_at: null,
result: null,
error: null,
created_at: "2026-01-01T00:00:00Z",
...overrides,
};
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function deferred<T>(): Deferred<T> {
let resolveFn!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolveFn = res;
});
return { promise, resolve: resolveFn };
}
function fireEvent(event: string, payload: unknown) {
const handlers = wsHandlers.get(event) ?? [];
for (const h of handlers) h(payload);
}
function renderCard(issueId = "issue-1") {
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<AgentLiveCard issueId={issueId} />
</I18nProvider>,
);
}
beforeEach(() => {
wsHandlers.clear();
wsReconnectCallbacks.clear();
mockApi.getActiveTasksForIssue.mockReset();
mockApi.listTaskMessages.mockReset();
mockApi.listTaskMessages.mockResolvedValue([]);
mockApi.cancelTask.mockReset();
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("AgentLiveCard reconcile race", () => {
it("does not re-add a banner when an older active-task response resolves after a newer empty one", async () => {
const mountFetch = deferred<{ tasks: AgentTask[] }>();
const queuedFetch = deferred<{ tasks: AgentTask[] }>();
const completedFetch = deferred<{ tasks: AgentTask[] }>();
// The component issues three reconciles in this test:
// 1. mount
// 2. task:queued
// 3. task:completed (after optimistic delete)
// We control the order they resolve to reproduce the GPT-Boy race.
mockApi.getActiveTasksForIssue
.mockReturnValueOnce(mountFetch.promise)
.mockReturnValueOnce(queuedFetch.promise)
.mockReturnValueOnce(completedFetch.promise);
renderCard();
// Mount call resolves with empty — no banner yet.
await act(async () => {
mountFetch.resolve({ tasks: [] });
});
expect(screen.queryByText(/is working/)).toBeNull();
// task:queued fires; reconcile A is now in flight (queuedFetch).
act(() => {
fireEvent("task:queued", { issue_id: "issue-1", task_id: "task-1" });
});
// task:completed fires; handler optimistically deletes (no-op since
// the banner isn't rendered yet) then issues reconcile B (completedFetch).
act(() => {
fireEvent("task:completed", { issue_id: "issue-1", task_id: "task-1" });
});
// Reconcile B resolves first with empty list — server truth says no
// active tasks. State is empty.
await act(async () => {
completedFetch.resolve({ tasks: [] });
});
expect(screen.queryByText(/is working/)).toBeNull();
// Reconcile A (older, slow) resolves last with a stale snapshot that
// still includes the task. With the generation guard, this response
// must be dropped. Without the guard, the banner would re-appear.
await act(async () => {
queuedFetch.resolve({ tasks: [makeTask("task-1")] });
});
// The banner must NOT come back.
expect(screen.queryByText(/is working/)).toBeNull();
expect(mockApi.getActiveTasksForIssue).toHaveBeenCalledTimes(3);
});
it("WS reconnect refetch removes a stale banner whose end event was lost", async () => {
const mountFetch = deferred<{ tasks: AgentTask[] }>();
const reconnectFetch = deferred<{ tasks: AgentTask[] }>();
mockApi.getActiveTasksForIssue
.mockReturnValueOnce(mountFetch.promise)
.mockReturnValueOnce(reconnectFetch.promise);
renderCard();
// Mount sees the task as active — banner shows.
await act(async () => {
mountFetch.resolve({ tasks: [makeTask("task-1")] });
});
await waitFor(() => {
expect(screen.getByText(/is working/)).toBeTruthy();
});
// Simulate the WS dropping task:completed and then reconnecting.
// The reconnect callback runs reconcile, which fetches and finds the
// task is no longer active.
expect(wsReconnectCallbacks.size).toBeGreaterThan(0);
act(() => {
for (const cb of wsReconnectCallbacks) cb();
});
await act(async () => {
reconnectFetch.resolve({ tasks: [] });
});
// The banner self-heals.
await waitFor(() => {
expect(screen.queryByText(/is working/)).toBeNull();
});
});
});
describe("AgentLiveCard queued rendering", () => {
it("renders 'is queued' copy without transcript when status is queued", async () => {
const queuedTask = makeTask("task-q", {
status: "queued",
dispatched_at: null,
started_at: null,
});
mockApi.getActiveTasksForIssue.mockResolvedValueOnce({ tasks: [queuedTask] });
renderCard();
await waitFor(() => {
expect(screen.getByText(/is queued/)).toBeTruthy();
});
// No execution transcript while queued — no log to show yet.
expect(screen.queryByTestId("transcript-button")).toBeNull();
// Cancel button is still available so users can drop a queued task.
expect(screen.getByText("Stop")).toBeTruthy();
});
it("Stop button opens a confirm dialog and only calls cancelTask after the user confirms", async () => {
const runningTask = makeTask("task-r", { status: "running" });
mockApi.getActiveTasksForIssue.mockResolvedValueOnce({ tasks: [runningTask] });
mockApi.cancelTask.mockResolvedValue(undefined);
renderCard();
await waitFor(() => {
expect(screen.getByText("Stop")).toBeTruthy();
});
// First click should not hit the API — it only opens the confirm.
await act(async () => {
rtlFireEvent.click(screen.getByText("Stop"));
});
expect(mockApi.cancelTask).not.toHaveBeenCalled();
expect(screen.getByText(/Stop this task\?/)).toBeTruthy();
// Confirm — now the cancel fires.
await act(async () => {
rtlFireEvent.click(screen.getByRole("button", { name: "Stop task" }));
});
expect(mockApi.cancelTask).toHaveBeenCalledWith("issue-1", "task-r");
});
it("Stop confirm dialog dismisses without cancelling when the user picks Keep running", async () => {
const runningTask = makeTask("task-r", { status: "running" });
mockApi.getActiveTasksForIssue.mockResolvedValueOnce({ tasks: [runningTask] });
mockApi.cancelTask.mockResolvedValue(undefined);
renderCard();
await waitFor(() => {
expect(screen.getByText("Stop")).toBeTruthy();
});
await act(async () => {
rtlFireEvent.click(screen.getByText("Stop"));
});
expect(screen.getByText(/Stop this task\?/)).toBeTruthy();
await act(async () => {
rtlFireEvent.click(screen.getByRole("button", { name: "Keep running" }));
});
expect(mockApi.cancelTask).not.toHaveBeenCalled();
});
it("running tasks sort above queued tasks in the multi-agent accordion", async () => {
const runningTask = makeTask("task-r", { status: "running", agent_id: "agent-r" });
const queuedTask = makeTask("task-q", {
status: "queued",
agent_id: "agent-q",
dispatched_at: null,
started_at: null,
});
// Server returns queued first (created_at DESC), but the client must
// re-sort so the running row leads the popover list.
mockApi.getActiveTasksForIssue.mockResolvedValueOnce({
tasks: [queuedTask, runningTask],
});
renderCard();
// Two agents → collapsed summary; the per-agent rows aren't in the DOM
// until the accordion is expanded.
await waitFor(() => {
expect(screen.getByText(/agents working/)).toBeTruthy();
});
expect(screen.queryByText(/is working/)).toBeNull();
await act(async () => {
rtlFireEvent.click(screen.getByText(/agents working/));
});
const working = await screen.findByText(/is working/);
const queued = screen.getByText(/is queued/);
// Running row appears earlier in the document order.
expect(working.compareDocumentPosition(queued) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("collapses multiple agents into a summary and exposes each agent's Stop inside the accordion", async () => {
const taskA = makeTask("task-a", { status: "running", agent_id: "agent-a" });
const taskB = makeTask("task-b", { status: "running", agent_id: "agent-b" });
mockApi.getActiveTasksForIssue.mockResolvedValueOnce({ tasks: [taskA, taskB] });
mockApi.cancelTask.mockResolvedValue(undefined);
renderCard();
// Collapsed: one summary, no inline banners.
await waitFor(() => {
expect(screen.getByText(/2 agents working/)).toBeTruthy();
});
expect(screen.queryByText(/is working/)).toBeNull();
// Expand the accordion → one row per agent, each with its own Stop.
await act(async () => {
rtlFireEvent.click(screen.getByText(/2 agents working/));
});
const [firstStop, secondStop] = await screen.findAllByText("Stop");
expect(secondStop).toBeTruthy();
// Stop on the first row → confirm → cancelTask fires for that task only.
await act(async () => {
rtlFireEvent.click(firstStop!);
});
await act(async () => {
rtlFireEvent.click(screen.getByRole("button", { name: "Stop task" }));
});
expect(mockApi.cancelTask).toHaveBeenCalledWith("issue-1", "task-a");
});
});

View File

@@ -1,459 +0,0 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Bot, ChevronDown, Clock, Loader2, Square } from "lucide-react";
import { api } from "@multica/core/api";
import { useWSEvent, useWSReconnect } from "@multica/core/realtime";
import type { TaskMessagePayload } from "@multica/core/types/events";
import type { AgentTask } from "@multica/core/types/agent";
import { toast } from "sonner";
import { ActorAvatar } from "../../common/actor-avatar";
import { useActorName } from "@multica/core/workspace/hooks";
import {
TranscriptButton,
buildTimeline,
type TimelineItem,
} from "../../common/task-transcript";
import { useT } from "../../i18n";
import { TerminateTaskConfirmDialog } from "./terminate-task-confirm-dialog";
import { AgentAvatarStack } from "../../agents/components/agent-avatar-stack";
// AgentLiveCard renders a sticky banner at the top of the issue's main
// column for every active task. Each banner shows "agent X is working",
// elapsed time, tool count, and Cancel/Transcript actions.
//
// The full timeline (live execution log) used to live inside an
// expandable area on this card. It now lives in the right panel via
// ExecutionLogSection — this card is just a header-style anchor that
// answers "is anyone working on this issue right now?" at a glance.
//
// We still maintain per-task raw message state here so the live
// TranscriptButton on the sticky banner can open the dialog with live
// items already attached (the dialog stays in sync via WS as messages
// arrive). The right-panel rows use the lazy mode of TranscriptButton
// instead — a one-shot fetch when opened. Both modes coexist.
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`;
}
interface TaskState {
task: AgentTask;
messages: TaskMessagePayload[];
}
interface AgentLiveCardProps {
issueId: string;
}
export function AgentLiveCard({ issueId }: AgentLiveCardProps) {
const { t } = useT("issues");
const { getActorName } = useActorName();
const [taskStates, setTaskStates] = useState<Map<string, TaskState>>(new Map());
// Cancel confirmation is hoisted here (not per-card) so a single dialog
// serves both the inline single banner and the multi-agent popover. A
// confirm dialog living inside the popover would be torn down the moment
// the popover closed on the dialog's outside-press; lifting it keeps the
// confirm flow alive regardless of where Stop was clicked.
const [cancelTarget, setCancelTarget] = useState<AgentTask | null>(null);
const [cancellingIds, setCancellingIds] = useState<ReadonlySet<string>>(
() => new Set(),
);
// Multi-agent accordion: collapsed by default to a one-line summary,
// expands inline within the same full-width content column as the single
// banner so all states read at one consistent width. A popover would
// force the list into a narrow floating card ≠ the full-width banner.
const [expanded, setExpanded] = useState(false);
const seenSeqs = useRef(new Set<string>());
const hydratedTaskIds = useRef(new Set<string>());
const mountedRef = useRef(true);
// Monotonic counter — each reconcile() call captures its issued seq and
// only applies its response if it's still the latest issued. This stops
// a slow getActiveTasksForIssue response from clobbering newer truth
// (e.g. a stale "task is active" payload re-adding a banner that a
// newer "tasks: []" response just cleared).
const reconcileSeq = useRef(0);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
// Reconcile local state to server truth. Replaces taskStates with the
// server's active set: tasks no longer active are dropped (this is what
// self-heals a stale "is working" banner when a task:completed/failed/
// cancelled event was lost during a WS reconnect window), and tasks
// still active keep their accumulated TimelineItems so the live
// TranscriptButton doesn't lose history. New tasks get a one-shot
// listTaskMessages hydration to backfill any messages that landed
// before the WS subscription saw them.
const reconcile = useCallback(() => {
const mySeq = ++reconcileSeq.current;
api.getActiveTasksForIssue(issueId).then(({ tasks }) => {
if (!mountedRef.current) return;
// A newer reconcile was issued after this one — drop this response
// unconditionally and let the latest request win, regardless of
// resolution order. Without this guard, a slow A then a fast B can
// resolve in B-then-A order and A re-adds tasks B already cleared.
if (mySeq !== reconcileSeq.current) return;
const activeIds = new Set(tasks.map((t) => t.id));
setTaskStates((prev) => {
const next = new Map<string, TaskState>();
for (const task of tasks) {
const existing = prev.get(task.id);
next.set(task.id, existing
? { task, messages: existing.messages }
: { task, messages: [] });
}
return next;
});
// Drop bookkeeping for tasks that vanished, so a future re-dispatch
// of the same id (very rare, but possible) re-hydrates cleanly.
for (const key of Array.from(seenSeqs.current)) {
const taskId = key.slice(0, key.indexOf(":"));
if (!activeIds.has(taskId)) seenSeqs.current.delete(key);
}
for (const id of Array.from(hydratedTaskIds.current)) {
if (!activeIds.has(id)) hydratedTaskIds.current.delete(id);
}
// Hydrate messages for tasks we haven't fetched yet. Per-task guard
// prevents duplicate fetches when reconcile fires repeatedly (mount
// + reconnect + queued/dispatch can stack within a single tick).
for (const task of tasks) {
if (hydratedTaskIds.current.has(task.id)) continue;
hydratedTaskIds.current.add(task.id);
api.listTaskMessages(task.id).then((msgs) => {
if (!mountedRef.current) return;
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
setTaskStates((prev) => {
const next = new Map(prev);
const existing = next.get(task.id);
if (!existing) return prev;
const loadedSeqs = new Set(msgs.map((i) => i.seq));
const wsOnly = existing.messages.filter((i) => !loadedSeqs.has(i.seq));
const messages = [...msgs, ...wsOnly].sort((a, b) => a.seq - b.seq);
next.set(task.id, { task: existing.task, messages });
return next;
});
}).catch((e) => {
hydratedTaskIds.current.delete(task.id);
console.error(e);
});
}
}).catch(console.error);
}, [issueId]);
// Initial fetch on mount / issueId change.
useEffect(() => {
reconcile();
}, [reconcile]);
// WS reconnect — anything that happened while we were offline (most
// notably task:completed / task:failed / task:cancelled) won't replay,
// so re-pull the truth and let reconcile drop any stale banners.
useWSReconnect(reconcile);
// Real-time messages — route by task_id and dedupe by seq.
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);
setTaskStates((prev) => {
const next = new Map(prev);
const existing = next.get(msg.task_id);
if (existing) {
const messages = [...existing.messages, msg].sort((a, b) => a.seq - b.seq);
next.set(msg.task_id, { ...existing, messages });
}
return next;
});
}, [issueId]),
);
// Task end — optimistically drop the banner for snappy UX, then
// reconcile to also clean up sibling tasks whose own end events may
// have been missed (e.g. a sequence of tasks all ending during a WS
// reconnect window will only replay this one event when we resubscribe).
const handleTaskEnd = useCallback((payload: unknown) => {
const p = payload as { task_id: string; issue_id: string };
if (p.issue_id !== issueId) return;
setTaskStates((prev) => {
if (!prev.has(p.task_id)) return prev;
const next = new Map(prev);
next.delete(p.task_id);
return next;
});
reconcile();
}, [issueId, reconcile]);
useWSEvent("task:completed", handleTaskEnd);
useWSEvent("task:failed", handleTaskEnd);
useWSEvent("task:cancelled", handleTaskEnd);
// Newly active tasks — both queued and dispatched land here. Subscribing
// to both events matters because retry creates a queued child without
// emitting task:dispatch (only the daemon's claim does), so listening
// to dispatch alone leaves the banner stale during the queued window.
// reconcile is idempotent (per-task hydration guard) and also drops
// stale tasks, so it's safe to fire once per event.
const handleTaskActive = useCallback((payload: unknown) => {
const p = payload as { issue_id?: string };
if (p.issue_id && p.issue_id !== issueId) return;
reconcile();
}, [issueId, reconcile]);
useWSEvent("task:queued", handleTaskActive);
useWSEvent("task:dispatch", handleTaskActive);
// The daemon publishes these two transitions while a task moves through
// the dispatch → waiting_local_directory → running sequence on a busy
// local_directory path. Without subscribing here, the sticky banner
// would stay stuck on the optimistic dispatch state instead of flipping
// to "waiting" then resuming "is working".
useWSEvent("task:waiting_local_directory", handleTaskActive);
useWSEvent("task:running", handleTaskActive);
// Fire the actual cancel once the user confirms. The banner is dropped
// optimistically by handleTaskEnd / reconcile when the task:cancelled
// event lands, so `cancellingIds` only needs to gate the button between
// confirm and that event.
const handleConfirmCancel = useCallback(async () => {
const task = cancelTarget;
if (!task) return;
setCancelTarget(null);
setCancellingIds((prev) => new Set(prev).add(task.id));
try {
await api.cancelTask(issueId, task.id);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t(($) => $.agent_live.cancel_failed),
);
setCancellingIds((prev) => {
const next = new Set(prev);
next.delete(task.id);
return next;
});
}
}, [cancelTarget, issueId, t]);
if (taskStates.size === 0) return null;
// Order: running → dispatched → waiting → queued. The most-active task
// takes the sticky slot; the parked / queued tasks sit below so the
// "is working" banner isn't pushed off by a freshly-enqueued or
// path-parked sibling. ListActiveTasksByIssue's server-side ORDER BY is
// created_at DESC, which doesn't reflect lifecycle priority, so we
// re-sort on the client.
const statusRank: Record<AgentTask["status"], number> = {
running: 0,
dispatched: 1,
waiting_local_directory: 2,
queued: 3,
completed: 4,
failed: 4,
cancelled: 4,
};
const entries = Array.from(taskStates.values()).sort(
(a, b) => statusRank[a.task.status] - statusRank[b.task.status],
);
const firstEntry = entries[0];
if (!firstEntry) return null;
const resolveName = (agentId: string | null) =>
agentId ? getActorName("agent", agentId) : t(($) => $.agent_live.fallback_name);
// One active task → it fills the card as a single row. Multiple → the
// card shows a collapsed summary header that expands the rows inline
// (one bordered container with divided rows, never N detached boxes).
// Active count tops out at ~4-5 in practice, so the expanded list stays
// short.
const isMulti = entries.length > 1;
const agentIds = [
...new Set(
entries.map((e) => e.task.agent_id).filter((id): id is string => !!id),
),
];
const anyRunning = entries.some((e) => e.task.status === "running");
return (
// Sticky bar at the top of the main content, above the editable title —
// answers "is anyone working on this issue right now?" while the comment
// thread scrolls under it. One bordered container in every state: the
// single row, the collapsed summary, and the expanded list all share it,
// so the bar reads at one consistent width.
<div className="mt-4 sticky top-4 z-10 rounded-lg bg-background/80 supports-[backdrop-filter]:bg-background/55 backdrop-blur-md">
<div className="overflow-hidden rounded-lg border border-info/20 bg-info/5">
{isMulti ? (
<>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-info/10 aria-expanded:bg-info/10 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<AgentAvatarStack agentIds={agentIds} size={20} max={4} />
<span className="flex min-w-0 items-center gap-1.5 text-xs">
{anyRunning ? (
<Loader2 className="h-3 w-3 animate-spin text-info shrink-0" />
) : (
<Clock className="h-3 w-3 text-muted-foreground shrink-0" />
)}
<span className="truncate font-medium text-foreground">
{t(($) => $.agent_activity.hover_header, { count: agentIds.length })}
</span>
</span>
<ChevronDown
className={`ml-auto h-3.5 w-3.5 text-muted-foreground shrink-0 transition-transform ${expanded ? "" : "-rotate-90"}`}
/>
</button>
{expanded && (
<div className="divide-y divide-info/15 border-t border-info/15">
{entries.map(({ task, messages }) => (
<AgentLiveRow
key={task.id}
task={task}
items={buildTimeline(messages)}
agentName={resolveName(task.agent_id)}
onRequestCancel={() => setCancelTarget(task)}
cancelling={cancellingIds.has(task.id)}
/>
))}
</div>
)}
</>
) : (
<AgentLiveRow
task={firstEntry.task}
items={buildTimeline(firstEntry.messages)}
agentName={resolveName(firstEntry.task.agent_id)}
onRequestCancel={() => setCancelTarget(firstEntry.task)}
cancelling={cancellingIds.has(firstEntry.task.id)}
/>
)}
</div>
<TerminateTaskConfirmDialog
open={cancelTarget !== null}
onOpenChange={(open) => {
if (!open) setCancelTarget(null);
}}
onConfirm={() => void handleConfirmCancel()}
// Matches the old per-card `!isParked`: a task that's queued or
// parked on a directory lock isn't interrupting live work, so the
// "this stops running work" note is suppressed for those only.
showRunningNote={
cancelTarget !== null &&
cancelTarget.status !== "queued" &&
cancelTarget.status !== "waiting_local_directory"
}
/>
</div>
);
}
// ─── AgentLiveRow (one active task: avatar + status + Logs/Stop) ───────────
interface AgentLiveRowProps {
task: AgentTask;
items: TimelineItem[];
agentName: string;
// Cancel is owned by the parent AgentLiveCard (a single confirm dialog
// serves both the lone row and the multi-agent list). The row only
// requests it and reflects the in-flight state.
onRequestCancel: () => void;
cancelling: boolean;
}
function AgentLiveRow({ task, items, agentName, onRequestCancel, cancelling }: AgentLiveRowProps) {
const { t } = useT("issues");
const [elapsed, setElapsed] = useState("");
const isQueued = task.status === "queued";
// `waiting_local_directory` is the daemon-parked stage of an otherwise-
// active task: it's been dispatched (no longer pure-queued) but hasn't
// entered the running phase yet because another task on this daemon
// holds the same local_directory lock.
const isWaitingLocalDirectory = task.status === "waiting_local_directory";
// Parked vs running is signalled by the icon (Clock vs spinning Loader2)
// and the label below — the container chrome is shared, so there's no
// per-row background variant.
const isParked = isQueued || isWaitingLocalDirectory;
// Elapsed time — ticks every second so users see the agent is alive.
// For queued tasks neither started_at nor dispatched_at is set yet, so
// anchor on created_at to show the "queued for Ns" wait window.
useEffect(() => {
const startRef = task.started_at ?? task.dispatched_at ?? task.created_at;
if (!startRef) return;
setElapsed(formatElapsed(startRef));
const interval = setInterval(() => setElapsed(formatElapsed(startRef)), 1000);
return () => clearInterval(interval);
}, [task.started_at, task.dispatched_at, task.created_at]);
const toolCount = items.filter((i) => i.type === "tool_use").length;
return (
<div className="flex items-center gap-2 px-3 py-2 text-muted-foreground">
{task.agent_id ? (
<ActorAvatar actorType="agent" actorId={task.agent_id} size={20} enableHoverCard showStatusDot />
) : (
<div className="flex items-center justify-center h-5 w-5 rounded-full shrink-0 bg-info/10 text-info">
<Bot className="h-3 w-3" />
</div>
)}
<div className="flex items-center gap-1.5 text-xs min-w-0">
{isParked ? (
<Clock className="h-3 w-3 text-muted-foreground shrink-0" />
) : (
<Loader2 className="h-3 w-3 animate-spin text-info shrink-0" />
)}
<span className="font-medium text-foreground truncate">
{isWaitingLocalDirectory
? t(($) => $.agent_live.is_waiting_local_directory, { name: agentName })
: isQueued
? t(($) => $.agent_live.is_queued, { name: agentName })
: t(($) => $.agent_live.is_working, { name: agentName })}
</span>
<span className="text-muted-foreground tabular-nums shrink-0">
{isParked
? t(($) => $.agent_live.queued_elapsed_prefix, { elapsed })
: elapsed}
</span>
{!isParked && toolCount > 0 && (
<span className="text-muted-foreground shrink-0">{t(($) => $.agent_live.tool_count, { count: toolCount })}</span>
)}
</div>
<div className="ml-auto flex items-center gap-1 shrink-0">
{!isParked && (
<TranscriptButton
task={task}
agentName={agentName}
items={items}
isLive
title={t(($) => $.agent_live.transcript_button)}
/>
)}
<button
type="button"
onClick={onRequestCancel}
disabled={cancelling}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50"
title={t(($) => $.agent_live.stop_tooltip)}
>
{cancelling ? <Loader2 className="h-3 w-3 animate-spin" /> : <Square className="h-3 w-3" />}
<span>{t(($) => $.agent_live.stop_button)}</span>
</button>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
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";
@@ -14,7 +14,10 @@ import {
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { ActorAvatar } from "../../common/actor-avatar";
import { TranscriptButton } from "../../common/task-transcript";
import { formatDuration } from "../../agents/components/agent-activity-hover-content";
import { TranscriptButton, buildTimeline } from "../../common/task-transcript";
import { taskMessagesOptions } from "@multica/core/chat/queries";
import { RunningStat } from "./running-stat";
import { failureReasonLabel } from "../../agents/components/tabs/task-failure";
import { useT } from "../../i18n";
import { TerminateTaskConfirmDialog } from "./terminate-task-confirm-dialog";
@@ -24,8 +27,9 @@ import { TerminateTaskConfirmDialog } from "./terminate-task-confirm-dialog";
// 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 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:
@@ -135,7 +139,7 @@ export function ExecutionLogSection({ issueId }: ExecutionLogSectionProps) {
{open && (
<div className="space-y-0.5 pl-2">
{activeTasks.map((task) => (
<ActiveRow key={task.id} task={task} issueId={issueId} />
<ActiveTaskRow key={task.id} task={task} issueId={issueId} />
))}
{pastTasks.length > 0 && (
@@ -241,7 +245,22 @@ function useStatusLabel(status: AgentTask["status"]): string {
}
}
function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
// One active (running / queued / dispatched / parked) task row. Exported so
// the issue-detail header live-chip popover renders the exact same row as
// this panel — same trigger text, same status treatment, same hover-reveal
// Logs/Stop. The popover hosting it must use `keepMounted` so this row (and
// its internal confirm dialog) survives the popover closing on Stop click.
// Running rows read the shared per-task message cache (taskMessagesOptions,
// kept live by useRealtimeSync's global task:message handler) so every surface
// — this panel, the header-chip popover, and the transcript dialog — shows the
// same live "N events (elapsed)" and the same streaming Logs from one source.
export function ActiveTaskRow({
task,
issueId,
}: {
task: AgentTask;
issueId: string;
}) {
const { t } = useT("issues");
const [cancelling, setCancelling] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
@@ -249,6 +268,31 @@ function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
const label = useStatusLabel(task.status);
const trigger = useTriggerText(task);
// Live message stream for this task — only fetched while running. The shared
// cache means the panel row, the popover row, and the chip all dedupe to one
// fetch + one WS-maintained entry.
const { data: msgs } = useQuery({
...taskMessagesOptions(task.id),
enabled: task.status === "running",
});
const items = useMemo(() => (msgs ? buildTimeline(msgs) : undefined), [msgs]);
// 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 =
@@ -276,7 +320,7 @@ function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
<RowStatus title={label}>
{task.status === "running" ? (
<>
<Loader2 className="h-3 w-3 animate-spin text-info" />
<RunningStat eventCount={msgs?.length ?? 0} elapsed={elapsed} />
<span className="sr-only">{label}</span>
</>
) : (
@@ -289,6 +333,7 @@ function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
task={task}
agentName=""
isLive
items={items}
title={t(($) => $.execution_log.transcript_tooltip)}
/>
)}

View File

@@ -0,0 +1,183 @@
"use client";
import { memo, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Clock } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@multica/ui/components/ui/popover";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import { taskMessagesOptions } from "@multica/core/chat/queries";
import type { AgentTask } from "@multica/core/types";
import { AgentAvatarStack } from "../../agents/components/agent-avatar-stack";
import { formatDuration } from "../../agents/components/agent-activity-hover-content";
import { ActiveTaskRow } from "./execution-log-section";
import { RunningStat } from "./running-stat";
import { useT } from "../../i18n";
// Per-issue "is an agent working on this right now?" chip for the issue
// detail header. Lives in the header (not the scrollable body) so the live
// signal stays in one fixed place and never competes with future sticky
// banners in the content column. Replaces the in-body sticky live card.
//
// Derives state from the workspace-wide agent task snapshot filtered by
// issue id — the same single source of truth that powers the board-card /
// list-row IssueAgentActivityIndicator, so the chip is always consistent
// with those surfaces and costs zero extra network.
//
// Collapsed display — the avatar stack carries how many agents, the text
// always carries one elapsed time, so a multi-agent chip reads "N heads +
// how long" rather than a redundant count next to countable heads:
// - running → avatar(s) + blue elapsed of the longest-running task
// - queued only → half-opacity avatar(s) + clock + muted longest wait
//
// Click opens a Popover listing every active task with the SAME row as the
// right-panel ExecutionLogSection (ActiveTaskRow) — trigger text + status,
// with Logs/Stop revealed on row hover. The popover is `keepMounted` so the
// row's internal confirm dialog survives the popover closing on Stop click.
interface IssueAgentHeaderChipProps {
issueId: string;
}
export const IssueAgentHeaderChip = memo(function IssueAgentHeaderChip({
issueId,
}: IssueAgentHeaderChipProps) {
const wsId = useWorkspaceId();
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
const { running, queued } = useMemo(() => {
const running: AgentTask[] = [];
const queued: AgentTask[] = [];
for (const task of snapshot) {
if (task.issue_id !== issueId) continue;
if (task.status === "running") running.push(task);
else if (
task.status === "queued" ||
task.status === "dispatched" ||
// Daemon-parked on a busy local_directory — still active, just
// waiting on a path lock. Belongs in the live chip, not dropped.
task.status === "waiting_local_directory"
)
queued.push(task);
// Terminal statuses are the execution log's story, not the live chip's.
}
return { running, queued };
}, [snapshot, issueId]);
// No active work → render nothing (and crucially, no `now` ticker). The
// 1s elapsed tick only runs while the ActiveChip is mounted.
if (running.length === 0 && queued.length === 0) return null;
return <ActiveChip issueId={issueId} running={running} queued={queued} />;
});
interface ActiveChipProps {
issueId: string;
running: AgentTask[];
queued: AgentTask[];
}
function ActiveChip({ issueId, running, queued }: ActiveChipProps) {
const { t } = useT("issues");
// Tick once per second so the collapsed single-agent elapsed stays live.
// Only mounted while there's active work, so it costs nothing at rest.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
const activeTasks = [...running, ...queued];
const agentIds = [...new Set(activeTasks.map((task) => task.agent_id))];
const anyRunning = running.length > 0;
const isSingle = agentIds.length === 1;
// Single-agent event count comes from the shared per-task message cache —
// the same entry the popover row and Logs read, kept live by
// useRealtimeSync. Only the single-running case shows a number, so we only
// query that one task; multi shows "N working".
const singleRunningTaskId =
isSingle && anyRunning ? (running[0]?.id ?? "") : "";
const { data: singleMsgs } = useQuery({
...taskMessagesOptions(singleRunningTaskId),
enabled: singleRunningTaskId !== "",
});
// One elapsed time = how long work has been going on this issue. When
// anything is running, anchor on the longest-running task (earliest start =
// the "is something stuck?" signal); when all queued, the longest wait.
// The relevant bucket is always non-empty (ActiveChip only mounts with ≥1
// active task), so the reduce has a safe seed.
const anchorOf = (task: AgentTask) =>
task.status === "running"
? (task.started_at ?? task.dispatched_at ?? task.created_at)
: task.created_at;
const bucket = anyRunning ? running : queued;
const leadFrom = bucket
.map(anchorOf)
.reduce((a, b) => (new Date(b).getTime() < new Date(a).getTime() ? b : a));
const elapsed = formatDuration(leadFrom, now);
return (
<div className="flex items-center gap-1">
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label={t(($) => $.agent_activity.hover_header, {
count: activeTasks.length,
})}
className="flex h-7 items-center gap-1.5 rounded-md px-1.5 text-muted-foreground outline-none transition-colors hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<AgentAvatarStack
agentIds={agentIds}
size={18}
max={3}
opacity={anyRunning ? "full" : "half"}
/>
{!isSingle ? (
// Multiple agents → "N working" (matches the workspace chip);
// per-agent time/events live in the popover rows below.
<span
className={`text-xs ${anyRunning ? "text-info" : "text-muted-foreground"}`}
>
{agentIds.length} {t(($) => $.agent_activity.chip_label)}
</span>
) : anyRunning ? (
// Single running → events (primary), elapsed in muted parens.
<RunningStat eventCount={singleMsgs?.length ?? 0} elapsed={elapsed} />
) : (
// Single queued/parked → clock + wait time.
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">{elapsed}</span>
</span>
)}
</PopoverTrigger>
<PopoverContent align="end" keepMounted className="w-80">
<div className="text-xs font-medium text-muted-foreground">
{t(($) => $.agent_activity.hover_header, {
count: activeTasks.length,
})}
</div>
<div className="flex flex-col gap-0.5">
{activeTasks.map((task) => (
<ActiveTaskRow key={task.id} task={task} issueId={issueId} />
))}
</div>
</PopoverContent>
</Popover>
{/* Separator from the action buttons — the chip is a status segment,
not another button, so a hairline keeps the two groups legible. */}
<span className="h-4 w-px bg-border" aria-hidden="true" />
</div>
);
}

View File

@@ -55,7 +55,7 @@ import { CommentCard } from "./comment-card";
import { CommentInput } from "./comment-input";
import { ResolvedThreadBar } from "./resolved-thread-bar";
import { collectThreadReplies } from "./thread-utils";
import { AgentLiveCard } from "./agent-live-card";
import { IssueAgentHeaderChip } from "./issue-agent-header-chip";
import { ExecutionLogSection } from "./execution-log-section";
import { PullRequestList } from "./pull-request-list";
import { useGitHubSettings } from "@multica/core/github";
@@ -1649,6 +1649,10 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
}
actions={
<>
{/* Live "agent is working" chip, leftmost in the right cluster so
it never overlaps the title (which truncates to make room).
It self-hides when no agent is active. */}
<IssueAgentHeaderChip issueId={id} />
{onDone && issue.status !== "done" && issue.status !== "cancelled" && (
<Tooltip>
<TooltipTrigger
@@ -1945,13 +1949,11 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
<LocalDirectoryHint projectId={issue?.project_id} />
{/* Agent live output — sticky banner in the activity section,
keyed by issue id so switching issues remounts the card and
clears any in-flight task state from the previous issue.
The execution log itself (per-task timeline + past runs)
lives in the right panel via ExecutionLogSection — this
card is just a header-style "agent is working" anchor. */}
<AgentLiveCard key={id} issueId={id} />
{/* The "agent is working" live signal now lives in the header
(IssueAgentHeaderChip) so it stays in one fixed place and
doesn't compete with sticky banners in this content column.
The per-task timeline + past runs live in the right panel
via ExecutionLogSection. */}
{/* Timeline entries — virtualized via react-virtuoso to keep
first-paint cost O(viewport) instead of O(N). On a 500-comment

View File

@@ -0,0 +1,26 @@
"use client";
import { useT } from "../../i18n";
// Shared "running" stat for the header chip and the execution-log / popover
// rows so all three read identically: live event count (primary, info blue)
// followed by the elapsed time in muted parens — e.g. "58 events (2m45s)".
// Both the count and the elapsed are live (event count via the shared
// task-messages cache, elapsed via the caller's 1s tick).
export function RunningStat({
eventCount,
elapsed,
}: {
eventCount: number;
elapsed: string;
}) {
const { t } = useT("issues");
return (
<span className="flex items-center gap-1 text-xs tabular-nums">
<span className="text-info">
{t(($) => $.agent_live.event_count, { count: eventCount })}
</span>
<span className="text-muted-foreground">({elapsed})</span>
</span>
);
}

View File

@@ -13,8 +13,8 @@ import {
import { useT } from "../../i18n";
// Reusable confirm step for the two issue-detail surfaces that terminate
// a single agent task — the sticky AgentLiveCard banner and the row
// action inside ExecutionLogSection. Task cancellation is irreversible
// a single agent task — the header live chip popover (IssueAgentHeaderChip)
// and the row action inside ExecutionLogSection. Task cancellation is irreversible
// and a misclick on a long-running run is costly, so both entry points
// route through this dialog instead of firing the cancel request on the
// first click.

View File

@@ -307,6 +307,8 @@
"fallback_name": "Agent",
"tool_count_one": "{{count}} tool",
"tool_count_other": "{{count}} tools",
"event_count_one": "{{count}} event",
"event_count_other": "{{count}} events",
"transcript_button": "View transcript",
"stop_button": "Stop",
"stop_tooltip": "Stop agent",

View File

@@ -295,6 +295,7 @@
"queued_elapsed_prefix": "{{elapsed}} 待機中",
"fallback_name": "エージェント",
"tool_count_other": "ツール {{count}} 件",
"event_count_other": "イベント {{count}} 件",
"transcript_button": "トランスクリプトを表示",
"stop_button": "停止",
"stop_tooltip": "エージェントを停止",

View File

@@ -307,6 +307,8 @@
"fallback_name": "에이전트",
"tool_count_one": "도구 {{count}}개",
"tool_count_other": "도구 {{count}}개",
"event_count_one": "이벤트 {{count}}개",
"event_count_other": "이벤트 {{count}}개",
"transcript_button": "트랜스크립트 보기",
"stop_button": "중지",
"stop_tooltip": "에이전트 중지",

View File

@@ -300,6 +300,7 @@
"queued_elapsed_prefix": "已排队 {{elapsed}}",
"fallback_name": "智能体",
"tool_count_other": "{{count}} 次工具调用",
"event_count_other": "{{count}} 个事件",
"transcript_button": "查看记录",
"stop_button": "停止",
"stop_tooltip": "停止智能体",