Compare commits

...

2 Commits

Author SHA1 Message Date
Naiyuan Qing
587e952d45 MUL-3134: remove execution log event count
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 17:21:15 +08:00
Naiyuan Qing
df9538dc0c MUL-3134: simplify issue agent header chip
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 17:13:40 +08:00
9 changed files with 250 additions and 165 deletions

View File

@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { cleanup, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentTask } from "@multica/core/types";
import { renderWithI18n } from "../../test/i18n";
const mockState = vi.hoisted(() => ({
taskMessagesOptions: vi.fn(),
}));
vi.mock("@multica/core/chat/queries", () => ({
taskMessagesOptions: mockState.taskMessagesOptions,
}));
vi.mock("../../common/actor-avatar", () => ({
ActorAvatar: () => <span data-testid="actor-avatar" />,
}));
vi.mock("../../common/task-transcript", () => ({
TranscriptButton: ({ title }: { title?: string }) => (
<button type="button">{title ?? "Transcript"}</button>
),
}));
vi.mock("./terminate-task-confirm-dialog", () => ({
TerminateTaskConfirmDialog: () => null,
}));
import { ActiveTaskRow } from "./execution-log-section";
function makeTask(overrides: Partial<AgentTask> = {}): AgentTask {
return {
id: "task-1",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "issue-1",
status: "running",
priority: 0,
dispatched_at: null,
started_at: "2026-06-08T08:00:00Z",
completed_at: null,
result: null,
error: null,
created_at: "2026-06-08T08:00:00Z",
trigger_summary: "Started from comment",
...overrides,
};
}
beforeEach(() => {
cleanup();
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-08T08:05:04Z"));
});
afterEach(() => {
vi.useRealTimers();
});
describe("ActiveTaskRow", () => {
it("renders running status as elapsed time only", () => {
renderWithI18n(<ActiveTaskRow task={makeTask()} issueId="issue-1" />);
expect(screen.getByText("5m 04s")).toBeInTheDocument();
expect(screen.queryByText(/events?/i)).not.toBeInTheDocument();
expect(screen.getByText("Started from comment")).toBeInTheDocument();
expect(screen.getByText("View transcript")).toBeInTheDocument();
expect(mockState.taskMessagesOptions).not.toHaveBeenCalled();
});
});

View File

@@ -15,9 +15,7 @@ import {
} from "@multica/ui/components/ui/tooltip";
import { ActorAvatar } from "../../common/actor-avatar";
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 { TranscriptButton } from "../../common/task-transcript";
import { failureReasonLabel } from "../../agents/components/tabs/task-failure";
import { useT } from "../../i18n";
import { TerminateTaskConfirmDialog } from "./terminate-task-confirm-dialog";
@@ -245,15 +243,10 @@ function useStatusLabel(status: AgentTask["status"]): 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.
// One active (running / queued / dispatched / parked) task row. Running rows
// keep status to a single live elapsed timer; transcript and stop stay available
// as hover actions. Transcript content lazy-loads on click via TranscriptButton,
// so the row no longer fetches task messages just to render a count.
export function ActiveTaskRow({
task,
issueId,
@@ -268,15 +261,6 @@ export function ActiveTaskRow({
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());
@@ -320,7 +304,7 @@ export function ActiveTaskRow({
<RowStatus title={label}>
{task.status === "running" ? (
<>
<RunningStat eventCount={msgs?.length ?? 0} elapsed={elapsed} />
<span className="text-info tabular-nums">{elapsed}</span>
<span className="sr-only">{label}</span>
</>
) : (
@@ -332,8 +316,7 @@ export function ActiveTaskRow({
<TranscriptButton
task={task}
agentName=""
isLive
items={items}
isLive={task.status === "running"}
title={t(($) => $.execution_log.transcript_tooltip)}
/>
)}

View File

@@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { cleanup, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentTask } from "@multica/core/types";
import { renderWithI18n } from "../../test/i18n";
const mockState = vi.hoisted(() => ({
snapshot: [] as unknown[],
taskMessagesOptions: vi.fn(),
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({
getActorName: (_type: string, id: string) =>
({
"agent-1": "Walt",
"agent-2": "Gus",
})[id] ?? "Unknown Agent",
getActorInitials: (_type: string, id: string) =>
({
"agent-1": "WA",
"agent-2": "GU",
})[id] ?? "UA",
getActorAvatarUrl: () => null,
}),
}));
vi.mock("@multica/core/chat/queries", () => ({
taskMessagesOptions: mockState.taskMessagesOptions,
}));
vi.mock("@tanstack/react-query", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return {
...actual,
useQuery: (opts: { queryKey?: readonly unknown[] }) => {
if (opts.queryKey?.[2] === "agent-task-snapshot") {
return { data: mockState.snapshot };
}
return { data: undefined };
},
};
});
import { IssueAgentHeaderChip } from "./issue-agent-header-chip";
function makeTask(overrides: Partial<AgentTask>): AgentTask {
return {
id: "task-1",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "issue-1",
status: "running",
priority: 0,
dispatched_at: null,
started_at: "2026-06-08T08:00:00Z",
completed_at: null,
result: null,
error: null,
created_at: "2026-06-08T08:00:00Z",
...overrides,
};
}
beforeEach(() => {
cleanup();
vi.clearAllMocks();
mockState.snapshot = [];
});
describe("IssueAgentHeaderChip", () => {
it("shows the active agent name without event count or elapsed time", () => {
mockState.snapshot = [makeTask({})];
renderWithI18n(<IssueAgentHeaderChip issueId="issue-1" />);
expect(
screen.getByRole("status", { name: "Walt is working" }),
).toBeInTheDocument();
expect(screen.getByText("Walt is working")).toBeInTheDocument();
expect(screen.queryByText(/events?/i)).not.toBeInTheDocument();
expect(screen.queryByText(/\d+[smh]/i)).not.toBeInTheDocument();
expect(mockState.taskMessagesOptions).not.toHaveBeenCalled();
});
it("uses the concise multi-agent working label", () => {
mockState.snapshot = [
makeTask({ id: "task-1", agent_id: "agent-1" }),
makeTask({ id: "task-2", agent_id: "agent-2" }),
];
renderWithI18n(<IssueAgentHeaderChip issueId="issue-1" />);
expect(
screen.getByRole("status", { name: "2 agents working" }),
).toBeInTheDocument();
expect(screen.getByText("2 agents working")).toBeInTheDocument();
expect(mockState.taskMessagesOptions).not.toHaveBeenCalled();
});
it("uses the requested Chinese single-agent copy", () => {
mockState.snapshot = [makeTask({})];
renderWithI18n(<IssueAgentHeaderChip issueId="issue-1" />, {
locale: "zh-Hans",
});
expect(screen.getByText("Walt 在工作")).toBeInTheDocument();
});
it("does not render for inactive or unrelated tasks", () => {
mockState.snapshot = [
makeTask({
id: "task-done",
status: "completed",
completed_at: "2026-06-08T08:05:00Z",
}),
makeTask({ id: "task-other", issue_id: "issue-2" }),
];
renderWithI18n(<IssueAgentHeaderChip issueId="issue-1" />);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});

View File

@@ -1,21 +1,12 @@
"use client";
import { memo, useEffect, useMemo, useState } from "react";
import { memo, useMemo } 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 { useActorName } from "@multica/core/workspace/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
@@ -28,16 +19,13 @@ import { useT } from "../../i18n";
// 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
// Collapsed display stays intentionally shallow:
// - one active agent → avatar + "{name} is working"
// - multiple agents → avatar stack + "N agents working"
// - queued only → same copy, half-opacity avatars / muted text
//
// 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.
// Events, elapsed time, transcript, and stop controls belong to the right
// panel ExecutionLogSection. The header is a scan-only status segment.
interface IssueAgentHeaderChipProps {
issueId: string;
@@ -68,113 +56,53 @@ export const IssueAgentHeaderChip = memo(function IssueAgentHeaderChip({
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.
// No active work → render nothing. Header details stay out of this component;
// the right panel owns per-task timing, messages, transcript, and stop controls.
if (running.length === 0 && queued.length === 0) return null;
return <ActiveChip issueId={issueId} running={running} queued={queued} />;
return <ActiveChip running={running} queued={queued} />;
});
interface ActiveChipProps {
issueId: string;
running: AgentTask[];
queued: AgentTask[];
}
function ActiveChip({ issueId, running, queued }: ActiveChipProps) {
function ActiveChip({ 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 { getActorName } = useActorName();
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);
const label = isSingle
? t(($) => $.agent_live.is_working, {
name: getActorName("agent", agentIds[0] ?? ""),
})
: t(($) => $.agent_activity.hover_header, {
count: agentIds.length,
});
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"
/>
}
<div
role="status"
aria-label={label}
className="flex h-7 max-w-[11rem] items-center gap-1.5 rounded-md px-1.5 text-muted-foreground"
>
<AgentAvatarStack
agentIds={agentIds}
size={18}
max={3}
opacity={anyRunning ? "full" : "half"}
/>
<span
className={`min-w-0 truncate text-xs ${anyRunning ? "text-info" : "text-muted-foreground"}`}
>
<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>
{label}
</span>
</div>
{/* 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" />

View File

@@ -1,26 +0,0 @@
"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>
);
}

View File

@@ -307,8 +307,6 @@
"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",

View File

@@ -295,7 +295,6 @@
"queued_elapsed_prefix": "{{elapsed}} 待機中",
"fallback_name": "エージェント",
"tool_count_other": "ツール {{count}} 件",
"event_count_other": "イベント {{count}} 件",
"transcript_button": "トランスクリプトを表示",
"stop_button": "停止",
"stop_tooltip": "エージェントを停止",

View File

@@ -307,8 +307,6 @@
"fallback_name": "에이전트",
"tool_count_one": "도구 {{count}}개",
"tool_count_other": "도구 {{count}}개",
"event_count_one": "이벤트 {{count}}개",
"event_count_other": "이벤트 {{count}}개",
"transcript_button": "트랜스크립트 보기",
"stop_button": "중지",
"stop_tooltip": "에이전트 중지",

View File

@@ -294,13 +294,12 @@
"filter_active_label": "正在查看工作中的智能体"
},
"agent_live": {
"is_working": "{{name}} 正在处理",
"is_working": "{{name}} 在工作",
"is_queued": "{{name}} 排队中",
"is_waiting_local_directory": "{{name}} 正在等待本地目录",
"queued_elapsed_prefix": "已排队 {{elapsed}}",
"fallback_name": "智能体",
"tool_count_other": "{{count}} 次工具调用",
"event_count_other": "{{count}} 个事件",
"transcript_button": "查看记录",
"stop_button": "停止",
"stop_tooltip": "停止智能体",