mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
1 Commits
fix/cloud-
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3968d4937a |
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -194,6 +194,8 @@
|
||||
},
|
||||
"agent_live": {
|
||||
"is_working": "{{name}} 正在处理",
|
||||
"is_queued": "{{name}} 排队中",
|
||||
"queued_elapsed_prefix": "已排队 {{elapsed}}",
|
||||
"fallback_name": "智能体",
|
||||
"tool_count_other": "{{count}} 次工具调用",
|
||||
"transcript_button": "查看记录",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user