mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 04:25:46 +02:00
fix(issues): count active issues, not agents, in working chip (#4750)
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: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,7 +65,7 @@ export function WorkspaceAgentWorkingChip({
|
|||||||
const wsId = useWorkspaceId();
|
const wsId = useWorkspaceId();
|
||||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||||
|
|
||||||
const { runningTasks, agentIds } = useMemo(() => {
|
const { runningTasks, agentIds, issueIds } = useMemo(() => {
|
||||||
const running: AgentTask[] = [];
|
const running: AgentTask[] = [];
|
||||||
for (const task of snapshot) {
|
for (const task of snapshot) {
|
||||||
if (task.status !== "running") continue;
|
if (task.status !== "running") continue;
|
||||||
@@ -75,11 +75,21 @@ export function WorkspaceAgentWorkingChip({
|
|||||||
if (scopedIssueIds && !scopedIssueIds.has(task.issue_id)) continue;
|
if (scopedIssueIds && !scopedIssueIds.has(task.issue_id)) continue;
|
||||||
running.push(task);
|
running.push(task);
|
||||||
}
|
}
|
||||||
const unique = [...new Set(running.map((tk) => tk.agent_id))];
|
// The count tracks active *issues*, not active agents: several agents
|
||||||
return { runningTasks: running, agentIds: unique };
|
// 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]);
|
}, [snapshot, scopedIssueIds]);
|
||||||
|
|
||||||
const hasAgents = agentIds.length > 0;
|
const hasAgents = issueIds.length > 0;
|
||||||
// Active (brand-filled) class — must explicitly re-pin text and bg in
|
// Active (brand-filled) class — must explicitly re-pin text and bg in
|
||||||
// every interactive state. Button's `outline` variant ships
|
// every interactive state. Button's `outline` variant ships
|
||||||
// `hover:text-foreground` + `aria-expanded:bg-muted aria-expanded:text-foreground`,
|
// `hover:text-foreground` + `aria-expanded:bg-muted aria-expanded:text-foreground`,
|
||||||
@@ -140,7 +150,7 @@ export function WorkspaceAgentWorkingChip({
|
|||||||
max={3}
|
max={3}
|
||||||
opacity="full"
|
opacity="full"
|
||||||
/>
|
/>
|
||||||
<span className="tabular-nums">{agentIds.length}</span>
|
<span className="tabular-nums">{issueIds.length}</span>
|
||||||
<span className="hidden md:inline">{label}</span>
|
<span className="hidden md:inline">{label}</span>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user