feat(FN-1098): add dashboard task-agent assignment flow

- Add task-agent assignment API helpers with coverage for assign, unassign, and list endpoints
- Show assigned agent badges on task cards and add assignment controls in TaskDetailModal
- Add agent task management tab plus agent selectors in quick and inline task creation UIs
- Expand component test coverage and include a changeset for the dashboard agent assignment feature
This commit is contained in:
gsxdsm
2026-04-08 03:19:09 -07:00
parent fb22b474cc
commit 71d9ae9d8b
17 changed files with 1342 additions and 29 deletions

View File

@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
fetchTaskDetail,
updateTask,
assignTask,
fetchAgentTasks,
archiveTask,
unarchiveTask,
fetchAuthStatus,
@@ -158,6 +160,52 @@ describe("updateTask", () => {
});
});
describe("assignTask and fetchAgentTasks", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
const ASSIGNED_TASK: Task = {
id: "FN-001",
description: "Test",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
assignedAgentId: "agent-001",
};
it("assignTask sends PATCH with agentId payload", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, ASSIGNED_TASK));
const result = await assignTask("FN-001", "agent-001");
expect(result.assignedAgentId).toBe("agent-001");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/assign", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ agentId: "agent-001" }),
});
});
it("fetchAgentTasks requests assigned tasks for an agent", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [ASSIGNED_TASK]));
const result = await fetchAgentTasks("agent-001");
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe("FN-001");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001/tasks", {
headers: { "Content-Type": "application/json" },
});
});
});
describe("task comments api", () => {
const originalFetch = globalThis.fetch;

View File

@@ -123,6 +123,7 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
dependencies,
breakIntoSubtasks,
enabledWorkflowSteps,
assignedAgentId,
modelPresetId,
modelProvider,
modelId,
@@ -142,6 +143,7 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
dependencies,
breakIntoSubtasks,
enabledWorkflowSteps,
assignedAgentId,
modelPresetId,
modelProvider,
modelId,
@@ -1887,6 +1889,19 @@ export function fetchAgentChildren(agentId: string, projectId?: string): Promise
});
}
/** Assign or unassign a task to an explicit agent */
export function assignTask(taskId: string, agentId: string | null, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/assign`, projectId), {
method: "PATCH",
body: JSON.stringify({ agentId }),
});
}
/** Fetch tasks explicitly assigned to an agent */
export function fetchAgentTasks(agentId: string, projectId?: string): Promise<Task[]> {
return api<Task[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/tasks`, projectId));
}
// ── Agent Import API ────────────────────────────────────────────────────────
/** Result of importing agents from a companies.sh manifest */

View File

@@ -2,13 +2,13 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import {
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
Settings, FileText, ActivitySquare, X, Copy,
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch,
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
ChevronDown, ChevronRight
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, updateAgentInstructions } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, updateAgentInstructions, fetchAgentTasks } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry } from "@fusion/core";
import type { AgentLogEntry, Task } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
/**
@@ -50,12 +50,13 @@ interface AgentDetailViewProps {
onChildClick?: (childId: string) => void;
}
type TabId = "dashboard" | "logs" | "config" | "runs" | "children";
type TabId = "dashboard" | "logs" | "config" | "runs" | "children" | "tasks";
const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
{ id: "dashboard", label: "Dashboard", icon: ActivitySquare },
{ id: "logs", label: "Logs", icon: FileText },
{ id: "runs", label: "Runs", icon: Activity },
{ id: "tasks", label: "Tasks", icon: ListChecks },
{ id: "children", label: "Children", icon: GitBranch },
{ id: "config", label: "Settings", icon: Settings },
];
@@ -381,6 +382,14 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
agentName={agent.name}
/>
)}
{activeTab === "tasks" && (
<TasksTab
agentId={agent.id}
projectId={projectId}
addToast={addToast}
/>
)}
{activeTab === "config" && (
<ConfigTab
@@ -1053,6 +1062,97 @@ function formatDuration(start: Date, end: Date): string {
return `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m`;
}
const TASK_COLUMN_LABELS: Record<Task["column"], string> = {
triage: "Triage",
todo: "Todo",
"in-progress": "In Progress",
"in-review": "In Review",
done: "Done",
archived: "Archived",
};
function truncateTaskLabel(task: Task): string {
const source = task.title?.trim() || task.description?.trim() || task.id;
return source.length > 80 ? `${source.slice(0, 77)}...` : source;
}
function TasksTab({
agentId,
projectId,
addToast,
}: {
agentId: string;
projectId?: string;
addToast: (msg: string, type?: "success" | "error") => void;
}) {
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
void fetchAgentTasks(agentId, projectId)
.then((assignedTasks) => {
if (!cancelled) {
setTasks(assignedTasks);
}
})
.catch((err: any) => {
if (!cancelled) {
setTasks([]);
addToast(`Failed to load assigned tasks: ${err.message}`, "error");
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [agentId, projectId, addToast]);
if (isLoading) {
return (
<div className="agent-tasks-empty">
<Loader2 size={16} className="animate-spin" />
<p>Loading assigned tasks...</p>
</div>
);
}
if (tasks.length === 0) {
return (
<div className="agent-tasks-empty">
<ListChecks size={18} />
<p>No tasks assigned to this agent</p>
</div>
);
}
return (
<div className="agent-tasks-list">
{tasks.map((task) => (
<a key={task.id} className="agent-task-item" href={`/tasks/${task.id}`}>
<div className="agent-task-row">
<span className="agent-task-id">{task.id}</span>
<span className={`agent-task-column column-${task.column}`}>{TASK_COLUMN_LABELS[task.column]}</span>
</div>
<div className="agent-task-title" title={task.title || task.description || task.id}>
{truncateTaskLabel(task)}
</div>
<div className="agent-task-status">
{task.status ?? "idle"} · Updated {relativeTime(task.updatedAt)}
</div>
</a>
))}
</div>
);
}
// ── Config Tab ─────────────────────────────────────────────────────────────
/** Shape of a single advanced setting field stored in agent.metadata */

View File

@@ -206,6 +206,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
availableModels={availableModels}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
projectId={projectId}
autoExpand={false}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}

View File

@@ -1,10 +1,10 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp } from "lucide-react";
import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp, Bot } from "lucide-react";
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings } from "../api";
import type { ModelInfo } from "../api";
import { fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents } from "../api";
import type { ModelInfo, Agent } from "../api";
import { ModelSelectionModal } from "./ModelSelectionModal";
import { applyPresetToSelection } from "../utils/modelPresets";
@@ -77,6 +77,10 @@ export function InlineCreateCard({
const [dependencies, setDependencies] = useState<string[]>([]);
const [showDeps, setShowDeps] = useState(false);
const [depSearch, setDepSearch] = useState("");
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agents, setAgents] = useState<Agent[]>([]);
const [showAgentPicker, setShowAgentPicker] = useState(false);
const [agentsLoading, setAgentsLoading] = useState(false);
const [isModelModalOpen, setIsModelModalOpen] = useState(false);
const [showPresets, setShowPresets] = useState(false);
const [settings, setSettings] = useState<Settings | null>(null);
@@ -97,6 +101,7 @@ export function InlineCreateCard({
const justResetRef = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const agentPickerRef = useRef<HTMLDivElement>(null);
// Persist description to localStorage whenever it changes
useEffect(() => {
@@ -135,6 +140,19 @@ export function InlineCreateCard({
if (!showDeps) setDepSearch("");
}, [showDeps]);
useEffect(() => {
if (!showAgentPicker) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (agentPickerRef.current?.contains(target)) return;
setShowAgentPicker(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAgentPicker]);
useEffect(() => {
let cancelled = false;
@@ -206,7 +224,7 @@ export function InlineCreateCard({
return;
}
const hasOpenOverlay = showDeps || isModelModalOpen || showPresets;
const hasOpenOverlay = showDeps || showAgentPicker || isModelModalOpen || showPresets;
const hasDescription = description.trim().length > 0;
const shouldCancelWhenCollapsed = !isExpanded && !hasDescription && !hasOpenOverlay;
const shouldCancelWhenExpanded = isExpanded && !hasDescription && !hasOpenOverlay;
@@ -218,7 +236,7 @@ export function InlineCreateCard({
card.addEventListener("focusout", handleFocusOut);
return () => card.removeEventListener("focusout", handleFocusOut);
}, [description, isExpanded, onCancel, showDeps, isModelModalOpen, showPresets]);
}, [description, isExpanded, onCancel, showDeps, showAgentPicker, isModelModalOpen, showPresets]);
// Clean up object URLs on unmount to prevent memory leaks
useEffect(() => {
@@ -277,6 +295,7 @@ export function InlineCreateCard({
description: description.trim(),
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
modelPresetId: selectedPresetId,
modelProvider: hasExecutorOverride ? executorProvider : undefined,
modelId: hasExecutorOverride ? executorModelId : undefined,
@@ -315,7 +334,9 @@ export function InlineCreateCard({
setValidatorModelId(undefined);
setBrowserVerification(false);
setDependencies([]);
setSelectedAgentId(null);
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
addToast(`Created ${task.id}`, "success");
@@ -334,6 +355,7 @@ export function InlineCreateCard({
}, [
description,
dependencies,
selectedAgentId,
hasExecutorOverride,
executorProvider,
executorModelId,
@@ -353,8 +375,9 @@ export function InlineCreateCard({
if (e.key === "Escape") {
e.preventDefault();
// Close dropdowns first if open
if (showDeps || isModelModalOpen || showPresets) {
if (showDeps || showAgentPicker || isModelModalOpen || showPresets) {
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
return;
@@ -381,7 +404,7 @@ export function InlineCreateCard({
handleSubmit();
}
},
[handleSubmit, onCancel, description, showDeps, isModelModalOpen, showPresets],
[handleSubmit, onCancel, description, showDeps, showAgentPicker, isModelModalOpen, showPresets],
);
const toggleDep = useCallback((id: string) => {
@@ -393,7 +416,10 @@ export function InlineCreateCard({
const toggleDepsDropdown = useCallback(() => {
setShowDeps((prev) => {
const next = !prev;
if (next) setIsModelModalOpen(false);
if (next) {
setIsModelModalOpen(false);
setShowAgentPicker(false);
}
return next;
});
}, []);
@@ -401,8 +427,31 @@ export function InlineCreateCard({
const toggleModelsDropdown = useCallback(() => {
setIsModelModalOpen(true);
setShowDeps(false);
setShowAgentPicker(false);
}, []);
const loadAgents = useCallback(async () => {
if (agents.length > 0) {
setShowAgentPicker(true);
return;
}
setAgentsLoading(true);
try {
const result = await fetchAgents(undefined, projectId);
setAgents(result);
setShowAgentPicker(true);
} catch (err: any) {
addToast(err?.message ? `Failed to load agents: ${err.message}` : "Failed to load agents", "error");
setShowAgentPicker(false);
} finally {
setAgentsLoading(false);
}
}, [agents.length, projectId, addToast]);
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
const handleExecutorChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setExecutorProvider(next.provider);
@@ -476,7 +525,9 @@ export function InlineCreateCard({
setValidatorModelId(undefined);
setBrowserVerification(false);
setSelectedPresetId(undefined);
setSelectedAgentId(null);
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
setIsExpanded(false);
@@ -498,7 +549,9 @@ export function InlineCreateCard({
setValidatorModelId(undefined);
setBrowserVerification(false);
setSelectedPresetId(undefined);
setSelectedAgentId(null);
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
setIsExpanded(false);
@@ -647,6 +700,60 @@ export function InlineCreateCard({
})()}
</div>
<div className="agent-trigger-wrap" ref={agentPickerRef}>
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={() => {
if (showAgentPicker) {
setShowAgentPicker(false);
} else {
void loadAgents();
}
}}
data-testid="inline-create-agent-button"
>
<Bot size={12} style={{ verticalAlign: "middle" }} />
{selectedAgentLabel ? ` ${selectedAgentLabel}` : " Agent"}
</button>
{showAgentPicker && (
<div className="dep-dropdown agent-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
<div className="dep-dropdown-search-header">Select agent</div>
{agentsLoading && <div className="dep-dropdown-empty">Loading agents...</div>}
{!agentsLoading && agents.filter((a) => a.state !== "terminated").map((a) => (
<div
key={a.id}
className={`dep-dropdown-item${selectedAgentId === a.id ? " selected" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSelectedAgentId(a.id === selectedAgentId ? null : a.id);
setShowAgentPicker(false);
}}
>
<Bot size={12} style={{ marginRight: 6 }} />
<span className="dep-dropdown-id">{a.role}</span>
<span className="dep-dropdown-title">{a.name}</span>
</div>
))}
{!agentsLoading && agents.filter((a) => a.state !== "terminated").length === 0 && (
<div className="dep-dropdown-empty">No agents available</div>
)}
{selectedAgentId && (
<div
className="dep-dropdown-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSelectedAgentId(null);
setShowAgentPicker(false);
}}
>
<span className="dep-dropdown-title">Clear selection</span>
</div>
)}
</div>
)}
</div>
<button
type="button"
className="btn btn-sm"
@@ -667,6 +774,7 @@ export function InlineCreateCard({
const next = !prev;
if (next) {
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
}
return next;

View File

@@ -751,6 +751,7 @@ export function ListView({
availableModels={availableModels}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
projectId={projectId}
autoExpand={false}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}

View File

@@ -2,9 +2,9 @@ import { useState, useCallback, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import type { ToastType } from "../hooks/useToast";
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
import type { ModelInfo, RefinementType } from "../api";
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings } from "../api";
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save, MoreHorizontal, ChevronDown, ChevronUp, ChevronRight } from "lucide-react";
import type { ModelInfo, RefinementType, Agent } from "../api";
import { fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents } from "../api";
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save, MoreHorizontal, ChevronDown, ChevronUp, ChevronRight, Bot } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
const STORAGE_KEY = "kb-quick-entry-text";
@@ -22,6 +22,8 @@ interface QuickEntryBoxProps {
* Called when the user clicks the "Subtask" button to trigger subtask breakdown.
*/
onSubtaskBreakdown?: (description: string) => void;
/** Optional project context for API calls */
projectId?: string;
/**
* When true, the component automatically expands when focused.
* Set to false to keep the view collapsed until manually toggled.
@@ -70,7 +72,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
};
}
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, autoExpand = true, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite }: QuickEntryBoxProps) {
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, projectId, autoExpand = true, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite }: QuickEntryBoxProps) {
const [description, setDescription] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem(STORAGE_KEY) || "";
@@ -90,6 +92,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [dependencies, setDependencies] = useState<string[]>([]);
const [showDeps, setShowDeps] = useState(false);
const [depSearch, setDepSearch] = useState("");
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agents, setAgents] = useState<Agent[]>([]);
const [showAgentPicker, setShowAgentPicker] = useState(false);
const [agentsLoading, setAgentsLoading] = useState(false);
const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false);
const [actionsMenuPosition, setActionsMenuPosition] = useState<{ top: number; left: number; width: number } | null>(null);
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
@@ -104,6 +110,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const actionsMenuPortalRef = useRef<HTMLDivElement>(null);
const modelMenuRef = useRef<HTMLDivElement>(null);
const modelMenuPortalRef = useRef<HTMLDivElement>(null);
const agentPickerRef = useRef<HTMLDivElement>(null);
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number } | null>(null);
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
const [modelsLoading, setModelsLoading] = useState(false);
@@ -163,7 +170,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
});
// Also fetch settings for presets
fetchSettings()
fetchSettings(projectId)
.then((nextSettings) => {
if (!cancelled) {
setSettings(nextSettings);
@@ -177,7 +184,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
cancelled = true;
};
}
}, [availableModels, parentFavoriteProviders, parentFavoriteModels]);
}, [availableModels, parentFavoriteProviders, parentFavoriteModels, projectId]);
const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId);
const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId);
@@ -192,7 +199,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
: selectedModelCount > 0
? `${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
: "Models";
const actionSelectionCount = dependencies.length + selectedModelCount;
const actionSelectionCount = dependencies.length + selectedModelCount + (selectedAgentId ? 1 : 0);
const getModelBadgeLabel = useCallback(
(provider?: string, modelId?: string) => {
@@ -324,9 +331,24 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isModelMenuOpen]);
useEffect(() => {
if (!showAgentPicker) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (agentPickerRef.current?.contains(target)) return;
setShowAgentPicker(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAgentPicker]);
const resetForm = useCallback(() => {
setDescription("");
setDependencies([]);
setSelectedAgentId(null);
setShowAgentPicker(false);
setExecutorProvider(undefined);
setExecutorModelId(undefined);
setValidatorProvider(undefined);
@@ -364,6 +386,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
description: trimmed,
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
modelPresetId: selectedPresetId,
modelProvider: hasExecutorOverride ? executorProvider : undefined,
modelId: hasExecutorOverride ? executorModelId : undefined,
@@ -386,6 +409,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
isSubmitting,
onCreate,
dependencies,
selectedAgentId,
hasExecutorOverride,
executorProvider,
executorModelId,
@@ -429,6 +453,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setModelMenuPosition(null);
return;
}
if (showAgentPicker) {
setShowAgentPicker(false);
return;
}
// Close dependency or refine popover if open
if (showDeps || isRefineMenuOpen) {
setShowDeps(false);
@@ -458,6 +486,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
description,
isExpanded,
showDeps,
showAgentPicker,
isActionsMenuOpen,
isModelMenuOpen,
activeModelSubmenu,
@@ -517,6 +546,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const next = !prev;
if (next) {
setShowDeps(false);
setShowAgentPicker(false);
setIsModelMenuOpen(false);
setModelMenuPosition(null);
setActiveModelSubmenu(null);
@@ -761,6 +791,28 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
}, [availableModels, parentFavoriteProviders, parentFavoriteModels]);
const loadAgents = useCallback(async () => {
if (agents.length > 0) {
setShowAgentPicker(true);
return;
}
setAgentsLoading(true);
try {
const result = await fetchAgents(undefined, projectId);
setAgents(result);
setShowAgentPicker(true);
} catch (err: any) {
addToast(err?.message ? `Failed to load agents: ${err.message}` : "Failed to load agents", "error");
setShowAgentPicker(false);
} finally {
setAgentsLoading(false);
}
}, [agents.length, projectId, addToast]);
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
// Show expanded controls based on disclosure state (user preference), not textarea focus
const showExpandedControls = isDisclosureExpanded;
@@ -955,6 +1007,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setIsModelMenuOpen(false);
setModelMenuPosition(null);
setActiveModelSubmenu(null);
setShowAgentPicker(false);
setShowDeps(true);
}}
>
@@ -969,6 +1022,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setIsActionsMenuOpen(false);
setActionsMenuPosition(null);
setShowDeps(false);
setShowAgentPicker(false);
setActiveModelSubmenu(null);
setIsModelMenuOpen(true);
updateModelMenuPosition();
@@ -1044,6 +1098,60 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
})()}
</div>
<div className="agent-trigger-wrap" ref={agentPickerRef}>
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={() => {
if (showAgentPicker) {
setShowAgentPicker(false);
} else {
void loadAgents();
}
}}
data-testid="quick-entry-agent-button"
>
<Bot size={12} style={{ verticalAlign: "middle" }} />
{selectedAgentLabel ? ` ${selectedAgentLabel}` : " Agent"}
</button>
{showAgentPicker && (
<div className="dep-dropdown agent-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
<div className="dep-dropdown-search-header">Select agent</div>
{agentsLoading && <div className="dep-dropdown-empty">Loading agents...</div>}
{!agentsLoading && agents.filter((a) => a.state !== "terminated").map((a) => (
<div
key={a.id}
className={`dep-dropdown-item${selectedAgentId === a.id ? " selected" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSelectedAgentId(a.id === selectedAgentId ? null : a.id);
setShowAgentPicker(false);
}}
>
<Bot size={12} style={{ marginRight: 6 }} />
<span className="dep-dropdown-id">{a.role}</span>
<span className="dep-dropdown-title">{a.name}</span>
</div>
))}
{!agentsLoading && agents.filter((a) => a.state !== "terminated").length === 0 && (
<div className="dep-dropdown-empty">No agents available</div>
)}
{selectedAgentId && (
<div
className="dep-dropdown-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSelectedAgentId(null);
setShowAgentPicker(false);
}}
>
<span className="dep-dropdown-title">Clear selection</span>
</div>
)}
</div>
)}
</div>
{isModelMenuOpen && portalRoot && modelMenuPosition && createPortal(
<div
ref={modelMenuPortalRef}

View File

@@ -14,6 +14,7 @@ vi.mock("lucide-react", () => ({
GitPullRequest: () => null,
CircleDot: () => null,
Target: () => null,
Bot: () => null,
}));
// Mock the api module
@@ -21,9 +22,10 @@ vi.mock("../api", () => ({
fetchTaskDetail: vi.fn(),
uploadAttachment: vi.fn(),
fetchMission: vi.fn(),
fetchAgent: vi.fn(),
}));
import { uploadAttachment, fetchMission } from "../api";
import { uploadAttachment, fetchMission, fetchAgent } from "../api";
function makeTask(overrides: Partial<Task> = {}): Task {
return {
@@ -403,3 +405,56 @@ describe("TaskCard mission badge", () => {
});
});
});
describe("TaskCard agent badge", () => {
let clearAgentCache: () => void;
beforeAll(async () => {
const mod = await import("./TaskCard");
clearAgentCache = (mod as { __test_clearAgentNameCache?: () => void }).__test_clearAgentNameCache ?? (() => undefined);
});
beforeEach(() => {
clearAgentCache?.();
vi.mocked(fetchAgent).mockReset();
});
it("renders agent badge when task has assignedAgentId", async () => {
vi.mocked(fetchAgent).mockResolvedValue({
id: "agent-001",
name: "Task Robot",
role: "executor",
state: "active",
metadata: {},
heartbeatHistory: [],
completedRuns: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as any);
render(
<TaskCard
task={makeTask({ assignedAgentId: "agent-001" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
await waitFor(() => {
expect(screen.getByTitle("Assigned to Task Robot")).toBeDefined();
expect(screen.getByText("Task Robot")).toBeDefined();
});
});
it("does not render agent badge when assignedAgentId is undefined", () => {
render(
<TaskCard
task={makeTask()}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByTitle(/Assigned to/)).toBeNull();
});
});

View File

@@ -1,7 +1,7 @@
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target } from "lucide-react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot } from "lucide-react";
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
import { fetchTaskDetail, uploadAttachment, fetchMission } from "../api";
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
import { GitHubBadge } from "./GitHubBadge";
import { pickPreferredBadge } from "./TaskCardBadge";
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
@@ -40,6 +40,33 @@ function abbreviateMissionTitle(title: string): string {
return title.slice(0, MAX_MISSION_TITLE_LENGTH - 3) + "...";
}
// ── Assigned agent name caching ─────────────────────────────────────────────
const agentNameCache = new Map<string, string>();
/** @internal Test helper to reset the assigned agent cache between tests */
export function __test_clearAgentNameCache(): void {
agentNameCache.clear();
}
async function getAgentName(agentId: string, projectId?: string): Promise<string> {
const cached = agentNameCache.get(agentId);
if (cached) return cached;
try {
const agent = await fetchAgent(agentId, projectId);
agentNameCache.set(agentId, agent.name);
return agent.name;
} catch {
return agentId;
}
}
function abbreviateBadge(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, max - 3) + "...";
}
// ── Constants ───────────────────────────────────────────────────────────────
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
@@ -131,6 +158,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previousTask.validatorModelId === nextTask.validatorModelId &&
previousTask.reviewLevel === nextTask.reviewLevel &&
previousTask.missionId === nextTask.missionId &&
previousTask.assignedAgentId === nextTask.assignedAgentId &&
previousTask.mergeRetries === nextTask.mergeRetries &&
JSON.stringify(previousTask.attachments ?? []) === JSON.stringify(nextTask.attachments ?? []) &&
JSON.stringify(previousTask.comments ?? []) === JSON.stringify(nextTask.comments ?? []) &&
@@ -165,6 +193,7 @@ function TaskCardComponent({
(task.column === "triage" && task.steps.some(s => s.status === "done" || s.status === "skipped"))
);
const [missionTitle, setMissionTitle] = useState<string | null>(null);
const [agentName, setAgentName] = useState<string | null>(null);
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
const touchOpenHandledRef = useRef(false);
@@ -207,6 +236,27 @@ function TaskCardComponent({
return () => { cancelled = true; };
}, [task.missionId, projectId]);
// Fetch assigned agent name when assignedAgentId is set
useEffect(() => {
if (!task.assignedAgentId) {
setAgentName(null);
return;
}
// Check cache synchronously first
const cached = agentNameCache.get(task.assignedAgentId);
if (cached) {
setAgentName(cached);
return;
}
let cancelled = false;
void getAgentName(task.assignedAgentId, projectId).then((name) => {
if (!cancelled) setAgentName(name);
});
return () => { cancelled = true; };
}, [task.assignedAgentId, projectId]);
// Auto-focus and auto-resize description textarea when entering edit mode
useEffect(() => {
if (isEditing && descTextareaRef.current) {
@@ -652,6 +702,12 @@ function TaskCardComponent({
{abbreviateMissionTitle(missionTitle ?? task.missionId)}
</span>
)}
{task.assignedAgentId && (
<span className="card-agent-badge" title={`Assigned to ${agentName ?? task.assignedAgentId}`}>
<Bot size={11} />
{abbreviateBadge(agentName ?? task.assignedAgentId, 15)}
</span>
)}
<div className="card-header-actions">
{canEdit && (
<button

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pencil } from "lucide-react";
import { Pencil, Bot, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo, Settings, AgentLogEntry } from "@fusion/core";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo, Settings, AgentLogEntry, Agent } from "@fusion/core";
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@fusion/core";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults } from "../api";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent } from "../api";
import type { WorkflowStepResult } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { useAgentLogs } from "../hooks/useAgentLogs";
@@ -222,6 +222,10 @@ export function TaskDetailModal({
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
const [showDepDropdown, setShowDepDropdown] = useState(false);
const [depSearch, setDepSearch] = useState("");
const [assignedAgent, setAssignedAgent] = useState<Agent | null>(null);
const [agents, setAgents] = useState<Agent[]>([]);
const [showAgentPicker, setShowAgentPicker] = useState(false);
const [agentsLoading, setAgentsLoading] = useState(false);
const [isSavingSpec, setIsSavingSpec] = useState(false);
const [isRequestingRevision, setIsRequestingRevision] = useState(false);
const [isEditingSpec, setIsEditingSpec] = useState(false);
@@ -313,6 +317,36 @@ export function TaskDetailModal({
}
}, [showDepDropdown]);
useEffect(() => {
if (!task.assignedAgentId) {
setAssignedAgent(null);
return;
}
const knownAgent = agents.find((agent) => agent.id === task.assignedAgentId);
if (knownAgent) {
setAssignedAgent(knownAgent);
return;
}
let cancelled = false;
void fetchAgent(task.assignedAgentId, projectId)
.then((agent) => {
if (!cancelled) setAssignedAgent(agent);
})
.catch(() => {
if (!cancelled) setAssignedAgent(null);
});
return () => {
cancelled = true;
};
}, [task.assignedAgentId, projectId, agents]);
useEffect(() => {
setShowAgentPicker(false);
}, [task.id]);
// Reset spec edit state when task changes
useEffect(() => {
setIsEditingSpec(false);
@@ -667,6 +701,49 @@ export function TaskDetailModal({
}
}, [task.id, projectId, workflowEnabledSteps, onTaskUpdated, addToast]);
const loadAgents = useCallback(async () => {
setAgentsLoading(true);
try {
const loadedAgents = await fetchAgents(undefined, projectId);
setAgents(loadedAgents);
setShowAgentPicker(true);
} catch (err: any) {
addToast(`Failed to load agents: ${err.message}`, "error");
setShowAgentPicker(false);
} finally {
setAgentsLoading(false);
}
}, [projectId, addToast]);
const handleAssignAgent = useCallback(async (agentId: string) => {
try {
const updatedTask = await assignTask(task.id, agentId, projectId);
const selected = agents.find((agent) => agent.id === agentId) ?? null;
if (selected) {
setAssignedAgent(selected);
} else {
setAssignedAgent((prev) => (prev?.id === agentId ? prev : null));
}
setShowAgentPicker(false);
onTaskUpdated?.(updatedTask);
addToast("Assigned agent updated", "success");
} catch (err: any) {
addToast(`Failed to assign agent: ${err.message}`, "error");
}
}, [task.id, projectId, agents, onTaskUpdated, addToast]);
const handleClearAgent = useCallback(async () => {
try {
const updatedTask = await assignTask(task.id, null, projectId);
setAssignedAgent(null);
setShowAgentPicker(false);
onTaskUpdated?.(updatedTask);
addToast("Agent unassigned", "success");
} catch (err: any) {
addToast(`Failed to unassign agent: ${err.message}`, "error");
}
}, [task.id, projectId, onTaskUpdated, addToast]);
const handleAddDep = useCallback(async (depId: string) => {
const newDeps = [...dependencies, depId];
setDependencies(newDeps);
@@ -781,6 +858,8 @@ export function TaskDetailModal({
return bNum - aNum;
});
const assignedAgentLabel = assignedAgent?.name ?? task.assignedAgentId ?? null;
const transitions = VALID_TRANSITIONS[task.column] || [];
const prAutomationStatusLabels: Record<string, string> = {
"creating-pr": "Creating PR…",
@@ -1000,6 +1079,61 @@ export function TaskDetailModal({
</div>
)}
<MergeDetails task={task} />
<div className="detail-section detail-agent-section">
<div className="detail-meta-row">
<span className="detail-meta-label">
<Bot size={14} style={{ verticalAlign: "middle", marginRight: 6 }} />
Agent
</span>
<div className="detail-agent-actions">
{assignedAgentLabel ? (
<span className="detail-agent-chip">
<Bot size={14} />
{assignedAgentLabel}
<button
className="detail-agent-clear"
onClick={() => void handleClearAgent()}
title="Unassign agent"
>
<X size={12} />
</button>
</span>
) : (
<button
className="btn btn-sm"
onClick={() => {
if (showAgentPicker) {
setShowAgentPicker(false);
} else {
void loadAgents();
}
}}
>
Assign Agent
</button>
)}
{showAgentPicker && (
<div className="agent-picker-dropdown">
{agentsLoading && <div className="agent-picker-loading">Loading agents...</div>}
{!agentsLoading && agents.filter((a) => a.state !== "terminated").map((a) => (
<button
key={a.id}
className={`agent-picker-item${task.assignedAgentId === a.id ? " selected" : ""}`}
onClick={() => void handleAssignAgent(a.id)}
>
<Bot size={14} />
<span className="agent-picker-name">{a.name}</span>
<span className="agent-picker-role">{a.role}</span>
</button>
))}
{!agentsLoading && agents.filter((a) => a.state !== "terminated").length === 0 && (
<div className="agent-picker-empty">No agents available</div>
)}
</div>
)}
</div>
</div>
</div>
<div className="detail-section detail-step-progress">
<h4>Progress</h4>
{task.steps && task.steps.length > 0 ? (

View File

@@ -16,6 +16,7 @@ vi.mock("../../api", () => ({
fetchAgentRuns: vi.fn(),
fetchAgentRunDetail: vi.fn(),
startAgentRun: vi.fn(),
fetchAgentTasks: vi.fn(),
}));
vi.mock("../AgentLogViewer", () => ({
@@ -26,7 +27,7 @@ vi.mock("../AgentLogViewer", () => ({
),
}));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail } from "../../api";
import { fetchAgent, updateAgent, updateAgentState, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent);
@@ -34,6 +35,7 @@ const mockUpdateAgentState = vi.mocked(updateAgentState);
const mockFetchAgentRunLogs = vi.mocked(fetchAgentRunLogs);
const mockFetchAgentRuns = vi.mocked(fetchAgentRuns);
const mockFetchAgentRunDetail = vi.mocked(fetchAgentRunDetail);
const mockFetchAgentTasks = vi.mocked(fetchAgentTasks);
describe("AgentDetailView", () => {
const createMockAgent = (overrides: Partial<{
@@ -86,6 +88,7 @@ describe("AgentDetailView", () => {
...mockAgent.completedRuns,
]);
mockFetchAgentRunDetail.mockResolvedValue(mockAgent.completedRuns[0]);
mockFetchAgentTasks.mockResolvedValue([]);
});
it("shows loading state initially", () => {
@@ -308,6 +311,8 @@ describe("AgentDetailView", () => {
expect(screen.getByText("Dashboard")).toBeInTheDocument();
expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.getByText("Runs")).toBeInTheDocument();
expect(screen.getByText("Tasks")).toBeInTheDocument();
expect(screen.getByText("Children")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
});
});
@@ -456,6 +461,61 @@ describe("AgentDetailView", () => {
});
});
describe("Tasks tab", () => {
it("renders tasks returned by fetchAgentTasks", async () => {
const user = userEvent.setup();
mockFetchAgentTasks.mockResolvedValue([
{
id: "FN-201",
title: "Implement assignment API",
description: "",
column: "in-progress",
status: "executing",
steps: [],
dependencies: [],
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
},
] as any);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await user.click(await screen.findByText("Tasks"));
await waitFor(() => {
expect(mockFetchAgentTasks).toHaveBeenCalledWith("agent-001", undefined);
expect(screen.getByText("FN-201")).toBeInTheDocument();
expect(screen.getByText("Implement assignment API")).toBeInTheDocument();
});
});
it("shows empty state when no tasks are assigned", async () => {
const user = userEvent.setup();
mockFetchAgentTasks.mockResolvedValue([]);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await user.click(await screen.findByText("Tasks"));
await waitFor(() => {
expect(screen.getByText("No tasks assigned to this agent")).toBeInTheDocument();
});
});
});
// ── Advanced Settings (Config Tab) ────────────────────────────────────
describe("Advanced Settings", () => {

View File

@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { InlineCreateCard } from "../InlineCreateCard";
import type { Task, Column } from "@fusion/core";
import { fetchModels, fetchSettings } from "../../api";
import { fetchModels, fetchSettings, fetchAgents } from "../../api";
import type { ModelInfo } from "../../api";
// Mock lucide-react
@@ -19,6 +19,7 @@ vi.mock("lucide-react", () => ({
Zap: () => null,
ChevronDown: () => null,
ChevronUp: () => null,
Bot: () => null,
}));
// Mock ModelSelectionModal (renders via portal, so mock for testability)
@@ -94,6 +95,7 @@ vi.mock("../../api", () => ({
}),
uploadAttachment: vi.fn(),
updateGlobalSettings: vi.fn(),
fetchAgents: vi.fn().mockResolvedValue([]),
}));
const MOCK_MODELS: ModelInfo[] = [
@@ -162,6 +164,7 @@ beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.mocked(fetchModels).mockResolvedValue({ models: MOCK_MODELS, favoriteProviders: [], favoriteModels: [] });
vi.mocked(fetchAgents).mockResolvedValue([]);
vi.mocked(fetchSettings).mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
@@ -992,4 +995,93 @@ describe("InlineCreateCard button visibility when collapsed", () => {
expect(screen.getByTestId("modal-props-selected-preset-id").textContent).toBe("fast");
});
});
describe("agent selector", () => {
it("opens the agent picker", async () => {
vi.mocked(fetchAgents).mockResolvedValue([
{
id: "agent-001",
name: "Task Runner",
role: "executor",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any);
renderCard([createMockTask({ id: "FN-100" })]);
expandCard();
fireEvent.click(screen.getByTestId("inline-create-agent-button"));
await waitFor(() => {
expect(screen.getByText("Select agent")).toBeInTheDocument();
expect(screen.getByText("Task Runner")).toBeInTheDocument();
});
});
it("passes assignedAgentId when selected", async () => {
vi.mocked(fetchAgents).mockResolvedValue([
{
id: "agent-002",
name: "Builder",
role: "executor",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any);
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-900" } as Task);
renderCard([createMockTask({ id: "FN-100" })], { onSubmit });
expandCard();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Create card with agent" } });
fireEvent.click(screen.getByTestId("inline-create-agent-button"));
await waitFor(() => expect(screen.getByText("Builder")).toBeInTheDocument());
fireEvent.click(screen.getByText("Builder"));
fireEvent.keyDown(screen.getByPlaceholderText("What needs to be done?"), { key: "Enter" });
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ assignedAgentId: "agent-002" }));
});
});
it("clears assignedAgentId when selection is removed", async () => {
vi.mocked(fetchAgents).mockResolvedValue([
{
id: "agent-003",
name: "Reviewer",
role: "reviewer",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any);
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-901" } as Task);
renderCard([createMockTask({ id: "FN-100" })], { onSubmit });
expandCard();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Create card without agent" } });
fireEvent.click(screen.getByTestId("inline-create-agent-button"));
await waitFor(() => expect(screen.getByText("Reviewer")).toBeInTheDocument());
fireEvent.click(screen.getByText("Reviewer"));
fireEvent.click(screen.getByTestId("inline-create-agent-button"));
await waitFor(() => expect(screen.getByText("Clear selection")).toBeInTheDocument());
fireEvent.click(screen.getByText("Clear selection"));
fireEvent.keyDown(screen.getByPlaceholderText("What needs to be done?"), { key: "Enter" });
await waitFor(() => {
const payload = onSubmit.mock.calls[0]?.[0] as Record<string, unknown>;
expect(payload.assignedAgentId).toBeUndefined();
});
});
});
});

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { QuickEntryBox } from "../QuickEntryBox";
import type { Task } from "@fusion/core";
import { fetchSettings } from "../../api";
import { fetchSettings, fetchAgents } from "../../api";
const MOCK_MODELS = [
{
@@ -78,6 +78,8 @@ vi.mock("../../api", () => ({
}),
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
fetchAgents: vi.fn().mockResolvedValue([]),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
}));
// Mock lucide-react
@@ -93,6 +95,7 @@ vi.mock("lucide-react", () => ({
ChevronDown: () => null,
ChevronUp: () => null,
ChevronRight: () => null,
Bot: () => null,
}));
// Mock ModelSelectionModal (kept for backward compatibility - no longer directly rendered)
@@ -184,6 +187,7 @@ describe("QuickEntryBox", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
localStorage.clear();
vi.mocked(fetchAgents).mockResolvedValue([]);
});
afterEach(() => {
@@ -2132,4 +2136,94 @@ describe("QuickEntryBox", () => {
expect(mobileWidth).toBeGreaterThan(desktopWidth);
});
});
describe("agent selector", () => {
it("opens the agent picker from the agent button", async () => {
vi.mocked(fetchAgents).mockResolvedValue([
{
id: "agent-001",
name: "Task Runner",
role: "executor",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any);
renderQuickEntryBox({});
expandQuickEntry();
fireEvent.click(screen.getByTestId("quick-entry-agent-button"));
await waitFor(() => {
expect(screen.getByText("Select agent")).toBeInTheDocument();
expect(screen.getByText("Task Runner")).toBeInTheDocument();
});
});
it("includes assignedAgentId in onCreate payload when selected", async () => {
vi.mocked(fetchAgents).mockResolvedValue([
{
id: "agent-002",
name: "Builder",
role: "executor",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any);
const onCreate = vi.fn().mockResolvedValue(undefined);
renderQuickEntryBox({ onCreate });
expandQuickEntry();
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Create task with agent" } });
fireEvent.click(screen.getByTestId("quick-entry-agent-button"));
await waitFor(() => expect(screen.getByText("Builder")).toBeInTheDocument());
fireEvent.click(screen.getByText("Builder"));
fireEvent.keyDown(screen.getByTestId("quick-entry-input"), { key: "Enter" });
await waitFor(() => {
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ assignedAgentId: "agent-002" }));
});
});
it("omits assignedAgentId when agent selection is cleared", async () => {
vi.mocked(fetchAgents).mockResolvedValue([
{
id: "agent-003",
name: "Reviewer",
role: "reviewer",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any);
const onCreate = vi.fn().mockResolvedValue(undefined);
renderQuickEntryBox({ onCreate });
expandQuickEntry();
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Create task without agent" } });
fireEvent.click(screen.getByTestId("quick-entry-agent-button"));
await waitFor(() => expect(screen.getByText("Reviewer")).toBeInTheDocument());
fireEvent.click(screen.getByText("Reviewer"));
// Clear via picker action
fireEvent.click(screen.getByTestId("quick-entry-agent-button"));
await waitFor(() => expect(screen.getByText("Clear selection")).toBeInTheDocument());
fireEvent.click(screen.getByText("Clear selection"));
fireEvent.keyDown(screen.getByTestId("quick-entry-input"), { key: "Enter" });
await waitFor(() => {
const payload = onCreate.mock.calls[0]?.[0] as Record<string, unknown>;
expect(payload.assignedAgentId).toBeUndefined();
});
});
});
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Column, Task, TaskDetail } from "@fusion/core";
@@ -8,6 +8,8 @@ import React, { useState } from "react";
vi.mock("../../api", () => ({
fetchTaskDetail: vi.fn(),
uploadAttachment: vi.fn(),
fetchMission: vi.fn(),
fetchAgent: vi.fn(),
}));
const mockUseBadgeWebSocket = vi.fn(() => ({
@@ -44,6 +46,7 @@ vi.mock("lucide-react", () => ({
GitPullRequest: ({ size }: { size?: number }) => <span data-testid="git-pr-icon">🔀</span>,
CircleDot: ({ size }: { size?: number }) => <span data-testid="circle-dot-icon">⭕</span>,
Target: ({ size }: { size?: number }) => <span data-testid="target-icon">🎯</span>,
Bot: ({ size }: { size?: number }) => <span data-testid="bot-icon">🤖</span>,
}));
beforeEach(() => {
@@ -3442,3 +3445,65 @@ describe("TaskCard mission badge", () => {
expect(onOpenMission).toHaveBeenCalledWith("MSN-001");
});
});
describe("TaskCard agent badge", () => {
const createTask = (overrides: Partial<Task> = {}): Task => ({
id: "FN-001",
description: "Test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
columnMovedAt: "2026-01-01T00:00:00Z",
...overrides,
} as Task);
let clearAgentCache: () => void;
beforeAll(async () => {
const mod = await import("../TaskCard");
clearAgentCache = (mod as { __test_clearAgentNameCache?: () => void }).__test_clearAgentNameCache ?? (() => undefined);
});
beforeEach(async () => {
clearAgentCache?.();
const { fetchAgent } = await import("../../api");
vi.mocked(fetchAgent).mockReset();
});
it("renders agent badge when task has assignedAgentId", async () => {
const { fetchAgent } = await import("../../api");
vi.mocked(fetchAgent).mockResolvedValue({
id: "agent-001",
name: "Autopilot Agent",
role: "executor",
state: "active",
metadata: {},
heartbeatHistory: [],
completedRuns: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as any);
const task = createTask({ assignedAgentId: "agent-001" });
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
const badge = screen.getByTitle("Assigned to Autopilot Agent");
expect(badge).toBeInTheDocument();
expect(badge).toHaveClass("card-agent-badge");
expect(screen.getByTestId("bot-icon")).toBeInTheDocument();
});
});
it("does not render agent badge when assignedAgentId is undefined", () => {
const task = createTask();
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} />);
expect(screen.queryByTitle(/Assigned to/)).not.toBeInTheDocument();
expect(screen.queryByTestId("bot-icon")).not.toBeInTheDocument();
});
});

View File

@@ -17,6 +17,9 @@ vi.mock("../../api", () => ({
duplicateTask: vi.fn().mockResolvedValue({}),
refineTask: vi.fn().mockResolvedValue({}),
addSteeringComment: vi.fn(),
assignTask: vi.fn().mockResolvedValue({}),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchAgent: vi.fn(),
// TaskForm dependencies
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
@@ -42,6 +45,7 @@ vi.mock("lucide-react", () => ({
ChevronUp: () => null,
ChevronDown: () => null,
X: () => null,
Bot: () => null,
CircleDot: () => null,
XCircle: () => null,
GitMerge: () => null,
@@ -4339,4 +4343,123 @@ describe("TaskDetailModal", () => {
});
});
});
describe("agent assignment", () => {
it("shows Assign Agent button when task has no assigned agent", () => {
render(
<TaskDetailModal
task={makeTask({ assignedAgentId: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByRole("button", { name: "Assign Agent" })).toBeInTheDocument();
});
it("shows assigned agent chip and clear button when task has assignedAgentId", async () => {
const { fetchAgent } = await import("../../api");
vi.mocked(fetchAgent).mockResolvedValue({
id: "agent-002",
name: "Pipeline Helper",
role: "executor",
state: "active",
metadata: {},
heartbeatHistory: [],
completedRuns: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as any);
render(
<TaskDetailModal
task={makeTask({ assignedAgentId: "agent-002" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => {
expect(screen.getByText("Pipeline Helper")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Unassign agent" })).toBeInTheDocument();
});
});
it("assigns selected agent via assignTask", async () => {
const { fetchAgents, assignTask } = await import("../../api");
vi.mocked(fetchAgents).mockResolvedValue([
{
id: "agent-001",
name: "Task Runner",
role: "executor",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any);
vi.mocked(assignTask).mockResolvedValue(makeTask({ assignedAgentId: "agent-001" }) as any);
render(
<TaskDetailModal
task={makeTask({ assignedAgentId: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Assign Agent" }));
await userEvent.click(screen.getByRole("button", { name: /Task Runner/i }));
await waitFor(() => {
expect(assignTask).toHaveBeenCalledWith("FN-099", "agent-001", undefined);
});
});
it("clears assigned agent via assignTask(null)", async () => {
const { fetchAgent, assignTask } = await import("../../api");
vi.mocked(fetchAgent).mockResolvedValue({
id: "agent-005",
name: "Doc Bot",
role: "executor",
state: "active",
metadata: {},
heartbeatHistory: [],
completedRuns: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as any);
vi.mocked(assignTask).mockResolvedValue(makeTask({ assignedAgentId: undefined }) as any);
render(
<TaskDetailModal
task={makeTask({ assignedAgentId: "agent-005" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Unassign agent" }));
await waitFor(() => {
expect(assignTask).toHaveBeenCalledWith("FN-099", null, undefined);
});
});
});
});

View File

@@ -1366,6 +1366,20 @@ body {
background: rgba(167, 139, 250, 0.22);
}
.card-agent-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
font-weight: 600;
font-family: var(--font-mono);
color: var(--text-muted);
background: var(--card-agent-badge-bg, rgba(139, 148, 158, 0.15));
border: 1px solid var(--card-agent-badge-border, rgba(139, 148, 158, 0.28));
padding: 2px 6px;
border-radius: 9999px;
}
.card-progress {
display: flex;
align-items: center;
@@ -4127,6 +4141,116 @@ body {
border-radius: 2px;
}
.detail-agent-section {
margin-top: var(--space-lg);
}
.detail-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.detail-meta-label {
display: inline-flex;
align-items: center;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.detail-agent-actions {
position: relative;
display: inline-flex;
align-items: center;
}
.detail-agent-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 9999px;
background: var(--surface);
color: var(--text);
font-size: 12px;
}
.detail-agent-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-dim);
cursor: pointer;
}
.detail-agent-clear:hover {
background: var(--card-hover);
color: var(--text);
}
.detail-agent-actions > .agent-picker-dropdown {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
min-width: 240px;
max-width: 320px;
max-height: 240px;
overflow-y: auto;
z-index: 220;
box-shadow: var(--shadow-lg);
}
.agent-picker-loading,
.agent-picker-empty {
padding: 10px 12px;
font-size: 12px;
color: var(--text-dim);
}
.agent-picker-item {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 6px 12px;
font-size: 12px;
color: var(--text);
text-align: left;
background: transparent;
border: none;
cursor: pointer;
}
.agent-picker-item:hover,
.agent-picker-item.selected {
background: var(--card-hover);
}
.agent-picker-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.agent-picker-role {
font-family: var(--font-mono);
color: var(--text-muted);
flex-shrink: 0;
}
/* === Step Progress === */
.detail-step-progress {
margin-top: var(--space-lg);
@@ -4824,6 +4948,10 @@ body {
position: relative;
}
.agent-trigger-wrap {
position: relative;
}
.inline-create-model-wrap {
position: relative;
}
@@ -4850,6 +4978,20 @@ body {
box-shadow: var(--shadow-lg);
}
.dep-dropdown-search-header {
position: sticky;
top: 0;
padding: 6px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--text-muted);
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 1;
}
.dep-dropdown-search {
position: sticky;
top: 0;
@@ -20815,6 +20957,112 @@ html .column.drag-over * {
color: var(--text-muted);
}
/* --- Tasks Tab --- */
.agent-tasks-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.agent-task-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-secondary);
text-decoration: none;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.agent-task-item:hover {
background: var(--card-hover);
border-color: var(--text-dim);
}
.agent-task-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.agent-task-id {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--todo);
}
.agent-task-title {
font-size: 14px;
font-weight: 500;
color: var(--text);
line-height: 1.4;
}
.agent-task-status {
font-size: 12px;
color: var(--text-muted);
}
.agent-task-column {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 9999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.agent-task-column.column-triage {
color: var(--triage, #1f6feb);
background: color-mix(in srgb, var(--triage, #1f6feb) 18%, transparent);
}
.agent-task-column.column-todo {
color: var(--todo, #58a6ff);
background: color-mix(in srgb, var(--todo, #58a6ff) 18%, transparent);
}
.agent-task-column.column-in-progress {
color: var(--in-progress, #bc8cff);
background: color-mix(in srgb, var(--in-progress, #bc8cff) 18%, transparent);
}
.agent-task-column.column-in-review {
color: var(--in-review, #f2cc60);
background: color-mix(in srgb, var(--in-review, #f2cc60) 18%, transparent);
}
.agent-task-column.column-done {
color: var(--done, #3fb950);
background: color-mix(in srgb, var(--done, #3fb950) 18%, transparent);
}
.agent-task-column.column-archived {
color: var(--text-muted);
background: color-mix(in srgb, var(--text-muted) 18%, transparent);
}
.agent-tasks-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: 48px;
color: var(--text-muted);
text-align: center;
}
.agent-tasks-empty p {
margin: 0;
}
/* --- Config Tab --- */
.config-tab {
display: flex;