- Add task `source` provenance field tracking how tasks enter the system (e.g. `"manual"`, `"heartbeat"`, `"executor"`, `"triage"`, `"cron"`, `"routine"`) - Wire `source` into shared create-task tools, executor agent, triage agent, cron runner, and routine runner - Add `source` to heartbeat task creation in `agent-heartbeat.ts` and mission execution loop in `mission-execution-loop.ts` - Wire `source` field into dashboard routes (`register-planning-subtask-routes.ts`, `register-task-workflow-routes.ts`) and GitHub integration (`register-git-github.ts`) - Add `source` to the API layer and task store handlers in the dashboard - Expose `source` in the `fn` CLI task command and `task-plan` extension tool - Add integration tests for provenance across engine (agent-tools, agent-heartbeat, cron-runner, triage, pr-comment-handler) and dashboard routes - Update existing tests to assert the correct source values - Bulk-revert stale changeset files that were superseded by the v0.9.1 release commit Commits merged: - test(FN-2923): complete Step 14 — align tests with task source provenance - test(FN-2923): complete Steps 12-13 — update and add provenance tests - feat(FN-2923): complete Step 10 — wire CLI task provenance - feat(FN-2923): complete Step 9 — wire dashboard UI and API source fields - feat(FN-2923): complete Step 8 — wire dashboard route provenance - feat(FN-2923): complete Step 7 — wire remaining engine provenance - feat(FN-2923): complete Step 6 — add routine create-task provenance - feat(FN-2923): complete Step 5 — add cron create-task provenance - feat(FN-2923): complete Step 4 — add triage subtask source - feat(FN-2923): complete Step 3 — add executor create-task source - feat(FN-2923): complete Step 2 — add heartbeat task provenance - feat(FN-2923): complete Step 1 — add provenance to shared create-task tools - feat(FN-2945): merge fusion/fn-2945 - chore(release): v0.9.1 Files changed: .changeset/active-agents-no-stuck-connecting.md | 13 --- .changeset/active-agents-panel-hoist-heartbeat.md | 13 --- .changeset/fix-agent-heartbeat-terminal-links.md | 5 -- .changeset/fix-experimental-defaults.md | 5 -- .changeset/improve-git-manager-diff-pane.md | 5 -- .changeset/insights-two-pane-layout.md | 5 -- .changeset/show-planning-tasks-immediately.md | 5 -- CHANGELOG.md | 92 ++++++++++++++++++++++ package.json | 2 +- packages/cli-alias/CHANGELOG.md | 15 ++++ packages/cli-alias/package.json | 2 +- packages/cli/CHANGELOG.md | 12 +++ packages/cli/package.json | 2 +- packages/cli/src/__tests__/task-plan.test.ts | 1 + packages/cli/src/commands/__tests__/task.test.ts | 15 +++- packages/cli/src/commands/task.ts | 15 +++- packages/cli/src/extension.ts | 9 +++ packages/core/CHANGELOG.md | 7 ++ packages/core/package.json | 2 +- packages/dashboard/CHANGELOG.md | 14 ++++ .../app/__tests__/agent-css-classes.test.ts | 3 +- packages/dashboard/app/__tests__/api.test.ts | 13 +++ packages/dashboard/app/api/legacy.ts | 2 + packages/dashboard/app/components/AgentsView.css | 22 +++--- .../app/components/ModelOnboardingModal.tsx | 5 +- packages/dashboard/app/components/TodoView.tsx | 2 + .../__tests__/ModelOnboardingModal.test.tsx | 4 +- .../app/components/__tests__/QuickChatFAB.test.tsx | 14 +--- .../app/components/__tests__/TodoView.test.tsx | 4 +- .../__tests__/agents-view-mobile.test.tsx | 5 +- .../components/__tests__/onboarding-flow.test.tsx | 2 +- .../app/hooks/__tests__/useTaskHandlers.test.ts | 4 +- packages/dashboard/app/hooks/useTaskHandlers.ts | 4 +- packages/dashboard/package.json | 2 +- packages/dashboard/src/__tests__/routes.test.ts | 14 ++++ packages/dashboard/src/routes.ts | 4 + .../dashboard/src/routes/register-git-github.ts | 12 +++ .../src/routes/register-planning-subtask-routes.ts | 3 + .../src/routes/register-task-workflow-routes.ts | 7 ++ packages/desktop/CHANGELOG.md | 7 ++ packages/desktop/package.json | 2 +- packages/engine/CHANGELOG.md | 11 +++ packages/engine/package.json | 2 +- .../engine/src/__tests__/agent-heartbeat.test.ts | 10 +++ .../src/__tests__/agent-tools-delegation.test.ts | 2 + packages/engine/src/__tests__/agent-tools.test.ts | 37 +++++++++ packages/engine/src/__tests__/cron-runner.test.ts | 4 + .../src/__tests__/pr-comment-handler.test.ts | 8 ++ packages/engine/src/__tests__/triage.test.ts | 2 + packages/engine/src/agent-heartbeat.ts | 13 ++- packages/engine/src/agent-tools.ts | 13 ++- packages/engine/src/cron-runner.ts | 7 +- packages/engine/src/executor.ts | 2 +- packages/engine/src/mission-execution-loop.ts | 8 ++ packages/engine/src/pr-comment-handler.ts | 5 ++ packages/engine/src/project-engine.ts | 8 ++ packages/engine/src/routine-runner.ts | 4 + packages/engine/src/triage.ts | 4 + packages/mobile/CHANGELOG.md | 7 ++ packages/mobile/package.json | 2 +- packages/pi-claude-cli/CHANGELOG.md | 7 ++ packages/pi-claude-cli/package.json | 2 +- packages/plugin-sdk/CHANGELOG.md | 10 +++ packages/plugin-sdk/package.json | 2 +- .../examples/fusion-plugin-auto-label/CHANGELOG.md | 8 ++ .../examples/fusion-plugin-auto-label/package.json | 2 +- .../examples/fusion-plugin-ci-status/CHANGELOG.md | 8 ++ .../examples/fusion-plugin-ci-status/package.json | 2 +- .../fusion-plugin-notification/CHANGELOG.md | 8 ++ .../fusion-plugin-notification/package.json | 2 +- .../fusion-plugin-settings-demo/CHANGELOG.md | 8 ++ .../fusion-plugin-settings-demo/package.json | 2 +- plugins/fusion-plugin-hermes-runtime/CHANGELOG.md | 8 ++ plugins/fusion-plugin-hermes-runtime/package.json | 2 +- .../fusion-plugin-openclaw-runtime/CHANGELOG.md | 8 ++ .../fusion-plugin-openclaw-runtime/package.json | 2 +- .../fusion-plugin-paperclip-runtime/CHANGELOG.md | 8 ++ .../fusion-plugin-paperclip-runtime/package.json | 2 +- 78 files changed, 499 insertions(+), 115 deletions(-) Fusion-Task-Id: FN-2923
722 lines
26 KiB
TypeScript
722 lines
26 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Plus,
|
|
Trash2,
|
|
Pencil,
|
|
Check,
|
|
X,
|
|
ChevronUp,
|
|
ChevronDown,
|
|
Loader2,
|
|
ListChecks,
|
|
Bot,
|
|
PlusCircle,
|
|
} from "lucide-react";
|
|
import { getErrorMessage, type Task, type TaskCreateInput, type TodoItem, type TodoList } from "@fusion/core";
|
|
import { createTask, fetchAgents } from "../api";
|
|
import type { Agent } from "../api";
|
|
import { useTodoLists } from "../hooks/useTodoLists";
|
|
import { useConfirm } from "../hooks/useConfirm";
|
|
import "./TodoView.css";
|
|
|
|
interface TodoViewProps {
|
|
projectId?: string;
|
|
addToast: (message: string, type?: "success" | "error" | "info") => void;
|
|
}
|
|
|
|
function sortItems(items: TodoItem[]): TodoItem[] {
|
|
return [...items].sort((a, b) => a.sortOrder - b.sortOrder);
|
|
}
|
|
|
|
export function TodoView({ projectId, addToast }: TodoViewProps) {
|
|
const {
|
|
lists,
|
|
items,
|
|
loading,
|
|
error,
|
|
selectedListId,
|
|
setSelectedListId,
|
|
createList,
|
|
renameList,
|
|
deleteList,
|
|
createItem,
|
|
updateItem,
|
|
toggleItem,
|
|
deleteItem,
|
|
reorderItems,
|
|
} = useTodoLists({
|
|
projectId,
|
|
addToast: (message, type) => {
|
|
if (type === "success" || type === "error" || type === "info" || type === undefined) {
|
|
addToast(message, type);
|
|
return;
|
|
}
|
|
addToast(message, "info");
|
|
},
|
|
});
|
|
|
|
const [editingListId, setEditingListId] = useState<string | null>(null);
|
|
const [editingListTitle, setEditingListTitle] = useState("");
|
|
const [editingItemId, setEditingItemId] = useState<string | null>(null);
|
|
const [editingItemText, setEditingItemText] = useState("");
|
|
const [newListTitle, setNewListTitle] = useState("");
|
|
const [isAddingList, setIsAddingList] = useState(false);
|
|
const [newItemText, setNewItemText] = useState("");
|
|
const [agents, setAgents] = useState<Agent[]>([]);
|
|
const [agentsLoading, setAgentsLoading] = useState(false);
|
|
const [showAgentPicker, setShowAgentPicker] = useState(false);
|
|
const [activeItemForAgent, setActiveItemForAgent] = useState<string | null>(null);
|
|
const agentPickerRef = useRef<HTMLDivElement>(null);
|
|
const { confirm } = useConfirm();
|
|
|
|
const selectedList = useMemo(
|
|
() => lists.find((list) => list.id === selectedListId) ?? null,
|
|
[lists, selectedListId],
|
|
);
|
|
const sortedItems = useMemo(
|
|
() => sortItems(items.filter((item) => item.listId === selectedListId)),
|
|
[items, selectedListId],
|
|
);
|
|
|
|
function resetListDraftState(): void {
|
|
setEditingListId(null);
|
|
setEditingListTitle("");
|
|
setNewListTitle("");
|
|
setIsAddingList(false);
|
|
}
|
|
|
|
function resetItemDraftState(): void {
|
|
setEditingItemId(null);
|
|
setEditingItemText("");
|
|
setNewItemText("");
|
|
}
|
|
|
|
function handleSelectList(listId: string): void {
|
|
resetListDraftState();
|
|
resetItemDraftState();
|
|
setSelectedListId(listId);
|
|
}
|
|
|
|
const loadAgents = useCallback(async () => {
|
|
setAgentsLoading(true);
|
|
try {
|
|
const loadedAgents = await fetchAgents(undefined, projectId);
|
|
setAgents(loadedAgents);
|
|
setShowAgentPicker(true);
|
|
} catch (err) {
|
|
addToast(`Failed to load agents: ${getErrorMessage(err)}`, "error");
|
|
setShowAgentPicker(false);
|
|
setActiveItemForAgent(null);
|
|
} finally {
|
|
setAgentsLoading(false);
|
|
}
|
|
}, [projectId, addToast]);
|
|
|
|
useEffect(() => {
|
|
setEditingListId(null);
|
|
setEditingListTitle("");
|
|
setNewListTitle("");
|
|
setIsAddingList(false);
|
|
setEditingItemId(null);
|
|
setEditingItemText("");
|
|
setNewItemText("");
|
|
setShowAgentPicker(false);
|
|
setActiveItemForAgent(null);
|
|
}, [selectedListId]);
|
|
|
|
useEffect(() => {
|
|
if (!showAgentPicker) {
|
|
return;
|
|
}
|
|
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (agentPickerRef.current && !agentPickerRef.current.contains(event.target as Node)) {
|
|
setShowAgentPicker(false);
|
|
setActiveItemForAgent(null);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
};
|
|
}, [showAgentPicker]);
|
|
|
|
function handleStartRenameList(list: TodoList): void {
|
|
resetItemDraftState();
|
|
setEditingListId(list.id);
|
|
setEditingListTitle(list.title);
|
|
setIsAddingList(false);
|
|
}
|
|
|
|
async function handleSaveRenameList(): Promise<void> {
|
|
if (!editingListId) {
|
|
return;
|
|
}
|
|
|
|
const trimmedTitle = editingListTitle.trim();
|
|
if (!trimmedTitle) {
|
|
setEditingListId(null);
|
|
setEditingListTitle("");
|
|
return;
|
|
}
|
|
|
|
await renameList(editingListId, trimmedTitle);
|
|
setEditingListId(null);
|
|
setEditingListTitle("");
|
|
}
|
|
|
|
function handleCancelRenameList(): void {
|
|
setEditingListId(null);
|
|
setEditingListTitle("");
|
|
}
|
|
|
|
function handleStartEditItem(item: TodoItem): void {
|
|
resetListDraftState();
|
|
setEditingItemId(item.id);
|
|
setEditingItemText(item.text);
|
|
}
|
|
|
|
async function handleSaveEditItem(): Promise<void> {
|
|
if (!editingItemId) {
|
|
return;
|
|
}
|
|
|
|
const trimmedText = editingItemText.trim();
|
|
if (!trimmedText) {
|
|
setEditingItemId(null);
|
|
setEditingItemText("");
|
|
return;
|
|
}
|
|
|
|
await updateItem(editingItemId, { text: trimmedText });
|
|
setEditingItemId(null);
|
|
setEditingItemText("");
|
|
}
|
|
|
|
function handleCancelEditItem(): void {
|
|
setEditingItemId(null);
|
|
setEditingItemText("");
|
|
}
|
|
|
|
async function handleAddList(): Promise<void> {
|
|
const trimmedTitle = newListTitle.trim();
|
|
if (!trimmedTitle) {
|
|
return;
|
|
}
|
|
|
|
await createList(trimmedTitle);
|
|
setNewListTitle("");
|
|
setIsAddingList(false);
|
|
}
|
|
|
|
async function handleAddItem(): Promise<void> {
|
|
if (!selectedListId) {
|
|
return;
|
|
}
|
|
|
|
const trimmedText = newItemText.trim();
|
|
if (!trimmedText) {
|
|
return;
|
|
}
|
|
|
|
await createItem(trimmedText);
|
|
setNewItemText("");
|
|
}
|
|
|
|
async function handleDeleteList(id: string): Promise<void> {
|
|
const shouldDelete = await confirm({
|
|
title: "Delete List",
|
|
message: "Delete this list and all its items?",
|
|
danger: true,
|
|
});
|
|
if (!shouldDelete) {
|
|
return;
|
|
}
|
|
await deleteList(id);
|
|
}
|
|
|
|
async function handleDeleteItem(id: string): Promise<void> {
|
|
await deleteItem(id);
|
|
}
|
|
|
|
async function handleMoveItem(itemId: string, direction: "up" | "down"): Promise<void> {
|
|
const ids = sortedItems.map((item) => item.id);
|
|
const index = ids.findIndex((id) => id === itemId);
|
|
if (index < 0) {
|
|
return;
|
|
}
|
|
|
|
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
|
if (targetIndex < 0 || targetIndex >= ids.length) {
|
|
return;
|
|
}
|
|
|
|
[ids[index], ids[targetIndex]] = [ids[targetIndex], ids[index]];
|
|
await reorderItems(ids);
|
|
}
|
|
|
|
const handleCreateTaskFromItem = useCallback(async (item: TodoItem) => {
|
|
try {
|
|
const input: TaskCreateInput = {
|
|
description: item.text,
|
|
column: "triage",
|
|
source: { sourceType: "dashboard_ui" },
|
|
};
|
|
const task: Task = await createTask(input, projectId);
|
|
addToast(`Created ${task.id} from todo`, "success");
|
|
} catch (err) {
|
|
addToast(`Failed to create task: ${getErrorMessage(err)}`, "error");
|
|
}
|
|
}, [projectId, addToast]);
|
|
|
|
const handleCreateTaskAndAssign = useCallback(async (item: TodoItem, agentId: string) => {
|
|
try {
|
|
const input: TaskCreateInput = {
|
|
description: item.text,
|
|
column: "triage",
|
|
assignedAgentId: agentId,
|
|
source: { sourceType: "dashboard_ui" },
|
|
};
|
|
const task: Task = await createTask(input, projectId);
|
|
const assignedAgent = agents.find((agent) => agent.id === agentId);
|
|
const agentLabel = assignedAgent?.name ?? agentId;
|
|
addToast(`Created ${task.id} and assigned to ${agentLabel}`, "success");
|
|
setShowAgentPicker(false);
|
|
setActiveItemForAgent(null);
|
|
} catch (err) {
|
|
addToast(`Failed to create and assign task: ${getErrorMessage(err)}`, "error");
|
|
}
|
|
}, [projectId, addToast, agents]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="todo-view">
|
|
<div className="todo-loading">
|
|
<Loader2 className="todo-loading-icon" aria-hidden="true" />
|
|
<p>Loading todos...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="todo-view">
|
|
<div className="todo-view-header">
|
|
<div>
|
|
<h2>Todos</h2>
|
|
<p className="todo-view-description">Manage reusable todo lists for your project.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="todo-view-layout">
|
|
<aside className="todo-view-sidebar" aria-label="Todo lists sidebar">
|
|
<div className="todo-sidebar-header">
|
|
<h3 className="todo-sidebar-title">Lists</h3>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon todo-add-list-btn"
|
|
onClick={() => {
|
|
resetItemDraftState();
|
|
setIsAddingList(true);
|
|
setEditingListId(null);
|
|
}}
|
|
aria-label="Add list"
|
|
data-testid="add-list-button"
|
|
>
|
|
<Plus size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
{isAddingList && (
|
|
<div className="todo-list-item">
|
|
<input
|
|
className="input todo-inline-edit-input"
|
|
placeholder="New list title"
|
|
value={newListTitle}
|
|
onChange={(event) => setNewListTitle(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
void handleAddList();
|
|
}
|
|
if (event.key === "Escape") {
|
|
setNewListTitle("");
|
|
setIsAddingList(false);
|
|
}
|
|
}}
|
|
autoFocus
|
|
data-testid="new-list-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon todo-icon-btn"
|
|
onClick={() => {
|
|
void handleAddList();
|
|
}}
|
|
aria-label="Save list"
|
|
>
|
|
<Check size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon todo-icon-btn"
|
|
onClick={() => {
|
|
setNewListTitle("");
|
|
setIsAddingList(false);
|
|
}}
|
|
aria-label="Cancel list"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{lists.length === 0 && !isAddingList ? (
|
|
<div className="todo-empty-state">
|
|
<ListChecks aria-hidden="true" />
|
|
<p>No todo lists yet. Create one to get started.</p>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={() => {
|
|
resetItemDraftState();
|
|
setIsAddingList(true);
|
|
}}
|
|
>
|
|
Create List
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="todo-list-items" role="list" aria-label="Todo lists">
|
|
{lists.map((list) => {
|
|
const isActive = list.id === selectedListId;
|
|
const isEditing = list.id === editingListId;
|
|
|
|
return (
|
|
<div
|
|
key={list.id}
|
|
className={`todo-list-item${isActive ? " todo-list-item--active" : ""}`}
|
|
role="listitem"
|
|
>
|
|
{isEditing ? (
|
|
<>
|
|
<input
|
|
className="input todo-inline-edit-input"
|
|
value={editingListTitle}
|
|
onChange={(event) => setEditingListTitle(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
void handleSaveRenameList();
|
|
}
|
|
if (event.key === "Escape") {
|
|
handleCancelRenameList();
|
|
}
|
|
}}
|
|
autoFocus
|
|
data-testid={`rename-list-input-${list.id}`}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon todo-icon-btn"
|
|
onClick={() => {
|
|
void handleSaveRenameList();
|
|
}}
|
|
aria-label="Save list rename"
|
|
>
|
|
<Check size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon todo-icon-btn"
|
|
onClick={handleCancelRenameList}
|
|
aria-label="Cancel list rename"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="todo-list-select-btn"
|
|
onClick={() => handleSelectList(list.id)}
|
|
aria-label={`Select list ${list.title}`}
|
|
aria-current={isActive ? "true" : undefined}
|
|
data-testid={`todo-list-${list.id}`}
|
|
>
|
|
<span className="todo-list-item-name">{list.title}</span>
|
|
</button>
|
|
<div className="todo-list-item-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon todo-icon-btn"
|
|
onClick={() => {
|
|
handleStartRenameList(list);
|
|
}}
|
|
aria-label={`Rename ${list.title}`}
|
|
data-testid={`rename-list-button-${list.id}`}
|
|
>
|
|
<Pencil size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon btn-danger todo-icon-btn"
|
|
onClick={() => {
|
|
void handleDeleteList(list.id);
|
|
}}
|
|
aria-label={`Delete ${list.title}`}
|
|
data-testid={`delete-list-button-${list.id}`}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</aside>
|
|
|
|
<section className="todo-view-main" aria-label="Todo items">
|
|
{error && (
|
|
<div className="todo-error-banner" role="alert">
|
|
<span className="todo-error-message">{error}</span>
|
|
<button type="button" className="btn btn-sm" onClick={() => window.location.reload()}>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!selectedList ? (
|
|
<div className="todo-empty-state">
|
|
<ListChecks aria-hidden="true" />
|
|
<p>Select a list from the sidebar</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="todo-items-header">
|
|
<h3>{selectedList.title}</h3>
|
|
</div>
|
|
|
|
<div className="todo-add-item-row">
|
|
<input
|
|
className="input"
|
|
placeholder="Add a todo item"
|
|
value={newItemText}
|
|
onChange={(event) => setNewItemText(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
void handleAddItem();
|
|
}
|
|
if (event.key === "Escape") {
|
|
setNewItemText("");
|
|
}
|
|
}}
|
|
data-testid="new-item-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={() => {
|
|
void handleAddItem();
|
|
}}
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
|
|
{sortedItems.length === 0 ? (
|
|
<div className="todo-empty-state">
|
|
<p>No items in this list. Add one above.</p>
|
|
</div>
|
|
) : (
|
|
<div className="todo-items-list">
|
|
{sortedItems.map((item, index) => {
|
|
const isEditing = item.id === editingItemId;
|
|
|
|
return (
|
|
<div className="todo-item" key={item.id} data-testid={`todo-item-${item.id}`}>
|
|
<input
|
|
type="checkbox"
|
|
checked={item.completed}
|
|
onChange={() => {
|
|
void toggleItem(item.id);
|
|
}}
|
|
className="todo-item-checkbox"
|
|
aria-label={`Toggle ${item.text}`}
|
|
data-testid={`toggle-item-${item.id}`}
|
|
/>
|
|
|
|
{isEditing ? (
|
|
<input
|
|
className="input todo-inline-edit-input"
|
|
value={editingItemText}
|
|
onChange={(event) => setEditingItemText(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
void handleSaveEditItem();
|
|
}
|
|
if (event.key === "Escape") {
|
|
handleCancelEditItem();
|
|
}
|
|
}}
|
|
autoFocus
|
|
data-testid={`edit-item-input-${item.id}`}
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className={`todo-item-text${item.completed ? " todo-item-text--completed" : ""}`}
|
|
onClick={() => handleStartEditItem(item)}
|
|
>
|
|
{item.text}
|
|
</button>
|
|
)}
|
|
|
|
<div className="todo-item-actions">
|
|
{isEditing ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon todo-icon-btn"
|
|
onClick={() => {
|
|
void handleSaveEditItem();
|
|
}}
|
|
aria-label="Save item edit"
|
|
>
|
|
<Check size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon todo-icon-btn"
|
|
onClick={handleCancelEditItem}
|
|
aria-label="Cancel item edit"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="todo-item-reorder-btns">
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon todo-item-reorder-btn"
|
|
onClick={() => {
|
|
void handleMoveItem(item.id, "up");
|
|
}}
|
|
disabled={index === 0}
|
|
aria-label={`Move ${item.text} up`}
|
|
data-testid={`move-up-${item.id}`}
|
|
>
|
|
<ChevronUp size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon todo-item-reorder-btn"
|
|
onClick={() => {
|
|
void handleMoveItem(item.id, "down");
|
|
}}
|
|
disabled={index === sortedItems.length - 1}
|
|
aria-label={`Move ${item.text} down`}
|
|
data-testid={`move-down-${item.id}`}
|
|
>
|
|
<ChevronDown size={14} />
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon todo-icon-btn"
|
|
onClick={() => {
|
|
void handleCreateTaskFromItem(item);
|
|
}}
|
|
aria-label={`Create task from ${item.text}`}
|
|
data-testid={`create-task-from-${item.id}`}
|
|
>
|
|
<PlusCircle size={14} />
|
|
</button>
|
|
<div
|
|
className="todo-agent-picker-trigger"
|
|
ref={activeItemForAgent === item.id ? agentPickerRef : undefined}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon todo-icon-btn"
|
|
onClick={() => {
|
|
setActiveItemForAgent(item.id);
|
|
void loadAgents();
|
|
}}
|
|
aria-label={`Assign ${item.text} to agent`}
|
|
data-testid={`assign-agent-for-${item.id}`}
|
|
>
|
|
<Bot size={14} />
|
|
</button>
|
|
{showAgentPicker && activeItemForAgent === item.id && (
|
|
<div
|
|
className="todo-agent-picker-dropdown"
|
|
onMouseDown={(event) => {
|
|
event.preventDefault();
|
|
}}
|
|
>
|
|
{agentsLoading ? (
|
|
<div className="todo-agent-picker-loading">Loading agents...</div>
|
|
) : agents.filter((agent) => agent.state !== "terminated").length > 0 ? (
|
|
agents
|
|
.filter((agent) => agent.state !== "terminated")
|
|
.map((agent) => (
|
|
<button
|
|
type="button"
|
|
key={agent.id}
|
|
className="todo-agent-picker-item"
|
|
onClick={() => {
|
|
void handleCreateTaskAndAssign(item, agent.id);
|
|
}}
|
|
>
|
|
<Bot size={14} />
|
|
<span>{agent.name}</span>
|
|
<span className="todo-agent-picker-role">{agent.role}</span>
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="todo-agent-picker-empty">No agents available</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon todo-icon-btn"
|
|
onClick={() => handleStartEditItem(item)}
|
|
aria-label={`Edit ${item.text}`}
|
|
data-testid={`edit-item-${item.id}`}
|
|
>
|
|
<Pencil size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-icon btn-danger todo-icon-btn"
|
|
onClick={() => {
|
|
void handleDeleteItem(item.id);
|
|
}}
|
|
aria-label={`Delete ${item.text}`}
|
|
data-testid={`delete-item-${item.id}`}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|