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);
|
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 {
|
.card-agent-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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 { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
||||||
import { GitHubBadge } from "./GitHubBadge";
|
import { GitHubBadge } from "./GitHubBadge";
|
||||||
import { pickPreferredBadge } from "./TaskCardBadge";
|
import { pickPreferredBadge } from "./TaskCardBadge";
|
||||||
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
import { PluginSlot } from "./PluginSlot";
|
import { PluginSlot } from "./PluginSlot";
|
||||||
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
||||||
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
||||||
@@ -326,6 +327,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
|||||||
previousTask.modelId === nextTask.modelId &&
|
previousTask.modelId === nextTask.modelId &&
|
||||||
previousTask.validatorModelProvider === nextTask.validatorModelProvider &&
|
previousTask.validatorModelProvider === nextTask.validatorModelProvider &&
|
||||||
previousTask.validatorModelId === nextTask.validatorModelId &&
|
previousTask.validatorModelId === nextTask.validatorModelId &&
|
||||||
|
previousTask.planningModelProvider === nextTask.planningModelProvider &&
|
||||||
|
previousTask.planningModelId === nextTask.planningModelId &&
|
||||||
previousTask.reviewLevel === nextTask.reviewLevel &&
|
previousTask.reviewLevel === nextTask.reviewLevel &&
|
||||||
previousTask.missionId === nextTask.missionId &&
|
previousTask.missionId === nextTask.missionId &&
|
||||||
previousTask.assignedAgentId === nextTask.assignedAgentId &&
|
previousTask.assignedAgentId === nextTask.assignedAgentId &&
|
||||||
@@ -611,6 +614,17 @@ function TaskCardComponent({
|
|||||||
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
|
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
|
||||||
const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo);
|
const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo);
|
||||||
const isAgentNameLoading = Boolean(task.assignedAgentId && agentName === null);
|
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(
|
const unifiedProgress = useMemo(
|
||||||
() => getUnifiedTaskProgress(task, workflowStepNameLookup),
|
() => getUnifiedTaskProgress(task, workflowStepNameLookup),
|
||||||
[task.steps, task.enabledWorkflowSteps, task.workflowStepResults, workflowStepNameLookup],
|
[task.steps, task.enabledWorkflowSteps, task.workflowStepResults, workflowStepNameLookup],
|
||||||
@@ -1334,17 +1348,26 @@ function TaskCardComponent({
|
|||||||
{(queued || task.status === "queued") && task.column !== "in-progress" && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
|
{(queued || task.status === "queued") && task.column !== "in-progress" && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{task.assignedAgentId && (
|
{(task.assignedAgentId || taskProviders.length > 0) && (
|
||||||
<div className="card-agent-row">
|
<div className="card-agent-row">
|
||||||
<span
|
{taskProviders.length > 0 && (
|
||||||
className={`card-agent-badge${isAgentNameLoading ? " card-agent-badge--loading" : ""}`}
|
<span className="card-provider-icons" data-testid="card-provider-icons">
|
||||||
title={`Assigned to ${agentName ?? task.assignedAgentId}`}
|
{taskProviders.map((provider) => (
|
||||||
>
|
<ProviderIcon key={provider} provider={provider} size="sm" />
|
||||||
<Bot size={11} />
|
))}
|
||||||
<span className="card-agent-badge-text">
|
|
||||||
{abbreviateBadge(agentName ?? task.assignedAgentId, 15)}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
)}
|
||||||
|
{task.assignedAgentId && (
|
||||||
|
<span
|
||||||
|
className={`card-agent-badge${isAgentNameLoading ? " card-agent-badge--loading" : ""}`}
|
||||||
|
title={`Assigned to ${agentName ?? task.assignedAgentId}`}
|
||||||
|
>
|
||||||
|
<Bot size={11} />
|
||||||
|
<span className="card-agent-badge-text">
|
||||||
|
{abbreviateBadge(agentName ?? task.assignedAgentId, 15)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PluginSlot slotId="task-card-badge" projectId={projectId} />
|
<PluginSlot slotId="task-card-badge" projectId={projectId} />
|
||||||
|
|||||||
@@ -157,6 +157,12 @@
|
|||||||
margin-top: var(--space-lg);
|
margin-top: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-provider-icons {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.detail-source-section {
|
.detail-source-section {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import "./TaskDetailModal.css";
|
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 { Pencil, Bot, X, ChevronDown } from "lucide-react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
@@ -20,6 +20,7 @@ import { WorkflowResultsTab } from "./WorkflowResultsTab";
|
|||||||
import { TaskDocumentsTab } from "./TaskDocumentsTab";
|
import { TaskDocumentsTab } from "./TaskDocumentsTab";
|
||||||
import { TaskTokenStatsPanel } from "./TaskTokenStatsPanel";
|
import { TaskTokenStatsPanel } from "./TaskTokenStatsPanel";
|
||||||
import { PluginSlot } from "./PluginSlot";
|
import { PluginSlot } from "./PluginSlot";
|
||||||
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
import { subscribeSse } from "../sse-bus";
|
import { subscribeSse } from "../sse-bus";
|
||||||
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
||||||
import { appendTokenQuery } from "../auth";
|
import { appendTokenQuery } from "../auth";
|
||||||
@@ -1215,6 +1216,17 @@ export function TaskDetailModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const assignedAgentLabel = assignedAgent?.name ?? task.assignedAgentId ?? null;
|
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 transitions = VALID_TRANSITIONS[task.column] || [];
|
||||||
const prAutomationStatusLabels: Record<string, string> = {
|
const prAutomationStatusLabels: Record<string, string> = {
|
||||||
@@ -1592,10 +1604,19 @@ export function TaskDetailModal({
|
|||||||
)}
|
)}
|
||||||
<div className="detail-section detail-agent-section">
|
<div className="detail-section detail-agent-section">
|
||||||
<div className="detail-meta-row">
|
<div className="detail-meta-row">
|
||||||
<span className="detail-meta-label">
|
<div className="detail-meta-left">
|
||||||
<Bot size={14} style={{ verticalAlign: "middle", marginRight: 6 }} />
|
{detailProviders.length > 0 && (
|
||||||
Agent
|
<span className="detail-provider-icons" data-testid="detail-provider-icons">
|
||||||
</span>
|
{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">
|
<div className="detail-agent-actions">
|
||||||
{assignedAgentLabel ? (
|
{assignedAgentLabel ? (
|
||||||
<span className="detail-agent-chip">
|
<span className="detail-agent-chip">
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ vi.mock("lucide-react", () => ({
|
|||||||
Trash2: () => null,
|
Trash2: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../ProviderIcon", () => ({
|
||||||
|
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the api module
|
// Mock the api module
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchTaskDetail: vi.fn(),
|
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", () => {
|
describe("TaskCard mission badge", () => {
|
||||||
// Access the internal cache reset helper
|
// Access the internal cache reset helper
|
||||||
let clearCache: () => void;
|
let clearCache: () => void;
|
||||||
|
|||||||
@@ -1873,6 +1873,12 @@ input[type="range"]:focus-visible {
|
|||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-meta-left {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.detail-meta-label {
|
.detail-meta-label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user