Files
fusion/packages/dashboard/app/components/TodoView.tsx
gsxdsm bb86844f8d FN-7869: add hide done toggle to Todo list view
Adds a per-project Hide done / Show done toggle to the Todo list items header so operators can declutter long selected lists while completion counts still reflect all items.
- Add hideDone state persisted per project via localStorage (kb-dashboard-todo-hide-done key registered in projectStorage)
- Filter rendered todo items to hide completed ones when the toggle is active, while keeping list stats/progress counts based on all items
- Adjust up/down item reordering to operate correctly against the visible (filtered) list while still reordering the underlying full item list
- Add an empty-state message when all items are hidden by the toggle, with Eye/EyeOff icon + i18n strings (todo.hideDone, todo.showDone, todo.allDoneHidden)
- Add regression tests covering the toggle, persistence, filtering, and empty state
- Document the Hide done / Show done control in the dashboard guide

Files changed:
 docs/dashboard-guide.md                            |  3 +
 packages/dashboard/app/components/TodoView.css     | 45 ++++++++++
 packages/dashboard/app/components/TodoView.tsx     | 66 +++++++++++++--
 .../app/components/__tests__/TodoView.test.tsx     | 99 ++++++++++++++++++++++
 packages/dashboard/app/utils/projectStorage.ts     |  1 +
 packages/i18n/locales/en/app.json                  |  3 +
 6 files changed, 210 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-7869

Fusion-Task-Lineage: 9c334aba-e802-43b8-963c-0f2daf727583

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-12 12:52:30 -07:00

858 lines
34 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Plus,
Trash2,
Pencil,
Check,
X,
ChevronUp,
ChevronDown,
ChevronLeft,
Loader2,
ListChecks,
Bot,
PlusCircle,
Lightbulb,
Eye,
EyeOff,
} 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 { LoadingSpinner } from "./LoadingSpinner";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import "./TodoView.css";
interface TodoViewProps {
projectId?: string;
addToast: (message: string, type?: "success" | "error" | "info") => void;
onPlanningMode?: (initialPlan: string) => void;
onTaskCreated?: (task: Task) => void;
}
function sortItems(items: TodoItem[]): TodoItem[] {
return [...items].sort((a, b) => a.sortOrder - b.sortOrder);
}
function readHideDoneTodos(projectId?: string): boolean {
try {
const saved = getScopedItem("kb-dashboard-todo-hide-done", projectId);
if (saved !== null) {
return saved === "true";
}
} catch {
// Invalid localStorage data - fall through to default
}
return false;
}
export function TodoView({
projectId,
addToast,
onPlanningMode,
onTaskCreated,
}: TodoViewProps) {
const { t } = useTranslation("app");
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 [hideDone, setHideDone] = useState<boolean>(() => readHideDoneTodos(projectId));
const agentPickerRef = useRef<HTMLDivElement>(null);
const { confirm } = useConfirm();
/*
FNXC:Todos 2026-06-22-00:00:
TodoView mounts in the narrow right dock (no width prop) where the two side-by-side panels (list selection + items) cannot fit. The layout switch is driven by a CSS container query on `.todo-view` (container-name: todo-view), NOT a prop. In the NARROW container we render a single-panel navigation stack: the master list-selection panel first, and selecting a list navigates forward to its items panel with a Back affordance. `mobileStackView` tracks which panel the narrow stack shows; the WIDE two-panel layout ignores it entirely (both panels always render). Selecting a list pushes to "detail"; Back returns to "list".
*/
const [mobileStackView, setMobileStackView] = useState<"list" | "detail">("list");
const selectedList = useMemo(
() => lists.find((list) => list.id === selectedListId) ?? null,
[lists, selectedListId],
);
const sortedItems = useMemo(
() => sortItems(items.filter((item) => item.listId === selectedListId)),
[items, selectedListId],
);
/*
FNXC:Todos 2026-07-12-00:00:
Operators need a per-project Hide done toggle for TodoView so long lists can focus on outstanding work. The rendered list filters completed items only when enabled, but progress counts intentionally continue to reflect every item in the selected list so completion status remains truthful while completed rows are hidden.
*/
const visibleItems = useMemo(
() => hideDone ? sortedItems.filter((item) => !item.completed) : sortedItems,
[hideDone, sortedItems],
);
const listItemStats = useMemo(() => {
const stats = new Map<string, { total: number; completed: number }>();
for (const list of lists) {
stats.set(list.id, { total: 0, completed: 0 });
}
for (const item of items) {
const current = stats.get(item.listId) ?? { total: 0, completed: 0 };
current.total += 1;
if (item.completed) {
current.completed += 1;
}
stats.set(item.listId, current);
}
return stats;
}, [items, lists]);
const selectedListStats = selectedList ? (listItemStats.get(selectedList.id) ?? { total: sortedItems.length, completed: sortedItems.filter((item) => item.completed).length }) : null;
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);
// FNXC:Todos 2026-06-22-00:00: Narrow stack navigates forward to the items panel on selection; no-op visually in the wide two-panel layout.
setMobileStackView("detail");
}
// FNXC:Todos 2026-06-22-00:00: Narrow-stack Back affordance returns to the master list-selection panel. Inert in the wide layout where both panels are always visible.
function handleMobileBack(): void {
setMobileStackView("list");
}
const loadAgents = useCallback(async () => {
setAgentsLoading(true);
try {
const loadedAgents = await fetchAgents(undefined, projectId);
setAgents(loadedAgents);
setShowAgentPicker(true);
} catch (err) {
addToast(t("todo.failedToLoadAgents", "Failed to load agents: {{error}}", { error: getErrorMessage(err) }), "error");
setShowAgentPicker(false);
setActiveItemForAgent(null);
} finally {
setAgentsLoading(false);
}
}, [projectId, addToast, t]);
useEffect(() => {
setEditingListId(null);
setEditingListTitle("");
setNewListTitle("");
setIsAddingList(false);
setEditingItemId(null);
setEditingItemText("");
setNewItemText("");
setShowAgentPicker(false);
setActiveItemForAgent(null);
}, [selectedListId]);
useEffect(() => {
if (typeof window !== "undefined") {
setScopedItem("kb-dashboard-todo-hide-done", hideDone.toString(), projectId);
}
}, [hideDone, projectId]);
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: t("todo.deleteListTitle", "Delete List"),
message: t("todo.deleteListConfirm", "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 visibleIds = visibleItems.map((item) => item.id);
const visibleIndex = visibleIds.findIndex((id) => id === itemId);
if (visibleIndex < 0) {
return;
}
const targetVisibleIndex = direction === "up" ? visibleIndex - 1 : visibleIndex + 1;
if (targetVisibleIndex < 0 || targetVisibleIndex >= visibleIds.length) {
return;
}
const ids = sortedItems.map((item) => item.id);
const index = ids.findIndex((id) => id === itemId);
const targetIndex = ids.findIndex((id) => id === visibleIds[targetVisibleIndex]);
if (index < 0 || targetIndex < 0) {
return;
}
[ids[index], ids[targetIndex]] = [ids[targetIndex], ids[index]];
await reorderItems(ids);
}
const handleCreateTaskFromItem = useCallback(async (item: TodoItem) => {
try {
/*
FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
Do not hard-code `column: "triage"` — this surface has no workflow picker, so it inherits the project-default workflow, and the store resolves the landing column from that workflow's intake column (e.g. Coding (Ideas) → "ideas") instead of forcing triage.
*/
const input: TaskCreateInput = {
description: item.text,
source: { sourceType: "dashboard_ui" },
};
const task: Task = await createTask(input, projectId);
onTaskCreated?.(task);
addToast(t("todo.taskCreatedFromTodo", "Created {{id}} from todo", { id: task.id }), "success");
} catch (err) {
addToast(t("todo.failedToCreateTask", "Failed to create task: {{error}}", { error: getErrorMessage(err) }), "error");
}
}, [projectId, addToast, onTaskCreated, t]);
const handleCreateTaskAndAssign = useCallback(async (item: TodoItem, agentId: string) => {
try {
// FNXC:CodingIdeasWorkflow 2026-07-05-00:00: same rationale as handleCreateTaskFromItem above — omit column so the project-default workflow's intake column resolves it.
const input: TaskCreateInput = {
description: item.text,
assignedAgentId: agentId,
source: { sourceType: "dashboard_ui" },
};
const task: Task = await createTask(input, projectId);
onTaskCreated?.(task);
const assignedAgent = agents.find((agent) => agent.id === agentId);
const agentLabel = assignedAgent?.name ?? agentId;
addToast(t("todo.taskCreatedAndAssigned", "Created {{id}} and assigned to {{agent}}", { id: task.id, agent: agentLabel }), "success");
setShowAgentPicker(false);
setActiveItemForAgent(null);
} catch (err) {
addToast(t("todo.failedToCreateAndAssign", "Failed to create and assign task: {{error}}", { error: getErrorMessage(err) }), "error");
}
}, [projectId, addToast, agents, onTaskCreated, t]);
/*
FNXC:Todos 2026-06-22-17:45:
The redundant "Todos" title + "Manage reusable todo lists" subtitle are removed — Todos lives in the right dock (and left-sidebar nav) which already labels the view, so a repeated in-view header is noise. The list/detail layout owns the full height with no header above it.
*/
const header = null;
if (loading) {
return (
<div className="todo-view" data-testid="todo-view-root">
{header}
<div className="todo-loading">
<Loader2 className="todo-loading-icon" aria-hidden="true" />
<p>{t("todo.loading", "Loading todos...")}</p>
</div>
</div>
);
}
return (
<div className="todo-view" data-testid="todo-view-root">
{header}
<div className="todo-view-layout" data-mobile-stack-view={mobileStackView}>
<aside className="todo-view-sidebar" aria-label={t("todo.listsLabel", "Todo lists sidebar")}>
<div className="todo-sidebar-header">
<h3 className="todo-sidebar-title">{t("todo.lists", "Lists")}</h3>
<button
type="button"
className="btn btn-sm btn-icon todo-add-list-btn"
onClick={() => {
resetItemDraftState();
setIsAddingList(true);
setEditingListId(null);
}}
aria-label={t("todo.addList", "Add list")}
data-testid="add-list-button"
>
<Plus />
</button>
</div>
{isAddingList && (
<div className="todo-list-item">
<input
className="input todo-inline-edit-input"
placeholder={t("todo.newListTitle", "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={t("todo.saveList", "Save list")}
>
<Check />
</button>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={() => {
setNewListTitle("");
setIsAddingList(false);
}}
aria-label={t("todo.cancelList", "Cancel list")}
>
<X />
</button>
</div>
)}
{lists.length === 0 && !isAddingList ? (
<div className="todo-empty-state">
<ListChecks aria-hidden="true" />
<p>{t("todo.noListsEmpty", "No todo lists yet. Create one to get started.")}</p>
<button
type="button"
className="btn btn-sm"
onClick={() => {
resetItemDraftState();
setIsAddingList(true);
}}
>
{t("todo.createList", "Create List")}
</button>
</div>
) : (
<div className="todo-list-items" role="list" aria-label={t("todo.todoListsLabel", "Todo lists")}>
{lists.map((list) => {
const isActive = list.id === selectedListId;
const isEditing = list.id === editingListId;
const stats = listItemStats.get(list.id) ?? { total: 0, completed: 0 };
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={t("todo.saveListRename", "Save list rename")}
>
<Check />
</button>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={handleCancelRenameList}
aria-label={t("todo.cancelListRename", "Cancel list rename")}
>
<X />
</button>
</>
) : (
<>
<button
type="button"
className="todo-list-select-btn"
onClick={() => handleSelectList(list.id)}
aria-label={t("todo.selectList", "Select list {{title}}", { title: list.title })}
aria-current={isActive ? "true" : undefined}
data-testid={`todo-list-${list.id}`}
>
<span className="todo-list-item-name">{list.title}</span>
<span className="todo-list-item-count">
{stats.completed}/{stats.total}
</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={t("todo.renameList", "Rename {{title}}", { title: list.title })}
data-testid={`rename-list-button-${list.id}`}
>
<Pencil />
</button>
<button
type="button"
className="btn btn-sm btn-icon btn-danger todo-icon-btn"
onClick={() => {
void handleDeleteList(list.id);
}}
aria-label={t("todo.deleteList", "Delete {{title}}", { title: list.title })}
data-testid={`delete-list-button-${list.id}`}
>
<Trash2 />
</button>
</div>
</>
)}
</div>
);
})}
</div>
)}
</aside>
<section className="todo-view-main" aria-label={t("todo.itemsLabel", "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()}>
{t("actions.retry", "Retry")}
</button>
</div>
)}
{!selectedList ? (
<div className="todo-empty-state">
<ListChecks aria-hidden="true" />
<p>{t("todo.selectListEmpty", "Select a list from the sidebar")}</p>
</div>
) : (
<>
<div className="todo-items-header">
{/* FNXC:Todos 2026-06-22-00:00: Back button is visible only in the narrow container (CSS-gated) to pop the items panel back to the list-selection panel. Hidden in the wide two-panel layout where both panels coexist. */}
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn todo-mobile-back-btn"
onClick={handleMobileBack}
aria-label={t("todo.backToLists", "Back to lists")}
data-testid="todo-mobile-back-button"
>
<ChevronLeft />
</button>
<div className="todo-items-heading">
<h3>{selectedList.title}</h3>
{selectedListStats && (
<span className="todo-items-progress">
{t("todo.completedCount", "{{completed}}/{{total}} complete", {
completed: selectedListStats.completed,
total: selectedListStats.total,
})}
</span>
)}
</div>
<button
type="button"
className="btn btn-sm todo-hide-done-toggle"
aria-pressed={hideDone}
onClick={() => setHideDone((current) => !current)}
data-testid="todo-hide-done-toggle"
>
{hideDone ? <Eye aria-hidden="true" /> : <EyeOff aria-hidden="true" />}
{hideDone ? t("todo.showDone", "Show done") : t("todo.hideDone", "Hide done")}
</button>
</div>
<div className="todo-add-item-row">
<input
className="input"
placeholder={t("todo.addItemPlaceholder", "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();
}}
>
<Plus size={14} />
{t("actions.add", "Add")}
</button>
</div>
{sortedItems.length === 0 ? (
<div className="todo-empty-state">
<p>{t("todo.noItemsEmpty", "No items in this list. Add one above.")}</p>
</div>
) : visibleItems.length === 0 ? (
<div className="todo-empty-state" data-testid="todo-all-done-hidden-empty">
<p>{t("todo.allDoneHidden", "All items are complete. Choose Show done to view them.")}</p>
</div>
) : (
<div className="todo-items-list">
{visibleItems.map((item, index) => {
const isEditing = item.id === editingItemId;
return (
<div className="todo-item" key={item.id} data-testid={`todo-item-${item.id}`}>
<div className="todo-item-main-row">
<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>
<div className="todo-item-actions" data-testid={`todo-item-actions-${item.id}`}>
{isEditing ? (
<>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={() => {
void handleSaveEditItem();
}}
aria-label={t("todo.saveItemEdit", "Save item edit")}
>
<Check />
</button>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={handleCancelEditItem}
aria-label={t("todo.cancelItemEdit", "Cancel item edit")}
>
<X />
</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={t("todo.moveItemUp", "Move {{text}} up", { text: item.text })}
data-testid={`move-up-${item.id}`}
>
<ChevronUp />
</button>
<button
type="button"
className="btn btn-sm btn-icon todo-item-reorder-btn"
onClick={() => {
void handleMoveItem(item.id, "down");
}}
disabled={index === visibleItems.length - 1}
aria-label={t("todo.moveItemDown", "Move {{text}} down", { text: item.text })}
data-testid={`move-down-${item.id}`}
>
<ChevronDown />
</button>
</div>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={() => {
onPlanningMode?.(item.text);
}}
aria-label={t("todo.startPlanning", "Start planning from {{text}}", { text: item.text })}
data-testid={`planning-from-${item.id}`}
>
<Lightbulb />
</button>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={() => {
void handleCreateTaskFromItem(item);
}}
aria-label={t("todo.createTaskFrom", "Create task from {{text}}", { text: item.text })}
data-testid={`create-task-from-${item.id}`}
>
<PlusCircle />
</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={t("todo.assignAgent", "Assign {{text}} to agent", { text: item.text })}
data-testid={`assign-agent-for-${item.id}`}
>
<Bot />
</button>
{showAgentPicker && activeItemForAgent === item.id && (
<div
className="todo-agent-picker-dropdown"
onMouseDown={(event) => {
event.preventDefault();
}}
>
{agentsLoading ? (
<div className="todo-agent-picker-loading"><LoadingSpinner label={t("todo.loadingAgents", "Loading agents...")} /></div>
) : agents.length > 0 ? (
agents
.map((agent) => (
<button
type="button"
key={agent.id}
className="todo-agent-picker-item"
onClick={() => {
void handleCreateTaskAndAssign(item, agent.id);
}}
>
<Bot />
<span>{agent.name}</span>
<span className="todo-agent-picker-role">{agent.role}</span>
</button>
))
) : (
<div className="todo-agent-picker-empty">{t("todo.noAgentsAvailable", "No agents available")}</div>
)}
</div>
)}
</div>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={() => handleStartEditItem(item)}
aria-label={t("todo.editItem", "Edit {{text}}", { text: item.text })}
data-testid={`edit-item-${item.id}`}
>
<Pencil />
</button>
<button
type="button"
className="btn btn-sm btn-icon btn-danger todo-icon-btn"
onClick={() => {
void handleDeleteItem(item.id);
}}
aria-label={t("todo.deleteItem", "Delete {{text}}", { text: item.text })}
data-testid={`delete-item-${item.id}`}
>
<Trash2 />
</button>
</>
)}
</div>
</div>
);
})}
</div>
)}
</>
)}
</section>
</div>
</div>
);
}