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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -751,6 +751,7 @@ export function ListView({
|
||||
availableModels={availableModels}
|
||||
onPlanningMode={onPlanningMode}
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
projectId={projectId}
|
||||
autoExpand={false}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user