From fc9cec8e8724fc78a545bfb56e9f826ec79b58a6 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 29 May 2026 15:44:40 +0800 Subject: [PATCH] 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) --- .../components/agent-live-card.test.tsx | 55 +++- .../issues/components/agent-live-card.tsx | 294 +++++++++++------- .../views/issues/components/issue-detail.tsx | 16 +- 3 files changed, 241 insertions(+), 124 deletions(-) diff --git a/packages/views/issues/components/agent-live-card.test.tsx b/packages/views/issues/components/agent-live-card.test.tsx index f22ce0418..f09ea1acf 100644 --- a/packages/views/issues/components/agent-live-card.test.tsx +++ b/packages/views/issues/components/agent-live-card.test.tsx @@ -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"); + }); }); diff --git a/packages/views/issues/components/agent-live-card.tsx b/packages/views/issues/components/agent-live-card.tsx index 5785a806f..481cd7188 100644 --- a/packages/views/issues/components/agent-live-card.tsx +++ b/packages/views/issues/components/agent-live-card.tsx @@ -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>(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(null); + const [cancellingIds, setCancellingIds] = useState>( + () => 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()); const hydratedTaskIds = useRef(new Set()); 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 */} -
- $.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. +
+
+ {isMulti ? ( + <> + + {expanded && ( +
+ {entries.map(({ task, messages }) => ( + setCancelTarget(task)} + cancelling={cancellingIds.has(task.id)} + /> + ))} +
+ )} + + ) : ( + setCancelTarget(firstEntry.task)} + cancelling={cancellingIds.has(firstEntry.task.id)} + /> + )}
- {/* Additional agents — non-sticky, scroll with the page */} - {restEntries.length > 0 && ( -
- {restEntries.map(({ task, messages }) => ( - $.agent_live.fallback_name)} - /> - ))} -
- )} - + { + 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" + } + /> +
); } -// ─── 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 ( -
-
- {task.agent_id ? ( - +
+ {task.agent_id ? ( + + ) : ( +
+ +
+ )} +
+ {isParked ? ( + ) : ( -
- -
+ + )} + + {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 })} + + + {isParked + ? t(($) => $.agent_live.queued_elapsed_prefix, { elapsed }) + : elapsed} + + {!isParked && toolCount > 0 && ( + {t(($) => $.agent_live.tool_count, { count: toolCount })} )} -
- {isParked ? ( - - ) : ( - - )} - - {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 })} - - - {isParked - ? t(($) => $.agent_live.queued_elapsed_prefix, { elapsed }) - : elapsed} - - {!isParked && toolCount > 0 && ( - {t(($) => $.agent_live.tool_count, { count: toolCount })} - )} -
-
- {!isParked && ( - $.agent_live.transcript_button)} - /> - )} - -
- void handleCancel()} - showRunningNote={!isParked} - /> +
+ {!isParked && ( + $.agent_live.transcript_button)} + /> + )} + +
); } diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index fb931539f..e34720a70 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -1728,6 +1728,14 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr className="relative flex-1 overflow-y-auto" >
+ {/* 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. */} + - {/* 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. */} - - {/* 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