Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
e399c4f4af fix(issues): sync header agent chip with execution log via shared query
The header live chip derived its active-task state from the workspace-wide
agent-task-snapshot, while the right-panel Execution log read the per-issue
task list. Two queries, two endpoints, two independent refetches: the heavier
workspace snapshot lands later than the per-issue list, so the log could show
a running task while the header chip had not started yet.

Point the chip at the same `issueKeys.tasks(issueId)` cache the Execution log
uses (identical query options). Both surfaces now observe one cache entry and
update atomically. Drop the now-redundant workspace-id lookup and client-side
issue_id filter, since the endpoint is already issue-scoped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:19:16 +08:00
2 changed files with 38 additions and 27 deletions

View File

@@ -6,17 +6,13 @@ import type { AgentTask } from "@multica/core/types";
import { renderWithI18n } from "../../test/i18n";
const mockState = vi.hoisted(() => ({
snapshot: [] as unknown[],
tasks: [] as unknown[],
taskMessagesOptions: vi.fn(),
// Captures the props the chip passes to PopoverTrigger so a test can assert
// the card is wired to open on hover, not only on click.
triggerProps: undefined as Record<string, unknown> | undefined,
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({
getActorName: (_type: string, id: string) =>
@@ -75,8 +71,9 @@ vi.mock("@tanstack/react-query", async () => {
return {
...actual,
useQuery: (opts: { queryKey?: readonly unknown[] }) => {
if (opts.queryKey?.[2] === "agent-task-snapshot") {
return { data: mockState.snapshot };
// Per-issue task list: issueKeys.tasks(issueId) === ["issues","tasks",id]
if (opts.queryKey?.[0] === "issues" && opts.queryKey?.[1] === "tasks") {
return { data: mockState.tasks };
}
return { data: undefined };
},
@@ -106,13 +103,13 @@ function makeTask(overrides: Partial<AgentTask>): AgentTask {
beforeEach(() => {
cleanup();
vi.clearAllMocks();
mockState.snapshot = [];
mockState.tasks = [];
mockState.triggerProps = undefined;
});
describe("IssueAgentHeaderChip", () => {
it("shows the active agent name without event count or elapsed time", () => {
mockState.snapshot = [makeTask({})];
mockState.tasks = [makeTask({})];
renderWithI18n(<IssueAgentHeaderChip issueId="issue-1" />);
@@ -126,7 +123,7 @@ describe("IssueAgentHeaderChip", () => {
});
it("keeps the header popover card with active task rows", () => {
mockState.snapshot = [makeTask({ id: "task-running" })];
mockState.tasks = [makeTask({ id: "task-running" })];
renderWithI18n(<IssueAgentHeaderChip issueId="issue-1" />);
@@ -138,7 +135,7 @@ describe("IssueAgentHeaderChip", () => {
});
it("opens the activity card on hover, not only on click", () => {
mockState.snapshot = [makeTask({})];
mockState.tasks = [makeTask({})];
renderWithI18n(<IssueAgentHeaderChip issueId="issue-1" />);
@@ -152,7 +149,7 @@ describe("IssueAgentHeaderChip", () => {
});
it("uses the concise multi-agent working label", () => {
mockState.snapshot = [
mockState.tasks = [
makeTask({ id: "task-1", agent_id: "agent-1" }),
makeTask({ id: "task-2", agent_id: "agent-2" }),
];
@@ -168,7 +165,7 @@ describe("IssueAgentHeaderChip", () => {
});
it("uses the requested Chinese single-agent copy", () => {
mockState.snapshot = [makeTask({})];
mockState.tasks = [makeTask({})];
renderWithI18n(<IssueAgentHeaderChip issueId="issue-1" />, {
locale: "zh-Hans",
@@ -177,14 +174,20 @@ describe("IssueAgentHeaderChip", () => {
expect(screen.getByText("Walt 在工作")).toBeInTheDocument();
});
it("does not render for inactive or unrelated tasks", () => {
mockState.snapshot = [
it("does not render when the issue has only terminal tasks", () => {
// The list is issue-scoped by the endpoint, so the chip's only job is to
// ignore terminal statuses (those are the execution log's story).
mockState.tasks = [
makeTask({
id: "task-done",
status: "completed",
completed_at: "2026-06-08T08:05:00Z",
}),
makeTask({ id: "task-other", issue_id: "issue-2" }),
makeTask({
id: "task-cancelled",
status: "cancelled",
completed_at: "2026-06-08T08:06:00Z",
}),
];
renderWithI18n(<IssueAgentHeaderChip issueId="issue-1" />);

View File

@@ -7,10 +7,10 @@ import {
PopoverContent,
PopoverTrigger,
} from "@multica/ui/components/ui/popover";
import { useWorkspaceId } from "@multica/core/hooks";
import { useActorName } from "@multica/core/workspace/hooks";
import { cn } from "@multica/ui/lib/utils";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import { api } from "@multica/core/api";
import { issueKeys } from "@multica/core/issues/queries";
import type { AgentTask } from "@multica/core/types";
import { AgentAvatarStack } from "../../agents/components/agent-avatar-stack";
import { ActiveTaskRow } from "./execution-log-section";
@@ -21,10 +21,12 @@ import { useT } from "../../i18n";
// signal stays in one fixed place and never competes with future sticky
// banners in the content column. Replaces the in-body sticky live card.
//
// Derives state from the workspace-wide agent task snapshot filtered by
// issue id — the same single source of truth that powers the board-card /
// list-row IssueAgentActivityIndicator, so the chip is always consistent
// with those surfaces and costs zero extra network.
// Reads the same per-issue task list as the right-panel Execution log
// (shared `issueKeys.tasks(issueId)` cache), so the header chip and the log
// always agree on what is active. Both surfaces derive from one query, which
// removes the race where the old workspace-wide agent-task-snapshot refetched
// slower than this per-issue list and left the chip lagging behind the log's
// "agent is working".
//
// Collapsed display stays intentionally shallow:
// - one running agent → avatar + "{name} is working"
@@ -44,14 +46,20 @@ interface IssueAgentHeaderChipProps {
export const IssueAgentHeaderChip = memo(function IssueAgentHeaderChip({
issueId,
}: IssueAgentHeaderChipProps) {
const wsId = useWorkspaceId();
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
// Same query options as ExecutionLogSection so both observe one cache entry.
const { data: tasks = [] } = useQuery({
queryKey: issueKeys.tasks(issueId),
queryFn: () => api.listTasksByIssue(issueId),
staleTime: 30_000,
refetchOnWindowFocus: true,
});
const { running, queued } = useMemo(() => {
const running: AgentTask[] = [];
const queued: AgentTask[] = [];
for (const task of snapshot) {
if (task.issue_id !== issueId) continue;
// The list is already issue-scoped by the endpoint, so only the status
// split matters here.
for (const task of tasks) {
if (task.status === "running") running.push(task);
else if (
task.status === "queued" ||
@@ -64,7 +72,7 @@ export const IssueAgentHeaderChip = memo(function IssueAgentHeaderChip({
// Terminal statuses are the execution log's story, not the live chip's.
}
return { running, queued };
}, [snapshot, issueId]);
}, [tasks]);
// No active work → render nothing.
if (running.length === 0 && queued.length === 0) return null;