Files
fusion/packages/dashboard/app/components/TodoView.tsx
Fusion a285c2988b feat(FN-2923): merge fusion/fn-2923
- 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
2026-04-29 17:23:22 -07:00

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>
);
}