diff --git a/packages/views/issues/components/workspace-agent-working-chip.test.tsx b/packages/views/issues/components/workspace-agent-working-chip.test.tsx new file mode 100644 index 000000000..036cbe023 --- /dev/null +++ b/packages/views/issues/components/workspace-agent-working-chip.test.tsx @@ -0,0 +1,149 @@ +// @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[], + // Captures the agent ids handed to the avatar stack so a test can assert + // the stack still reflects distinct agents even when the count counts issues. + avatarAgentIds: undefined as string[] | undefined, +})); + +vi.mock("@multica/core/hooks", () => ({ + useWorkspaceId: () => "ws-1", +})); + +vi.mock("@multica/core/agents", () => ({ + agentTaskSnapshotOptions: (wsId: string) => ({ + queryKey: ["agents", "task-snapshot", wsId], + }), +})); + +vi.mock("../../agents/components/agent-avatar-stack", () => ({ + AgentAvatarStack: ({ agentIds }: { agentIds: string[] }) => { + mockState.avatarAgentIds = agentIds; + return
{agentIds.length}
; + }, +})); + +vi.mock("../../agents/components/agent-activity-hover-content", () => ({ + AgentActivityHoverContent: ({ tasks }: { tasks: AgentTask[] }) => ( +
{tasks.length}
+ ), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual( + "@tanstack/react-query", + ); + return { + ...actual, + useQuery: (opts: { queryKey?: readonly unknown[] }) => { + if (opts.queryKey?.[1] === "task-snapshot") { + return { data: mockState.snapshot }; + } + return { data: undefined }; + }, + }; +}); + +import { WorkspaceAgentWorkingChip } from "./workspace-agent-working-chip"; + +function makeTask(overrides: Partial): 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 = []; + mockState.avatarAgentIds = undefined; +}); + +describe("WorkspaceAgentWorkingChip", () => { + it("counts distinct active issues, not running agents", () => { + // Two agents working the SAME issue: the count is about issues, so it + // must read "1", not "2" (the old unique-agent behavior). MUL-3875. + mockState.snapshot = [ + makeTask({ id: "t-1", agent_id: "agent-1", issue_id: "issue-1" }), + makeTask({ id: "t-2", agent_id: "agent-2", issue_id: "issue-1" }), + ]; + + renderWithI18n( + {}} />, + ); + + expect( + screen.getByRole("button", { name: /working/i }), + ).toHaveTextContent("1"); + // The avatar stack still shows both distinct agents behind that work. + expect(mockState.avatarAgentIds).toEqual(["agent-1", "agent-2"]); + }); + + it("counts each distinct issue once when agents span several issues", () => { + mockState.snapshot = [ + makeTask({ id: "t-1", agent_id: "agent-1", issue_id: "issue-1" }), + makeTask({ id: "t-2", agent_id: "agent-2", issue_id: "issue-2" }), + makeTask({ id: "t-3", agent_id: "agent-1", issue_id: "issue-3" }), + ]; + + renderWithI18n( + {}} />, + ); + + expect( + screen.getByRole("button", { name: /working/i }), + ).toHaveTextContent("3"); + }); + + it("ignores non-running tasks and respects scopedIssueIds", () => { + mockState.snapshot = [ + makeTask({ id: "t-1", issue_id: "issue-1", status: "running" }), + makeTask({ id: "t-2", issue_id: "issue-2", status: "queued" }), + makeTask({ id: "t-3", issue_id: "issue-3", status: "running" }), + ]; + + renderWithI18n( + {}} + scopedIssueIds={new Set(["issue-1"])} + />, + ); + + // Only the running task within scope counts → "1". + expect( + screen.getByRole("button", { name: /working/i }), + ).toHaveTextContent("1"); + }); + + it("shows 0 when no agents are running", () => { + mockState.snapshot = []; + + renderWithI18n( + {}} />, + ); + + expect( + screen.getByRole("button", { name: /working/i }), + ).toHaveTextContent("0"); + }); +}); diff --git a/packages/views/issues/components/workspace-agent-working-chip.tsx b/packages/views/issues/components/workspace-agent-working-chip.tsx index 2ffe82772..e9b1a8573 100644 --- a/packages/views/issues/components/workspace-agent-working-chip.tsx +++ b/packages/views/issues/components/workspace-agent-working-chip.tsx @@ -65,7 +65,7 @@ export function WorkspaceAgentWorkingChip({ const wsId = useWorkspaceId(); const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId)); - const { runningTasks, agentIds } = useMemo(() => { + const { runningTasks, agentIds, issueIds } = useMemo(() => { const running: AgentTask[] = []; for (const task of snapshot) { if (task.status !== "running") continue; @@ -75,11 +75,21 @@ export function WorkspaceAgentWorkingChip({ if (scopedIssueIds && !scopedIssueIds.has(task.issue_id)) continue; running.push(task); } - const unique = [...new Set(running.map((tk) => tk.agent_id))]; - return { runningTasks: running, agentIds: unique }; + // The count tracks active *issues*, not active agents: several agents + // can work the same issue at once, and the chip answers "how many + // issues are agents working on right now?" (its filter narrows the + // list to exactly those issues). The avatar stack still shows the + // distinct agents behind that work. + const uniqueIssues = [...new Set(running.map((tk) => tk.issue_id))]; + const uniqueAgents = [...new Set(running.map((tk) => tk.agent_id))]; + return { + runningTasks: running, + agentIds: uniqueAgents, + issueIds: uniqueIssues, + }; }, [snapshot, scopedIssueIds]); - const hasAgents = agentIds.length > 0; + const hasAgents = issueIds.length > 0; // Active (brand-filled) class — must explicitly re-pin text and bg in // every interactive state. Button's `outline` variant ships // `hover:text-foreground` + `aria-expanded:bg-muted aria-expanded:text-foreground`, @@ -140,7 +150,7 @@ export function WorkspaceAgentWorkingChip({ max={3} opacity="full" /> - {agentIds.length} + {issueIds.length} {label} }