feat(FN-2578): merge fusion/fn-2578 (auto-resolved)

- fix(FN-2578): align TodoView placeholder addToast typing
- feat(FN-2578): complete Step 4 — route and preload TodoView
- feat(FN-2578): complete Step 3 — wire todos nav entries
- feat(FN-2578): complete Step 2 — add todos to view state types
- feat(FN-2578): complete Step 1 — add useTodoLists hook and tests
This commit is contained in:
Fusion
2026-04-26 14:16:35 -07:00
committed by gsxdsm
parent 6bafd3d708
commit fc32aacb33
7 changed files with 714 additions and 7 deletions

View File

@@ -65,6 +65,7 @@ const RoadmapsView = lazy(() => import("./components/RoadmapsView").then((m) =>
const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ default: m.SkillsView })));
const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView })));
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
const TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
// Warm lazy chunks during browser idle so first navigation to each view is
// instant. Each chunk is ~1080 kB; total prefetch finishes well under a
@@ -83,6 +84,7 @@ function prefetchLazyViews() {
void import("./components/SkillsView");
void import("./components/MemoryView");
void import("./components/DevServerView");
void import("./components/TodoView");
});
}
@@ -670,6 +672,16 @@ function AppInner() {
);
}
if (taskView === "todos") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<TodoView addToast={addToast} projectId={currentProject?.id} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "devserver" || taskView === "dev-server") {
return (
<PageErrorBoundary>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Map, Zap, Sparkles, FileText, Brain } from "lucide-react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Map, Zap, Sparkles, FileText, Brain, CheckSquare } from "lucide-react";
import "./Header.css";
// Header renders an inline ProjectSelector dropdown using project-selector-* classes.
import "./ProjectSelector.css";
@@ -185,8 +185,8 @@ export interface HeaderProps {
enginePaused?: boolean;
onToggleGlobalPause?: () => void;
onToggleEnginePause?: () => void;
view?: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server") => void;
view?: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos") => void;
/** Whether to show the skills tab in the view toggle */
showSkillsTab?: boolean;
/** When true, shows the Agents view tab button. Hidden by default (experimental feature). */
@@ -834,7 +834,7 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${["skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) ? " active" : ""}`}
className={`view-toggle-btn${["skills", "roadmaps", "insights", "memory", "dev-server", "devserver", "todos"].includes(view) ? " active" : ""}`}
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
title="More views"
aria-label="More views"
@@ -922,6 +922,18 @@ export function Header({
<span className="visually-hidden" data-testid="view-toggle-dev-server" />
</button>
)}
<button
className={`view-toggle-overflow-item${view === "todos" ? " active" : ""}`}
onClick={() => {
onChangeView("todos");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-overflow-todos"
>
<CheckSquare size={14} />
<span>Todos</span>
</button>
</div>
)}
</>

View File

@@ -4,6 +4,7 @@ import {
Activity,
Bot,
Brain,
CheckSquare,
ChevronRight,
Clock,
FileCode,
@@ -32,9 +33,9 @@ import { useViewportMode } from "./Header";
export interface MobileNavBarProps {
/** Current task view mode */
view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
/** Change task view handler */
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server") => void;
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos") => void;
/** Whether the ExecutorStatusBar footer is visible */
footerVisible: boolean;
/** Whether any full-screen modal is currently open (hides the tab bar) */
@@ -186,6 +187,7 @@ export function MobileNavBar({
|| view === "memory"
|| view === "devserver"
|| view === "dev-server"
|| view === "todos"
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|| (view === "skills" && !showSkillsTopLevel);
@@ -582,6 +584,16 @@ export function MobileNavBar({
</button>
)}
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-todos"
onClick={() => handleMoreAction(() => onChangeView("todos"))}
>
<CheckSquare size={14} />
<span>Todos</span>
</button>
<div className="mobile-more-separator" />
<button

View File

@@ -0,0 +1,5 @@
import type { ToastType } from "../hooks/useToast";
export function TodoView(_props: { addToast?: (msg: string, type?: ToastType) => void; projectId?: string }) {
return null;
}

View File

@@ -0,0 +1,289 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import type { TodoItem, TodoList, TodoListWithItems } from "@fusion/core";
import { useTodoLists } from "../useTodoLists";
vi.mock("../../api", () => ({
fetchTodoLists: vi.fn(),
createTodoList: vi.fn(),
updateTodoList: vi.fn(),
deleteTodoList: vi.fn(),
createTodoItem: vi.fn(),
updateTodoItem: vi.fn(),
deleteTodoItem: vi.fn(),
reorderTodoItems: vi.fn(),
}));
import {
fetchTodoLists,
createTodoList,
updateTodoList,
deleteTodoList,
createTodoItem,
updateTodoItem,
deleteTodoItem,
reorderTodoItems,
} from "../../api";
const mockFetchTodoLists = vi.mocked(fetchTodoLists);
const mockCreateTodoList = vi.mocked(createTodoList);
const mockUpdateTodoList = vi.mocked(updateTodoList);
const mockDeleteTodoList = vi.mocked(deleteTodoList);
const mockCreateTodoItem = vi.mocked(createTodoItem);
const mockUpdateTodoItem = vi.mocked(updateTodoItem);
const mockDeleteTodoItem = vi.mocked(deleteTodoItem);
const mockReorderTodoItems = vi.mocked(reorderTodoItems);
function makeList(id: string, title: string, items: TodoItem[] = []): TodoListWithItems {
return {
id,
projectId: "project-1",
title,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
items,
};
}
function makeItem(id: string, listId: string, text: string, completed = false, sortOrder = 0): TodoItem {
return {
id,
listId,
text,
completed,
completedAt: completed ? "2026-01-01T00:00:00.000Z" : null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
sortOrder,
};
}
describe("useTodoLists", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUpdateTodoList.mockResolvedValue({} as TodoList);
mockDeleteTodoItem.mockResolvedValue();
});
it("initial fetch populates lists", async () => {
mockFetchTodoLists.mockResolvedValue([makeList("list-1", "Inbox")]);
const { result } = renderHook(() => useTodoLists({ projectId: "project-1" }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.lists).toHaveLength(1);
expect(result.current.lists[0].title).toBe("Inbox");
expect(result.current.selectedListId).toBe("list-1");
});
it("selectedListId filters items", async () => {
const listOneItems = [makeItem("item-1", "list-1", "One")];
const listTwoItems = [makeItem("item-2", "list-2", "Two")];
mockFetchTodoLists.mockResolvedValue([
makeList("list-1", "Inbox", listOneItems),
makeList("list-2", "Work", listTwoItems),
]);
const { result } = renderHook(() => useTodoLists({ projectId: "project-1" }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.items.map((item) => item.id)).toEqual(["item-1"]);
act(() => {
result.current.setSelectedListId("list-2");
});
await waitFor(() => {
expect(result.current.items.map((item) => item.id)).toEqual(["item-2"]);
});
});
it("createList optimistically adds and rolls back on error", async () => {
const addToast = vi.fn();
mockFetchTodoLists.mockResolvedValue([makeList("list-1", "Inbox")]);
let rejectCreate: ((reason?: unknown) => void) | undefined;
mockCreateTodoList.mockReturnValue(
new Promise((_, reject) => {
rejectCreate = reject;
}),
);
const { result } = renderHook(() => useTodoLists({ projectId: "project-1", addToast }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
let createPromise: Promise<void> | undefined;
act(() => {
createPromise = result.current.createList("Urgent");
});
expect(result.current.lists.some((list) => list.title === "Urgent")).toBe(true);
rejectCreate?.(new Error("create failed"));
await act(async () => {
await createPromise;
});
expect(result.current.lists.map((list) => list.title)).toEqual(["Inbox"]);
expect(addToast).toHaveBeenCalledWith("Failed to create todo list", "error");
});
it("deleteList optimistically removes and rolls back on error", async () => {
const addToast = vi.fn();
mockFetchTodoLists.mockResolvedValue([
makeList("list-1", "Inbox"),
makeList("list-2", "Work"),
]);
let rejectDelete: ((reason?: unknown) => void) | undefined;
mockDeleteTodoList.mockReturnValue(
new Promise((_, reject) => {
rejectDelete = reject;
}),
);
const { result } = renderHook(() => useTodoLists({ projectId: "project-1", addToast }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
let deletePromise: Promise<void> | undefined;
act(() => {
deletePromise = result.current.deleteList("list-1");
});
expect(result.current.lists.map((list) => list.id)).toEqual(["list-2"]);
rejectDelete?.(new Error("delete failed"));
await act(async () => {
await deletePromise;
});
expect(result.current.lists.map((list) => list.id)).toEqual(["list-1", "list-2"]);
expect(addToast).toHaveBeenCalledWith("Failed to delete todo list", "error");
});
it("createItem optimistically adds to selected list items", async () => {
const initialItems = [makeItem("item-1", "list-1", "Existing")];
mockFetchTodoLists.mockResolvedValue([makeList("list-1", "Inbox", initialItems)]);
let resolveCreate: ((value: TodoItem) => void) | undefined;
mockCreateTodoItem.mockReturnValue(
new Promise((resolve) => {
resolveCreate = resolve;
}),
);
const { result } = renderHook(() => useTodoLists({ projectId: "project-1" }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
let createPromise: Promise<void> | undefined;
act(() => {
createPromise = result.current.createItem("New item");
});
expect(result.current.items).toHaveLength(2);
expect(result.current.items[1].text).toBe("New item");
resolveCreate?.(makeItem("item-2", "list-1", "New item", false, 1));
await act(async () => {
await createPromise;
});
expect(result.current.items.map((item) => item.id)).toEqual(["item-1", "item-2"]);
});
it("toggleItem flips completed and rolls back on error", async () => {
const addToast = vi.fn();
const item = makeItem("item-1", "list-1", "Toggle me", false, 0);
mockFetchTodoLists.mockResolvedValue([makeList("list-1", "Inbox", [item])]);
let rejectUpdate: ((reason?: unknown) => void) | undefined;
mockUpdateTodoItem.mockReturnValue(
new Promise((_, reject) => {
rejectUpdate = reject;
}),
);
const { result } = renderHook(() => useTodoLists({ projectId: "project-1", addToast }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
let togglePromise: Promise<void> | undefined;
act(() => {
togglePromise = result.current.toggleItem("item-1");
});
expect(result.current.items[0].completed).toBe(true);
rejectUpdate?.(new Error("toggle failed"));
await act(async () => {
await togglePromise;
});
expect(result.current.items[0].completed).toBe(false);
expect(addToast).toHaveBeenCalledWith("Failed to update todo item", "error");
});
it("reorderItems optimistically reorders", async () => {
const itemOne = makeItem("item-1", "list-1", "One", false, 0);
const itemTwo = makeItem("item-2", "list-1", "Two", false, 1);
mockFetchTodoLists.mockResolvedValue([makeList("list-1", "Inbox", [itemOne, itemTwo])]);
let resolveReorder: (() => void) | undefined;
mockReorderTodoItems.mockReturnValue(
new Promise((resolve) => {
resolveReorder = resolve;
}),
);
const { result } = renderHook(() => useTodoLists({ projectId: "project-1" }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
let reorderPromise: Promise<void> | undefined;
act(() => {
reorderPromise = result.current.reorderItems(["item-2", "item-1"]);
});
expect(result.current.items.map((item) => item.id)).toEqual(["item-2", "item-1"]);
resolveReorder?.();
await act(async () => {
await reorderPromise;
});
expect(mockReorderTodoItems).toHaveBeenCalledWith("list-1", ["item-2", "item-1"], "project-1");
});
it("sets error state on fetch failure", async () => {
mockFetchTodoLists.mockRejectedValue(new Error("fetch failed"));
const { result } = renderHook(() => useTodoLists({ projectId: "project-1" }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe("fetch failed");
expect(result.current.lists).toEqual([]);
expect(result.current.items).toEqual([]);
});
});

View File

@@ -0,0 +1,376 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { TodoItem, TodoList, TodoListWithItems } from "@fusion/core";
import {
fetchTodoLists,
createTodoList,
updateTodoList,
deleteTodoList,
createTodoItem,
updateTodoItem,
deleteTodoItem,
reorderTodoItems,
} from "../api";
type ToastType = "info" | "success" | "error" | "warning";
export interface UseTodoListsOptions {
projectId?: string;
addToast?: (message: string, type?: ToastType | string) => void;
}
export interface UseTodoListsResult {
lists: TodoList[];
items: TodoItem[];
loading: boolean;
error: string | null;
selectedListId: string | null;
setSelectedListId: (id: string | null) => void;
createList: (title: string) => Promise<void>;
renameList: (id: string, title: string) => Promise<void>;
deleteList: (id: string) => Promise<void>;
createItem: (text: string) => Promise<void>;
toggleItem: (id: string) => Promise<void>;
deleteItem: (id: string) => Promise<void>;
reorderItems: (itemIds: string[]) => Promise<void>;
}
function toList(listWithItems: TodoListWithItems): TodoList {
const { items: _items, ...list } = listWithItems;
return list;
}
function buildTempId(prefix: string): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return `${prefix}-${crypto.randomUUID()}`;
}
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export function useTodoLists(options: UseTodoListsOptions = {}): UseTodoListsResult {
const { projectId, addToast } = options;
const [lists, setLists] = useState<TodoList[]>([]);
const [items, setItems] = useState<TodoItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedListId, setSelectedListId] = useState<string | null>(null);
const [listData, setListData] = useState<TodoListWithItems[]>([]);
const selectedListIdRef = useRef<string | null>(selectedListId);
selectedListIdRef.current = selectedListId;
useEffect(() => {
let cancelled = false;
async function loadLists() {
setLoading(true);
setError(null);
try {
const data = await fetchTodoLists(projectId);
if (cancelled) {
return;
}
setListData(data);
setLists(data.map(toList));
const activeListId =
selectedListIdRef.current && data.some((list) => list.id === selectedListIdRef.current)
? selectedListIdRef.current
: (data[0]?.id ?? null);
setSelectedListId(activeListId);
setItems(activeListId ? (data.find((list) => list.id === activeListId)?.items ?? []) : []);
} catch (err) {
if (cancelled) {
return;
}
setListData([]);
setLists([]);
setItems([]);
setSelectedListId(null);
setError(err instanceof Error ? err.message : "Failed to load todo lists");
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadLists();
return () => {
cancelled = true;
};
}, [projectId]);
useEffect(() => {
if (!selectedListId) {
setItems([]);
return;
}
const selected = listData.find((list) => list.id === selectedListId);
setItems(selected?.items ?? []);
}, [listData, selectedListId]);
const createListAction = useCallback(async (title: string) => {
const previousLists = lists;
const previousListData = listData;
const now = new Date().toISOString();
const tempList: TodoList = {
id: buildTempId("temp-list"),
projectId: projectId ?? "",
title,
createdAt: now,
updatedAt: now,
};
setError(null);
setLists((prev) => [...prev, tempList]);
setListData((prev) => [...prev, { ...tempList, items: [] }]);
try {
const created = await createTodoList(title, projectId);
setLists((prev) => prev.map((list) => (list.id === tempList.id ? created : list)));
setListData((prev) => prev.map((list) => (list.id === tempList.id ? { ...created, items: [] } : list)));
if (!selectedListIdRef.current) {
setSelectedListId(created.id);
}
} catch (err) {
setLists(previousLists);
setListData(previousListData);
setError(err instanceof Error ? err.message : "Failed to create list");
addToast?.("Failed to create todo list", "error");
}
}, [addToast, listData, lists, projectId]);
const renameListAction = useCallback(async (id: string, title: string) => {
const previousLists = lists;
const previousListData = listData;
setError(null);
setLists((prev) => prev.map((list) => (list.id === id ? { ...list, title } : list)));
setListData((prev) => prev.map((list) => (list.id === id ? { ...list, title } : list)));
try {
const updated = await updateTodoList(id, title, projectId);
setLists((prev) => prev.map((list) => (list.id === id ? updated : list)));
setListData((prev) => prev.map((list) => (list.id === id ? { ...updated, items: list.items } : list)));
} catch (err) {
setLists(previousLists);
setListData(previousListData);
setError(err instanceof Error ? err.message : "Failed to rename list");
addToast?.("Failed to rename todo list", "error");
}
}, [addToast, listData, lists, projectId]);
const deleteListAction = useCallback(async (id: string) => {
const previousLists = lists;
const previousListData = listData;
const previousSelectedListId = selectedListIdRef.current;
const nextSelectedListId =
previousSelectedListId === id ? (lists.find((list) => list.id !== id)?.id ?? null) : previousSelectedListId;
setError(null);
setLists((prev) => prev.filter((list) => list.id !== id));
setListData((prev) => prev.filter((list) => list.id !== id));
if (previousSelectedListId === id) {
setSelectedListId(nextSelectedListId);
}
try {
await deleteTodoList(id, projectId);
} catch (err) {
setLists(previousLists);
setListData(previousListData);
setSelectedListId(previousSelectedListId);
setError(err instanceof Error ? err.message : "Failed to delete list");
addToast?.("Failed to delete todo list", "error");
}
}, [addToast, listData, lists, projectId]);
const createItemAction = useCallback(async (text: string) => {
const listId = selectedListIdRef.current;
if (!listId) {
return;
}
const previousItems = items;
const previousListData = listData;
const now = new Date().toISOString();
const maxSortOrder = items.reduce((max, item) => Math.max(max, item.sortOrder), -1);
const tempItem: TodoItem = {
id: buildTempId("temp-item"),
listId,
text,
completed: false,
completedAt: null,
createdAt: now,
updatedAt: now,
sortOrder: maxSortOrder + 1,
};
setError(null);
setItems((prev) => [...prev, tempItem]);
setListData((prev) =>
prev.map((list) =>
list.id === listId
? { ...list, items: [...list.items, tempItem] }
: list,
),
);
try {
const created = await createTodoItem(listId, text, projectId);
setItems((prev) => prev.map((item) => (item.id === tempItem.id ? created : item)));
setListData((prev) =>
prev.map((list) =>
list.id === listId
? { ...list, items: list.items.map((item) => (item.id === tempItem.id ? created : item)) }
: list,
),
);
} catch (err) {
setItems(previousItems);
setListData(previousListData);
setError(err instanceof Error ? err.message : "Failed to create item");
addToast?.("Failed to create todo item", "error");
}
}, [addToast, items, listData, projectId]);
const toggleItemAction = useCallback(async (id: string) => {
const target = items.find((item) => item.id === id);
if (!target) {
return;
}
const previousItems = items;
const previousListData = listData;
const nextCompleted = !target.completed;
const optimisticItem: TodoItem = {
...target,
completed: nextCompleted,
completedAt: nextCompleted ? new Date().toISOString() : null,
updatedAt: new Date().toISOString(),
};
setError(null);
setItems((prev) => prev.map((item) => (item.id === id ? optimisticItem : item)));
setListData((prev) =>
prev.map((list) =>
list.id === optimisticItem.listId
? { ...list, items: list.items.map((item) => (item.id === id ? optimisticItem : item)) }
: list,
),
);
try {
const updated = await updateTodoItem(
id,
{
completed: nextCompleted,
},
projectId,
);
setItems((prev) => prev.map((item) => (item.id === id ? updated : item)));
setListData((prev) =>
prev.map((list) =>
list.id === updated.listId
? { ...list, items: list.items.map((item) => (item.id === id ? updated : item)) }
: list,
),
);
} catch (err) {
setItems(previousItems);
setListData(previousListData);
setError(err instanceof Error ? err.message : "Failed to toggle item");
addToast?.("Failed to update todo item", "error");
}
}, [addToast, items, listData, projectId]);
const deleteItemAction = useCallback(async (id: string) => {
const previousItems = items;
const previousListData = listData;
setError(null);
setItems((prev) => prev.filter((item) => item.id !== id));
setListData((prev) =>
prev.map((list) => ({
...list,
items: list.items.filter((item) => item.id !== id),
})),
);
try {
await deleteTodoItem(id, projectId);
} catch (err) {
setItems(previousItems);
setListData(previousListData);
setError(err instanceof Error ? err.message : "Failed to delete item");
addToast?.("Failed to delete todo item", "error");
}
}, [addToast, items, listData, projectId]);
const reorderItemsAction = useCallback(async (itemIds: string[]) => {
const listId = selectedListIdRef.current;
if (!listId) {
return;
}
const previousItems = items;
const previousListData = listData;
const byId = new Map(items.map((item) => [item.id, item]));
const reorderedItems = itemIds
.map((itemId, index) => {
const existing = byId.get(itemId);
if (!existing) {
return null;
}
return {
...existing,
sortOrder: index,
};
})
.filter((item): item is TodoItem => item !== null);
setError(null);
setItems(reorderedItems);
setListData((prev) =>
prev.map((list) =>
list.id === listId
? { ...list, items: reorderedItems }
: list,
),
);
try {
await reorderTodoItems(listId, itemIds, projectId);
} catch (err) {
setItems(previousItems);
setListData(previousListData);
setError(err instanceof Error ? err.message : "Failed to reorder items");
addToast?.("Failed to reorder todo items", "error");
}
}, [addToast, items, listData, projectId]);
return {
lists,
items,
loading,
error,
selectedListId,
setSelectedListId,
createList: createListAction,
renameList: renameListAction,
deleteList: deleteListAction,
createItem: createItemAction,
toggleItem: toggleItemAction,
deleteItem: deleteItemAction,
reorderItems: reorderItemsAction,
};
}

View File

@@ -4,7 +4,7 @@ import type { ProjectInfo } from "../api";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
export type ViewMode = "overview" | "project";
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
const TASK_VIEWS: readonly TaskView[] = [
"board",
@@ -20,6 +20,7 @@ const TASK_VIEWS: readonly TaskView[] = [
"memory",
"devserver",
"dev-server",
"todos",
];
function isTaskView(value: string | null): value is TaskView {