mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-11 16:36:32 +02:00
* feat(issues): aggregate agents-working chip on the sub-issues header (#5825) Add a live "N agents working" chip next to the sub-issues progress ring in issue detail. The per-row IssueAgentActivityIndicator shows which sub-issue is being worked; this chip shows how many agents are on the parent's children at a glance — and keeps that signal visible while the list is collapsed. Derives from the shared workspace agent-task snapshot narrowed by a new selectIssuesTasks select (structural sharing keeps unrelated snapshot churn from re-rendering the header). Counts unique agents to match the workspace chip, whose chip_agents_working / hover_header_queued strings it reuses — already translated in every locale. Hover opens the shared AgentActivityHoverContent task list. Fixes #5825 Co-authored-by: multica-agent <github@multica.ai> * refactor(issues): read the sub-issues chip from the working-agents projection (#5825) The chip landed deriving its own count from the workspace agent-task snapshot, which put a second definition of "an agent is working" in the client. It showed up immediately: the number came from the running tasks only while the hover body listed running plus queued, so a parent with 2 running and 3 queued agents read "2 agents working" over a five-row card. A header count is a claim about a scope, so let the server own both the scope and the arithmetic, exactly as the Issues list header already does. ListWorkspaceWorkingAgents grows an optional parent_issue_id narrowing and the chip reads /api/working-agents?type=issue&parent=<id>. The number, the avatars and the hover body are now one list rather than three derivations, so they cannot disagree. Row indicators keep reading the snapshot. One shared query sliced per row is the right shape for a per-row cue and a stale row decoration costs nothing; a header number is the opposite, it has to be authoritative. The new parameter is additive: omitted, the query and the response are byte-for-byte what they were, so an installed client that never sends it keeps the workspace-wide behaviour. A regression test pins that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
153 lines
4.5 KiB
TypeScript
153 lines
4.5 KiB
TypeScript
import { cleanup, render, screen } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { WorkspaceWorkingAgent } from "@multica/core/types";
|
|
|
|
const mockState = vi.hoisted(() => ({
|
|
agents: [] as WorkspaceWorkingAgent[],
|
|
optionsCalls: [] as unknown[][],
|
|
}));
|
|
|
|
vi.mock("@multica/core/hooks", () => ({
|
|
useWorkspaceId: () => "ws-1",
|
|
}));
|
|
|
|
vi.mock("@multica/core/agents", () => ({
|
|
workspaceWorkingAgentsOptions: (...args: unknown[]) => {
|
|
mockState.optionsCalls.push(args);
|
|
return { queryKey: ["working-agents", ...args] };
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../agents/components/agent-avatar-stack", () => ({
|
|
AgentAvatarStack: ({ agentIds }: { agentIds: string[] }) => (
|
|
<div data-testid="agent-avatar-stack">{agentIds.join(",")}</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("./workspace-agent-working-chip", () => ({
|
|
WorkingAgentsHoverContent: ({
|
|
agents,
|
|
}: {
|
|
agents: readonly WorkspaceWorkingAgent[];
|
|
}) => <div data-testid="hover-body">{agents.length}</div>,
|
|
}));
|
|
|
|
vi.mock("../../i18n", () => ({
|
|
useT: () => ({
|
|
t: (
|
|
selector: (keys: Record<string, Record<string, string>>) => string,
|
|
options?: { count?: number },
|
|
) => {
|
|
const key = selector(
|
|
new Proxy(
|
|
{},
|
|
{
|
|
get: (_t, ns: string) =>
|
|
new Proxy({}, { get: (_n, k: string) => `${ns}.${k}` }),
|
|
},
|
|
) as Record<string, Record<string, string>>,
|
|
);
|
|
return options?.count !== undefined ? `${key}:${options.count}` : key;
|
|
},
|
|
}),
|
|
}));
|
|
|
|
// The hover card only portals its content once open, so absence of the body
|
|
// cannot distinguish "closed" from "not wired up". Mock the primitive and
|
|
// assert on the wrapper itself (same approach as the row indicator's test).
|
|
vi.mock("@multica/ui/components/ui/hover-card", () => ({
|
|
HoverCard: ({ children }: { children: React.ReactNode }) => (
|
|
<div data-testid="hover-card">{children}</div>
|
|
),
|
|
HoverCardTrigger: ({ children }: { children: React.ReactNode }) => (
|
|
<span data-testid="hover-card-trigger">{children}</span>
|
|
),
|
|
HoverCardContent: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@tanstack/react-query", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@tanstack/react-query")>(
|
|
"@tanstack/react-query",
|
|
);
|
|
return { ...actual, useQuery: () => ({ data: mockState.agents }) };
|
|
});
|
|
|
|
import { SubIssuesAgentWorkingChip } from "./sub-issues-agent-working-chip";
|
|
|
|
function makeAgent(
|
|
overrides: Partial<WorkspaceWorkingAgent> = {},
|
|
): WorkspaceWorkingAgent {
|
|
return {
|
|
id: "agent-1",
|
|
name: "Agent One",
|
|
avatar_url: null,
|
|
running_task_count: 1,
|
|
issue_ids: ["child-1"],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
cleanup();
|
|
mockState.agents = [];
|
|
mockState.optionsCalls = [];
|
|
});
|
|
|
|
describe("SubIssuesAgentWorkingChip", () => {
|
|
it("asks the server to narrow the projection to the parent's children", () => {
|
|
mockState.agents = [makeAgent()];
|
|
|
|
render(<SubIssuesAgentWorkingChip parentIssueId="parent-1" />);
|
|
|
|
// type=issue plus the parent id, and no My Issues relation. Getting this
|
|
// wrong silently widens the chip to the whole workspace.
|
|
expect(mockState.optionsCalls).toEqual([
|
|
["ws-1", "issue", undefined, "parent-1"],
|
|
]);
|
|
});
|
|
|
|
it("counts the agents the server returned", () => {
|
|
mockState.agents = [
|
|
makeAgent({ id: "agent-1", issue_ids: ["child-1"] }),
|
|
makeAgent({ id: "agent-2", issue_ids: ["child-2"] }),
|
|
];
|
|
|
|
render(<SubIssuesAgentWorkingChip parentIssueId="parent-1" />);
|
|
|
|
expect(
|
|
screen.getByText("agent_activity.chip_agents_working:2"),
|
|
).not.toBeNull();
|
|
expect(screen.getByTestId("agent-avatar-stack").textContent).toBe(
|
|
"agent-1,agent-2",
|
|
);
|
|
});
|
|
|
|
it("hands the same agents to the hover body as it counted", () => {
|
|
mockState.agents = [
|
|
makeAgent({ id: "agent-1" }),
|
|
makeAgent({ id: "agent-2" }),
|
|
makeAgent({ id: "agent-3" }),
|
|
];
|
|
|
|
render(<SubIssuesAgentWorkingChip parentIssueId="parent-1" />);
|
|
|
|
// The number and the hover body must never disagree — they are the same
|
|
// list, not two derivations of one snapshot.
|
|
expect(
|
|
screen.getByText("agent_activity.chip_agents_working:3"),
|
|
).not.toBeNull();
|
|
expect(screen.getByTestId("hover-body").textContent).toBe("3");
|
|
});
|
|
|
|
it("renders nothing when no agent is working on a sub-issue", () => {
|
|
const { container } = render(
|
|
<SubIssuesAgentWorkingChip parentIssueId="parent-1" />,
|
|
);
|
|
|
|
expect(container.firstChild).toBeNull();
|
|
});
|
|
});
|