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:
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user