Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
3968d4937a feat(agent-live-card): show queued tasks in issue live banner (MUL-1897)
The issue-detail "agent live" banner only showed dispatched/running tasks.
A task that was queued — runtime offline, busy on a prior task, or held
behind a coalesced sibling — left the issue silent until claim, which
reads as "the trigger never landed".

Include 'queued' in `ListActiveTasksByIssue`, then branch the renderer:
queued banners use a non-spinning Clock, "{name} 排队中 / is queued"
copy, "queued for Ns" elapsed anchored on `created_at`, and hide the
transcript button (no execution log yet). Cancel still works because
`CancelAgentTask` already accepts queued.

Client-side re-sort by lifecycle (running → dispatched → queued) so the
sticky slot stays on the most-active task even when a queued sibling
was created more recently.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 13:00:50 +08:00
6 changed files with 117 additions and 20 deletions

View File

@@ -83,7 +83,7 @@ vi.mock("sonner", () => ({
import { AgentLiveCard } from "./agent-live-card";
function makeTask(id: string): AgentTask {
function makeTask(id: string, overrides: Partial<AgentTask> = {}): AgentTask {
return {
id,
agent_id: "agent-1",
@@ -97,6 +97,7 @@ function makeTask(id: string): AgentTask {
result: null,
error: null,
created_at: "2026-01-01T00:00:00Z",
...overrides,
};
}
@@ -229,3 +230,50 @@ describe("AgentLiveCard reconcile race", () => {
});
});
});
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("running tasks sort above queued tasks so the sticky slot stays on the active one", async () => {
const runningTask = makeTask("task-r", { status: "running" });
const queuedTask = makeTask("task-q", {
status: "queued",
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.
mockApi.getActiveTasksForIssue.mockResolvedValueOnce({
tasks: [queuedTask, runningTask],
});
renderCard();
await waitFor(() => {
expect(screen.getByText(/is working/)).toBeTruthy();
expect(screen.getByText(/is queued/)).toBeTruthy();
});
const working = screen.getByText(/is working/);
const queued = screen.getByText(/is queued/);
// Running banner appears earlier in the document order.
expect(working.compareDocumentPosition(queued) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Bot, Loader2, Square } from "lucide-react";
import { Bot, 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";
@@ -213,7 +213,22 @@ export function AgentLiveCard({ issueId }: AgentLiveCardProps) {
if (taskStates.size === 0) return null;
const entries = Array.from(taskStates.values());
// Order: running → dispatched → queued. The most-active task takes the
// sticky slot; queued tasks sit below so the "is working" banner isn't
// pushed off by a freshly-enqueued 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,
queued: 2,
completed: 3,
failed: 3,
cancelled: 3,
};
const entries = Array.from(taskStates.values()).sort(
(a, b) => statusRank[a.task.status] - statusRank[b.task.status],
);
const [firstEntry, ...restEntries] = entries;
if (!firstEntry) return null;
@@ -260,14 +275,18 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
const [elapsed, setElapsed] = useState("");
const [cancelling, setCancelling] = useState(false);
const isQueued = task.status === "queued";
// 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(() => {
if (!task.started_at && !task.dispatched_at) return;
const startRef = task.started_at ?? task.dispatched_at!;
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.started_at, task.dispatched_at, task.created_at]);
const handleCancel = useCallback(async () => {
if (cancelling) return;
@@ -282,8 +301,10 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
const toolCount = items.filter((i) => i.type === "tool_use").length;
// Queued 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="rounded-lg border border-info/20 bg-info/5">
<div className={isQueued ? "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 />
@@ -293,21 +314,35 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
</div>
)}
<div className="flex items-center gap-1.5 text-xs min-w-0">
<Loader2 className="h-3 w-3 animate-spin text-info shrink-0" />
<span className="font-medium text-foreground truncate">{t(($) => $.agent_live.is_working, { name: agentName })}</span>
<span className="text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
{toolCount > 0 && (
{isQueued ? (
<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">
{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">
{isQueued
? t(($) => $.agent_live.queued_elapsed_prefix, { elapsed })
: elapsed}
</span>
{!isQueued && 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">
<TranscriptButton
task={task}
agentName={agentName}
items={items}
isLive
title={t(($) => $.agent_live.transcript_button)}
/>
{!isQueued && (
<TranscriptButton
task={task}
agentName={agentName}
items={items}
isLive
title={t(($) => $.agent_live.transcript_button)}
/>
)}
<button
onClick={handleCancel}
disabled={cancelling}

View File

@@ -198,6 +198,8 @@
},
"agent_live": {
"is_working": "{{name}} is working",
"is_queued": "{{name}} is queued",
"queued_elapsed_prefix": "queued for {{elapsed}}",
"fallback_name": "Agent",
"tool_count_one": "{{count}} tool",
"tool_count_other": "{{count}} tools",

View File

@@ -194,6 +194,8 @@
},
"agent_live": {
"is_working": "{{name}} 正在处理",
"is_queued": "{{name}} 排队中",
"queued_elapsed_prefix": "已排队 {{elapsed}}",
"fallback_name": "智能体",
"tool_count_other": "{{count}} 次工具调用",
"transcript_button": "查看记录",

View File

@@ -1243,10 +1243,15 @@ func (q *Queries) LinkTaskToIssue(ctx context.Context, arg LinkTaskToIssueParams
const listActiveTasksByIssue = `-- name: ListActiveTasksByIssue :many
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session FROM agent_task_queue
WHERE issue_id = $1 AND status IN ('dispatched', 'running')
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running')
ORDER BY created_at DESC
`
// Backs the issue-detail "agent live" banner. Includes 'queued' so the
// banner shows up the moment a task is enqueued — not only after a runtime
// claims it. The queued window can be long when the runtime is offline or
// busy on a prior task, and a silent UI during that window looks like the
// platform never received the trigger.
func (q *Queries) ListActiveTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([]AgentTaskQueue, error) {
rows, err := q.db.Query(ctx, listActiveTasksByIssue, issueID)
if err != nil {

View File

@@ -347,8 +347,13 @@ WHERE runtime_id = $1 AND status = 'queued'
ORDER BY priority DESC, created_at ASC;
-- name: ListActiveTasksByIssue :many
-- Backs the issue-detail "agent live" banner. Includes 'queued' so the
-- banner shows up the moment a task is enqueued — not only after a runtime
-- claims it. The queued window can be long when the runtime is offline or
-- busy on a prior task, and a silent UI during that window looks like the
-- platform never received the trigger.
SELECT * FROM agent_task_queue
WHERE issue_id = $1 AND status IN ('dispatched', 'running')
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running')
ORDER BY created_at DESC;
-- name: GetWorkspaceAgentRunCounts :many