Compare commits

...

1 Commits

Author SHA1 Message Date
Lambda
4964928043 fix(issues): count active issues, not agents, in working chip
The Issues board header 'x working' chip derived its count from the set
of distinct running agent_ids, so two agents on the same issue read as
'2 working'. Count distinct issue_ids instead so the number reflects how
many issues agents are working on — matching the filter the chip toggles,
which already narrows the list to those issues. The avatar stack still
shows the distinct agents behind that work.

Adds workspace-agent-working-chip.test.tsx covering the multi-agent /
single-issue case, multi-issue counting, scopedIssueIds filtering, and
the empty state.

Fixes MUL-3875

Co-authored-by: multica-agent <github@multica.ai>
2026-06-30 15:16:43 +08:00
2 changed files with 164 additions and 5 deletions

View File

@@ -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 <div data-testid="agent-avatar-stack">{agentIds.length}</div>;
},
}));
vi.mock("../../agents/components/agent-activity-hover-content", () => ({
AgentActivityHoverContent: ({ tasks }: { tasks: AgentTask[] }) => (
<div data-testid="activity-hover">{tasks.length}</div>
),
}));
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?.[1] === "task-snapshot") {
return { data: mockState.snapshot };
}
return { data: undefined };
},
};
});
import { WorkspaceAgentWorkingChip } from "./workspace-agent-working-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 = [];
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(
<WorkspaceAgentWorkingChip value={false} onToggle={() => {}} />,
);
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(
<WorkspaceAgentWorkingChip value={false} onToggle={() => {}} />,
);
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(
<WorkspaceAgentWorkingChip
value={false}
onToggle={() => {}}
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(
<WorkspaceAgentWorkingChip value={false} onToggle={() => {}} />,
);
expect(
screen.getByRole("button", { name: /working/i }),
).toHaveTextContent("0");
});
});

View File

@@ -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"
/>
<span className="tabular-nums">{agentIds.length}</span>
<span className="tabular-nums">{issueIds.length}</span>
<span className="hidden md:inline">{label}</span>
</Button>
}