mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 07:29:14 +02:00
Compare commits
2 Commits
agent/lamb
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
835bc7c8ff | ||
|
|
34b6cb7aae |
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
183
packages/views/issues/components/issue-agent-header-chip.tsx
Normal file
183
packages/views/issues/components/issue-agent-header-chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
26
packages/views/issues/components/running-stat.tsx
Normal file
26
packages/views/issues/components/running-stat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -295,6 +295,7 @@
|
||||
"queued_elapsed_prefix": "{{elapsed}} 待機中",
|
||||
"fallback_name": "エージェント",
|
||||
"tool_count_other": "ツール {{count}} 件",
|
||||
"event_count_other": "イベント {{count}} 件",
|
||||
"transcript_button": "トランスクリプトを表示",
|
||||
"stop_button": "停止",
|
||||
"stop_tooltip": "エージェントを停止",
|
||||
|
||||
@@ -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": "에이전트 중지",
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"queued_elapsed_prefix": "已排队 {{elapsed}}",
|
||||
"fallback_name": "智能体",
|
||||
"tool_count_other": "{{count}} 次工具调用",
|
||||
"event_count_other": "{{count}} 个事件",
|
||||
"transcript_button": "查看记录",
|
||||
"stop_button": "停止",
|
||||
"stop_tooltip": "停止智能体",
|
||||
|
||||
Reference in New Issue
Block a user