feat(issues): sticky agent live bar above title with multi-agent accordion (#3517)

Move the per-issue "agent is working" bar out of the activity section to a
sticky bar at the top of the main content, above the editable title, and fix
the multi-agent layout.

- Single active task fills one bordered container as a directly-actionable
  row (Logs + Stop inline).
- Multiple active tasks collapse into the same container: a summary header
  (avatar stack + count) that expands the rows inline as a divided list,
  instead of stacking N detached banners.
- Lift cancel confirmation to AgentLiveCard so one dialog serves both the lone
  row and the expanded list (avoids a confirm dialog being torn down by an
  enclosing popup).
- Drop the redundant parked/running background variant; running vs queued is
  signalled by icon + label only.
- Reuse the existing agent_activity.hover_header plural for the summary; no new
  i18n keys.
- Update tests for single-inline vs multi-accordion behavior.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-29 15:44:40 +08:00
committed by GitHub
parent 5aa4fb7487
commit fc9cec8e87
3 changed files with 241 additions and 124 deletions

View File

@@ -46,6 +46,9 @@ vi.mock("@multica/core/realtime", () => ({
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,
}),
}));
@@ -302,29 +305,67 @@ describe("AgentLiveCard queued rendering", () => {
expect(mockApi.cancelTask).not.toHaveBeenCalled();
});
it("running tasks sort above queued tasks so the sticky slot stays on the active one", async () => {
const runningTask = makeTask("task-r", { status: "running" });
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 banner takes the sticky position.
// 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(/is working/)).toBeTruthy();
expect(screen.getByText(/is queued/)).toBeTruthy();
expect(screen.getByText(/agents working/)).toBeTruthy();
});
expect(screen.queryByText(/is working/)).toBeNull();
await act(async () => {
rtlFireEvent.click(screen.getByText(/agents working/));
});
const working = screen.getByText(/is working/);
const working = await screen.findByText(/is working/);
const queued = screen.getByText(/is queued/);
// Running banner appears earlier in the document order.
// 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,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Bot, Clock, Loader2, Square } from "lucide-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";
@@ -16,6 +16,7 @@ import {
} 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",
@@ -54,6 +55,20 @@ 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);
@@ -209,6 +224,29 @@ export function AgentLiveCard({ issueId }: AgentLiveCardProps) {
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
@@ -229,52 +267,116 @@ export function AgentLiveCard({ issueId }: AgentLiveCardProps) {
const entries = Array.from(taskStates.values()).sort(
(a, b) => statusRank[a.task.status] - statusRank[b.task.status],
);
const [firstEntry, ...restEntries] = entries;
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 (
<>
{/* Primary agent — sticky at the top of the activity area */}
<div className="mt-4 sticky top-4 z-10 rounded-lg bg-background/80 supports-[backdrop-filter]:bg-background/55 backdrop-blur-md">
<SingleAgentLiveCard
task={firstEntry.task}
items={buildTimeline(firstEntry.messages)}
issueId={issueId}
agentName={firstEntry.task.agent_id ? getActorName("agent", firstEntry.task.agent_id) : t(($) => $.agent_live.fallback_name)}
/>
// 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="mb-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>
{/* Additional agents — non-sticky, scroll with the page */}
{restEntries.length > 0 && (
<div className="mt-1.5 space-y-1.5">
{restEntries.map(({ task, messages }) => (
<SingleAgentLiveCard
key={task.id}
task={task}
items={buildTimeline(messages)}
issueId={issueId}
agentName={task.agent_id ? getActorName("agent", task.agent_id) : t(($) => $.agent_live.fallback_name)}
/>
))}
</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>
);
}
// ─── SingleAgentLiveCard (header-only banner per active task) ──────────────
// ─── AgentLiveRow (one active task: avatar + status + Logs/Stop) ───────────
interface SingleAgentLiveCardProps {
interface AgentLiveRowProps {
task: AgentTask;
items: TimelineItem[];
issueId: string;
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 SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiveCardProps) {
function AgentLiveRow({ task, items, agentName, onRequestCancel, cancelling }: AgentLiveRowProps) {
const { t } = useT("issues");
const [elapsed, setElapsed] = useState("");
const [cancelling, setCancelling] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const isQueued = task.status === "queued";
// `waiting_local_directory` is the daemon-parked stage of an otherwise-
@@ -282,8 +384,9 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
// entered the running phase yet because another task on this daemon
// holds the same local_directory lock.
const isWaitingLocalDirectory = task.status === "waiting_local_directory";
// Treat parked + queued the same visually (non-shimmering, muted accent),
// but the label below is distinct so the user sees the specific reason.
// 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.
@@ -297,87 +400,60 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
return () => clearInterval(interval);
}, [task.started_at, task.dispatched_at, task.created_at]);
const handleCancel = useCallback(async () => {
if (cancelling) return;
setCancelling(true);
try {
await api.cancelTask(issueId, task.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.agent_live.cancel_failed));
setCancelling(false);
}
}, [task.id, issueId, cancelling, t]);
const requestCancel = useCallback(() => {
if (cancelling) return;
setConfirmOpen(true);
}, [cancelling]);
const toolCount = items.filter((i) => i.type === "tool_use").length;
// Queued / waiting tasks render with a non-spinning Clock and dimmer
// accent so the banner reads as "waiting" rather than "working" at a
// glance.
return (
<div className={isParked ? "rounded-lg border border-border bg-muted/30" : "rounded-lg border border-info/20 bg-info/5"}>
<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 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" />
) : (
<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>
<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 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={requestCancel}
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>
<TerminateTaskConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
onConfirm={() => void handleCancel()}
showRunningNote={!isParked}
/>
<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

@@ -1728,6 +1728,14 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
className="relative flex-1 overflow-y-auto"
>
<div className="mx-auto w-full max-w-4xl px-8 py-8">
{/* Agent live status — sticky bar at the top of the main content,
above the editable title. Keyed by issue id so switching issues
remounts and clears in-flight task state from the previous
issue. A single active task is directly actionable (Logs +
Stop); multiple collapse into a click-to-open popover. The full
execution log lives in the right panel via ExecutionLogSection —
this bar is just the "is anyone working?" anchor. */}
<AgentLiveCard key={id} issueId={id} />
<TitleEditor
key={`title-${id}`}
defaultValue={issue.title}
@@ -1938,14 +1946,6 @@ 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} />
{/* Timeline entries — virtualized via react-virtuoso to keep
first-paint cost O(viewport) instead of O(N). On a 500-comment
issue the unvirtualized .map froze the page for several