FN-5859: resolve task card agent names from cache
Resolve task card agent badges to show assigned and source agent names without fetch delay. - use the agents map cache to resolve assigned agent names before falling back to fetches or raw ids - prefer cached source agent names for agent-created provenance badges while preserving the generic fallback when no name is available - add TaskCard coverage for cached agent badge rendering, provenance label resolution, and fallback behavior Files changed: packages/dashboard/app/components/TaskCard.tsx | 51 ++++++++++++--- .../app/components/__tests__/TaskCard.test.tsx | 76 ++++++++++++++++++++-- 2 files changed, 114 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-5859 Fusion-Task-Lineage: ac15f69a-50ca-48c5-a7a8-fb6c5e48af70
This commit is contained in:
@@ -18,6 +18,7 @@ import { PluginSlot } from "./PluginSlot";
|
|||||||
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
||||||
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
||||||
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
|
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
|
||||||
|
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
|
||||||
import { isTaskStuck } from "../utils/taskStuck";
|
import { isTaskStuck } from "../utils/taskStuck";
|
||||||
import { getStalledReviewSignal } from "../utils/taskStalledReview";
|
import { getStalledReviewSignal } from "../utils/taskStalledReview";
|
||||||
import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy";
|
import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy";
|
||||||
@@ -93,12 +94,32 @@ function abbreviateBadge(text: string, max: number): string {
|
|||||||
return text.slice(0, max - 3) + "...";
|
return text.slice(0, max - 3) + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSourceAgentName(task: Task): string | undefined {
|
function getResolvedAgentNameFromMap(
|
||||||
|
agentId: string | undefined,
|
||||||
|
agentsMap: ReadonlyMap<string, { name?: string | null }>,
|
||||||
|
): string | undefined {
|
||||||
|
if (typeof agentId !== "string" || agentId.trim().length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedName = agentsMap.get(agentId)?.name;
|
||||||
|
return typeof cachedName === "string" && cachedName.trim().length > 0 ? cachedName.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceAgentName(
|
||||||
|
task: Task,
|
||||||
|
agentsMap?: ReadonlyMap<string, { name?: string | null }>,
|
||||||
|
): string | undefined {
|
||||||
const metadataAgentName = task.sourceMetadata?.agentName;
|
const metadataAgentName = task.sourceMetadata?.agentName;
|
||||||
if (typeof metadataAgentName === "string" && metadataAgentName.trim().length > 0) {
|
if (typeof metadataAgentName === "string" && metadataAgentName.trim().length > 0) {
|
||||||
return metadataAgentName.trim();
|
return metadataAgentName.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedAgentName = getResolvedAgentNameFromMap(task.sourceAgentId, agentsMap ?? new Map());
|
||||||
|
if (resolvedAgentName) {
|
||||||
|
return resolvedAgentName;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof task.sourceAgentId === "string" && task.sourceAgentId.trim().length > 0) {
|
if (typeof task.sourceAgentId === "string" && task.sourceAgentId.trim().length > 0) {
|
||||||
return task.sourceAgentId.trim();
|
return task.sourceAgentId.trim();
|
||||||
}
|
}
|
||||||
@@ -571,6 +592,7 @@ function TaskCardComponent({
|
|||||||
const sendBackRef = useRef<HTMLDivElement>(null);
|
const sendBackRef = useRef<HTMLDivElement>(null);
|
||||||
const [isInViewport, setIsInViewport] = useState(false);
|
const [isInViewport, setIsInViewport] = useState(false);
|
||||||
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(projectId);
|
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(projectId);
|
||||||
|
const { agentsMap } = useAgentsMapCache(projectId);
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
const retryWarningThreshold = useRetryWarning();
|
const retryWarningThreshold = useRetryWarning();
|
||||||
|
|
||||||
@@ -628,7 +650,13 @@ function TaskCardComponent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache synchronously first
|
const cachedFromMap = getResolvedAgentNameFromMap(task.assignedAgentId, agentsMap);
|
||||||
|
if (cachedFromMap) {
|
||||||
|
agentNameCache.set(task.assignedAgentId, cachedFromMap);
|
||||||
|
setAgentName(cachedFromMap);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cached = agentNameCache.get(task.assignedAgentId);
|
const cached = agentNameCache.get(task.assignedAgentId);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setAgentName(cached);
|
setAgentName(cached);
|
||||||
@@ -642,7 +670,7 @@ function TaskCardComponent({
|
|||||||
if (!cancelled) setAgentName(name);
|
if (!cancelled) setAgentName(name);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [task.assignedAgentId, projectId]);
|
}, [agentsMap, task.assignedAgentId, projectId]);
|
||||||
|
|
||||||
// Auto-focus and auto-resize description textarea when entering edit mode
|
// Auto-focus and auto-resize description textarea when entering edit mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -857,9 +885,14 @@ function TaskCardComponent({
|
|||||||
const branchMetadata = useMemo(() => getVisibleTaskCardBranches(task), [task.id, task.branch, task.baseBranch]);
|
const branchMetadata = useMemo(() => getVisibleTaskCardBranches(task), [task.id, task.branch, task.baseBranch]);
|
||||||
const hasBranchMetadata = Boolean(branchMetadata.branch || branchMetadata.baseBranch);
|
const hasBranchMetadata = Boolean(branchMetadata.branch || branchMetadata.baseBranch);
|
||||||
const isAgentCreated = isAgentCreatedTask(task);
|
const isAgentCreated = isAgentCreatedTask(task);
|
||||||
const sourceAgentName = getSourceAgentName(task);
|
const sourceAgentName = getSourceAgentName(task, agentsMap);
|
||||||
|
const agentCreatedVisibleLabel = sourceAgentName ? abbreviateBadge(sourceAgentName, 15) : "Agent";
|
||||||
const agentCreatedTitle = sourceAgentName ? `Created by agent: ${sourceAgentName}` : "Created by agent";
|
const agentCreatedTitle = sourceAgentName ? `Created by agent: ${sourceAgentName}` : "Created by agent";
|
||||||
const isAgentNameLoading = Boolean(task.assignedAgentId && agentName === null);
|
const assignedAgentNameFromMap = getResolvedAgentNameFromMap(task.assignedAgentId, agentsMap);
|
||||||
|
const assignedAgentNameFromCache = task.assignedAgentId ? agentNameCache.get(task.assignedAgentId) ?? null : null;
|
||||||
|
const resolvedAssignedAgentName = assignedAgentNameFromMap ?? assignedAgentNameFromCache ?? agentName;
|
||||||
|
const assignedAgentBadgeLabel = resolvedAssignedAgentName ?? task.assignedAgentId ?? "";
|
||||||
|
const isAgentNameLoading = Boolean(task.assignedAgentId && !resolvedAssignedAgentName);
|
||||||
const taskProviders = useMemo(() => {
|
const taskProviders = useMemo(() => {
|
||||||
const providers: string[] = [];
|
const providers: string[] = [];
|
||||||
if (task.modelProvider) providers.push(task.modelProvider);
|
if (task.modelProvider) providers.push(task.modelProvider);
|
||||||
@@ -1742,7 +1775,7 @@ function TaskCardComponent({
|
|||||||
>
|
>
|
||||||
<Bot size={11} aria-hidden="true" />
|
<Bot size={11} aria-hidden="true" />
|
||||||
<span className="visually-hidden">{agentCreatedTitle}</span>
|
<span className="visually-hidden">{agentCreatedTitle}</span>
|
||||||
<span aria-hidden="true">Agent</span>
|
<span aria-hidden="true">{agentCreatedVisibleLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{showPriorityBadge && (
|
{showPriorityBadge && (
|
||||||
@@ -2132,13 +2165,13 @@ function TaskCardComponent({
|
|||||||
{task.assignedAgentId && (
|
{task.assignedAgentId && (
|
||||||
<span
|
<span
|
||||||
className={`card-agent-badge${isAgentNameLoading ? " card-agent-badge--loading" : ""}`}
|
className={`card-agent-badge${isAgentNameLoading ? " card-agent-badge--loading" : ""}`}
|
||||||
title={`Assigned to ${agentName ?? task.assignedAgentId}`}
|
title={`Assigned to ${assignedAgentBadgeLabel}`}
|
||||||
>
|
>
|
||||||
<Bot size={11} />
|
<Bot size={11} />
|
||||||
<span className="card-agent-badge-text" aria-hidden="true">
|
<span className="card-agent-badge-text" aria-hidden="true">
|
||||||
{abbreviateBadge(agentName ?? task.assignedAgentId, 15)}
|
{abbreviateBadge(assignedAgentBadgeLabel, 15)}
|
||||||
</span>
|
</span>
|
||||||
<span className="visually-hidden">Assigned to {agentName ?? task.assignedAgentId}</span>
|
<span className="visually-hidden">Assigned to {assignedAgentBadgeLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ vi.mock("../../api", () => ({
|
|||||||
uploadAttachment: vi.fn(),
|
uploadAttachment: vi.fn(),
|
||||||
fetchMission: vi.fn(),
|
fetchMission: vi.fn(),
|
||||||
fetchAgent: vi.fn(),
|
fetchAgent: vi.fn(),
|
||||||
|
fetchAgents: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockConfirm = vi.fn<(options: ConfirmOptions) => Promise<boolean>>();
|
const mockConfirm = vi.fn<(options: ConfirmOptions) => Promise<boolean>>();
|
||||||
@@ -87,8 +88,9 @@ vi.mock("../../hooks/useConfirm", () => ({
|
|||||||
useConfirm: () => ({ confirm: mockConfirm, confirmWithChoice: mockConfirmWithChoice }),
|
useConfirm: () => ({ confirm: mockConfirm, confirmWithChoice: mockConfirmWithChoice }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { uploadAttachment, fetchMission, fetchAgent } from "../../api";
|
import { uploadAttachment, fetchMission, fetchAgent, fetchAgents } from "../../api";
|
||||||
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
||||||
|
import { writeCache, SWR_CACHE_KEYS } from "../../utils/swrCache";
|
||||||
|
|
||||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
return {
|
return {
|
||||||
@@ -105,6 +107,18 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
|||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
|
function seedAgentsCache(projectId: string, agents: Array<{ id: string; name: string; role?: string; state?: string }>) {
|
||||||
|
writeCache(
|
||||||
|
`${SWR_CACHE_KEYS.CHAT_AGENTS_MAP_PREFIX}${projectId}`,
|
||||||
|
agents.map((agent) => ({
|
||||||
|
role: "executor",
|
||||||
|
state: "active",
|
||||||
|
...agent,
|
||||||
|
})),
|
||||||
|
{ maxBytes: 500_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function mountCssForBadgeTests() {
|
function mountCssForBadgeTests() {
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.textContent = loadAllAppCss();
|
style.textContent = loadAllAppCss();
|
||||||
@@ -3026,8 +3040,11 @@ describe("TaskCard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders agent-created provenance badge for automation tasks and prefers sourceMetadata.agentName", () => {
|
it("renders agent-created provenance badge for automation tasks and prefers sourceMetadata.agentName", () => {
|
||||||
|
seedAgentsCache("p1", [{ id: "agent-123", name: "Cache Robot" }]);
|
||||||
|
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
|
projectId="p1"
|
||||||
task={makeTask({
|
task={makeTask({
|
||||||
column: "todo",
|
column: "todo",
|
||||||
sourceType: "automation",
|
sourceType: "automation",
|
||||||
@@ -3042,6 +3059,8 @@ describe("TaskCard", () => {
|
|||||||
const badge = container.querySelector(".card-agent-created-badge");
|
const badge = container.querySelector(".card-agent-created-badge");
|
||||||
expect(badge).not.toBeNull();
|
expect(badge).not.toBeNull();
|
||||||
expect(badge?.getAttribute("title")).toBe("Created by agent: Task Robot");
|
expect(badge?.getAttribute("title")).toBe("Created by agent: Task Robot");
|
||||||
|
expect(badge?.querySelector("span[aria-hidden='true']")?.textContent).toBe("Task Robot");
|
||||||
|
expect(badge?.querySelector(".visually-hidden")?.textContent).toBe("Created by agent: Task Robot");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders agent-created provenance badge for agent_heartbeat tasks", () => {
|
it("renders agent-created provenance badge for agent_heartbeat tasks", () => {
|
||||||
@@ -3064,9 +3083,12 @@ describe("TaskCard", () => {
|
|||||||
expect(badge?.getAttribute("aria-label")).toBe("Created by agent: Scheduler Bot");
|
expect(badge?.getAttribute("aria-label")).toBe("Created by agent: Scheduler Bot");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders agent-created provenance badge for legacy sourceAgentId-only tasks", () => {
|
it("renders agent-created provenance badge with cached source agent names before falling back to ids", () => {
|
||||||
|
seedAgentsCache("p1", [{ id: "legacy-agent-1", name: "Legacy Robot" }]);
|
||||||
|
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
|
projectId="p1"
|
||||||
task={makeTask({
|
task={makeTask({
|
||||||
column: "todo",
|
column: "todo",
|
||||||
sourceAgentId: "legacy-agent-1",
|
sourceAgentId: "legacy-agent-1",
|
||||||
@@ -3078,7 +3100,29 @@ describe("TaskCard", () => {
|
|||||||
|
|
||||||
const badge = container.querySelector(".card-agent-created-badge");
|
const badge = container.querySelector(".card-agent-created-badge");
|
||||||
expect(badge).not.toBeNull();
|
expect(badge).not.toBeNull();
|
||||||
expect(badge?.getAttribute("title")).toBe("Created by agent: legacy-agent-1");
|
expect(badge?.getAttribute("title")).toBe("Created by agent: Legacy Robot");
|
||||||
|
expect(badge?.querySelector("span[aria-hidden='true']")?.textContent).toBe("Legacy Robot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the generic Agent label when source type is agent-created without a resolvable name", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({
|
||||||
|
column: "todo",
|
||||||
|
sourceType: "automation",
|
||||||
|
sourceAgentId: undefined,
|
||||||
|
sourceMetadata: undefined,
|
||||||
|
})}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const badge = container.querySelector(".card-agent-created-badge");
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
expect(badge?.getAttribute("title")).toBe("Created by agent");
|
||||||
|
expect(badge?.querySelector("span[aria-hidden='true']")?.textContent).toBe("Agent");
|
||||||
|
expect(badge?.querySelector(".visually-hidden")?.textContent).toBe("Created by agent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render agent-created provenance badge for non-agent task sources", () => {
|
it("does not render agent-created provenance badge for non-agent task sources", () => {
|
||||||
@@ -4403,10 +4447,34 @@ describe("TaskCard agent badge", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearAgentCache?.();
|
clearAgentCache?.();
|
||||||
|
localStorage.clear();
|
||||||
vi.mocked(fetchAgent).mockReset();
|
vi.mocked(fetchAgent).mockReset();
|
||||||
|
vi.mocked(fetchAgents).mockReset();
|
||||||
|
vi.mocked(fetchAgents).mockResolvedValue([] as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders agent badge when task has assignedAgentId", async () => {
|
it("renders agent badge synchronously from the seeded agents cache", () => {
|
||||||
|
seedAgentsCache("p1", [{ id: "agent-001", name: "Task Robot" }]);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TaskCard
|
||||||
|
projectId="p1"
|
||||||
|
task={makeTask({ assignedAgentId: "agent-001" })}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const badge = container.querySelector(".card-agent-badge");
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
expect(badge?.getAttribute("title")).toBe("Assigned to Task Robot");
|
||||||
|
expect(badge?.className).not.toContain("card-agent-badge--loading");
|
||||||
|
expect(badge?.querySelector(".card-agent-badge-text")?.textContent).toBe("Task Robot");
|
||||||
|
expect(badge?.querySelector(".visually-hidden")?.textContent).toContain("Assigned to Task Robot");
|
||||||
|
expect(fetchAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders agent badge when task has assignedAgentId and falls back to fetchAgent on cache miss", async () => {
|
||||||
vi.mocked(fetchAgent).mockResolvedValue({
|
vi.mocked(fetchAgent).mockResolvedValue({
|
||||||
id: "agent-001",
|
id: "agent-001",
|
||||||
name: "Task Robot",
|
name: "Task Robot",
|
||||||
|
|||||||
Reference in New Issue
Block a user