feat(FN-2592): add provider icons to task cards and detail modal

- Render provider icons on TaskCard model metadata with token-based sizing and spacing
- Add provider icon display in TaskDetailModal for executor, validator, and planning model rows
- Update dashboard styling with reusable provider icon classes and layout tweaks in component CSS
- Expand TaskCard tests to cover provider icon rendering and fallback behavior
This commit is contained in:
Fusion
2026-04-26 10:43:17 -07:00
committed by gsxdsm
parent 022d1522ea
commit 4d9c924f81
6 changed files with 145 additions and 14 deletions

View File

@@ -388,6 +388,18 @@
margin-top: var(--space-xs);
}
.card-provider-icons {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
flex-shrink: 0;
}
.card-provider-icons .provider-icon {
display: inline-flex;
align-items: center;
}
.card-agent-badge {
display: inline-flex;
align-items: center;

View File

@@ -6,6 +6,7 @@ import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, VALID_TRANSITION
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
import { GitHubBadge } from "./GitHubBadge";
import { pickPreferredBadge } from "./TaskCardBadge";
import { ProviderIcon } from "./ProviderIcon";
import { PluginSlot } from "./PluginSlot";
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
@@ -326,6 +327,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previousTask.modelId === nextTask.modelId &&
previousTask.validatorModelProvider === nextTask.validatorModelProvider &&
previousTask.validatorModelId === nextTask.validatorModelId &&
previousTask.planningModelProvider === nextTask.planningModelProvider &&
previousTask.planningModelId === nextTask.planningModelId &&
previousTask.reviewLevel === nextTask.reviewLevel &&
previousTask.missionId === nextTask.missionId &&
previousTask.assignedAgentId === nextTask.assignedAgentId &&
@@ -611,6 +614,17 @@ function TaskCardComponent({
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo);
const isAgentNameLoading = Boolean(task.assignedAgentId && agentName === null);
const taskProviders = useMemo(() => {
const providers: string[] = [];
if (task.modelProvider) providers.push(task.modelProvider);
if (task.validatorModelProvider && !providers.includes(task.validatorModelProvider)) {
providers.push(task.validatorModelProvider);
}
if (task.planningModelProvider && !providers.includes(task.planningModelProvider)) {
providers.push(task.planningModelProvider);
}
return providers;
}, [task.modelProvider, task.validatorModelProvider, task.planningModelProvider]);
const unifiedProgress = useMemo(
() => getUnifiedTaskProgress(task, workflowStepNameLookup),
[task.steps, task.enabledWorkflowSteps, task.workflowStepResults, workflowStepNameLookup],
@@ -1334,8 +1348,16 @@ function TaskCardComponent({
{(queued || task.status === "queued") && task.column !== "in-progress" && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
</div>
)}
{task.assignedAgentId && (
{(task.assignedAgentId || taskProviders.length > 0) && (
<div className="card-agent-row">
{taskProviders.length > 0 && (
<span className="card-provider-icons" data-testid="card-provider-icons">
{taskProviders.map((provider) => (
<ProviderIcon key={provider} provider={provider} size="sm" />
))}
</span>
)}
{task.assignedAgentId && (
<span
className={`card-agent-badge${isAgentNameLoading ? " card-agent-badge--loading" : ""}`}
title={`Assigned to ${agentName ?? task.assignedAgentId}`}
@@ -1345,6 +1367,7 @@ function TaskCardComponent({
{abbreviateBadge(agentName ?? task.assignedAgentId, 15)}
</span>
</span>
)}
</div>
)}
<PluginSlot slotId="task-card-badge" projectId={projectId} />

View File

@@ -157,6 +157,12 @@
margin-top: var(--space-lg);
}
.detail-provider-icons {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.detail-source-section {
border: 1px solid var(--border);
border-radius: var(--radius-md);

View File

@@ -1,5 +1,5 @@
import "./TaskDetailModal.css";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Bot, X, ChevronDown } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -20,6 +20,7 @@ import { WorkflowResultsTab } from "./WorkflowResultsTab";
import { TaskDocumentsTab } from "./TaskDocumentsTab";
import { TaskTokenStatsPanel } from "./TaskTokenStatsPanel";
import { PluginSlot } from "./PluginSlot";
import { ProviderIcon } from "./ProviderIcon";
import { subscribeSse } from "../sse-bus";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { appendTokenQuery } from "../auth";
@@ -1215,6 +1216,17 @@ export function TaskDetailModal({
});
const assignedAgentLabel = assignedAgent?.name ?? task.assignedAgentId ?? null;
const detailProviders = useMemo(() => {
const providers: string[] = [];
if (workingTask.modelProvider) providers.push(workingTask.modelProvider);
if (workingTask.validatorModelProvider && !providers.includes(workingTask.validatorModelProvider)) {
providers.push(workingTask.validatorModelProvider);
}
if (workingTask.planningModelProvider && !providers.includes(workingTask.planningModelProvider)) {
providers.push(workingTask.planningModelProvider);
}
return providers;
}, [workingTask.modelProvider, workingTask.validatorModelProvider, workingTask.planningModelProvider]);
const transitions = VALID_TRANSITIONS[task.column] || [];
const prAutomationStatusLabels: Record<string, string> = {
@@ -1592,10 +1604,19 @@ export function TaskDetailModal({
)}
<div className="detail-section detail-agent-section">
<div className="detail-meta-row">
<div className="detail-meta-left">
{detailProviders.length > 0 && (
<span className="detail-provider-icons" data-testid="detail-provider-icons">
{detailProviders.map((provider) => (
<ProviderIcon key={provider} provider={provider} size="sm" />
))}
</span>
)}
<span className="detail-meta-label">
<Bot size={14} style={{ verticalAlign: "middle", marginRight: 6 }} />
Agent
</span>
</div>
<div className="detail-agent-actions">
{assignedAgentLabel ? (
<span className="detail-agent-chip">

View File

@@ -18,6 +18,10 @@ vi.mock("lucide-react", () => ({
Trash2: () => null,
}));
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
}));
// Mock the api module
vi.mock("../../api", () => ({
fetchTaskDetail: vi.fn(),
@@ -699,6 +703,65 @@ describe("TaskCard", () => {
});
});
describe("TaskCard provider icons on agent row", () => {
it("renders provider icons when task has model overrides", () => {
render(
<TaskCard
task={makeTask({ modelProvider: "anthropic", assignedAgentId: "agent-1" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByTestId("card-provider-icons")).toBeDefined();
expect(screen.getByTestId("provider-icon-anthropic")).toBeDefined();
});
it("deduplicates when executor and validator use same provider", () => {
render(
<TaskCard
task={makeTask({
modelProvider: "openai",
validatorModelProvider: "openai",
planningModelProvider: "anthropic",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const icons = screen.getByTestId("card-provider-icons");
expect(icons.querySelectorAll("[data-testid^='provider-icon-']").length).toBe(2);
expect(screen.getByTestId("provider-icon-openai")).toBeDefined();
expect(screen.getByTestId("provider-icon-anthropic")).toBeDefined();
});
it("renders agent row with provider icons even without assignedAgentId", () => {
render(
<TaskCard
task={makeTask({ modelProvider: "anthropic", assignedAgentId: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByTestId("card-provider-icons")).toBeDefined();
expect(screen.getByTestId("provider-icon-anthropic")).toBeDefined();
});
it("does not render provider icons when no model overrides set", () => {
render(
<TaskCard
task={makeTask({ assignedAgentId: "agent-1" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByTestId("card-provider-icons")).toBeNull();
});
});
describe("TaskCard mission badge", () => {
// Access the internal cache reset helper
let clearCache: () => void;

View File

@@ -1873,6 +1873,12 @@ input[type="range"]:focus-visible {
gap: var(--space-sm);
}
.detail-meta-left {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.detail-meta-label {
display: inline-flex;
align-items: center;