From bb86844f8d95666c630d0a9ae96b059debfb0820 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Jul 2026 12:52:30 -0700 Subject: [PATCH] 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) --- docs/dashboard-guide.md | 3 + .../dashboard/app/components/TodoView.css | 45 +++++++++ .../dashboard/app/components/TodoView.tsx | 66 +++++++++++-- .../components/__tests__/TodoView.test.tsx | 99 +++++++++++++++++++ .../dashboard/app/utils/projectStorage.ts | 1 + packages/i18n/locales/en/app.json | 3 + 6 files changed, 210 insertions(+), 7 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 419a549f9c..945ccdce6c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -904,6 +904,9 @@ Navigation: - Desktop/tablet: **Left sidebar → Todos** when the Todo view is enabled - Mobile: **More** sheet → **Todos** + +Use the items header **Hide done** toggle to hide completed todo items in the selected list; switch it back with **Show done** when you need to review completed work. The completed/total count still reflects all items in the list. + For full behavior, API contracts, and storage details, use the canonical [Todo View guide](./todo-view.md). ## Research View diff --git a/packages/dashboard/app/components/TodoView.css b/packages/dashboard/app/components/TodoView.css index bb6baddbf9..857cd4be33 100644 --- a/packages/dashboard/app/components/TodoView.css +++ b/packages/dashboard/app/components/TodoView.css @@ -538,6 +538,33 @@ FN-7870 removes the active-row left accent stripe and per-row border boxing so T font-size: 0.8125rem; } +/* +FNXC:TodosStyling 2026-07-12-00:00: +The Todo items header owns the Hide done / Show done affordance. Keep it quiet, token-sized, and wrap-safe so the same control remains visible in the wide split pane, the right-dock container stack, and the viewport mobile layout without creating an overflow-only button shell. +*/ +.todo-hide-done-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-xs); + flex-shrink: 0; + white-space: nowrap; + color: var(--text-muted); + border-color: var(--border); + background: var(--surface); +} + +.todo-hide-done-toggle[aria-pressed="true"] { + color: var(--todo); + border-color: color-mix(in srgb, var(--todo) 36%, var(--border)); + background: color-mix(in srgb, var(--todo) 8%, var(--surface)); +} + +.todo-hide-done-toggle svg { + width: var(--space-md); + height: var(--space-md); +} + .todo-add-item-row { margin-bottom: var(--space-md); padding: var(--space-sm); @@ -714,6 +741,15 @@ NARROW container (right dock): collapse the side-by-side split into a single-pan opacity: 1; } + .todo-items-header { + align-items: stretch; + flex-wrap: wrap; + } + + .todo-hide-done-toggle { + min-height: calc(var(--space-2xl) + var(--space-xs)); + } + /* Stack text over the action row: a phone-width row cannot fit text plus seven controls. */ .todo-item { flex-direction: column; @@ -808,6 +844,15 @@ NARROW container (right dock): collapse the side-by-side split into a single-pan align-items: center; } + .todo-items-header { + align-items: stretch; + flex-wrap: wrap; + } + + .todo-hide-done-toggle { + min-height: calc(var(--space-2xl) + var(--space-xs)); + } + /* Stack text over the action row on mobile; mirror the narrow-container stack. */ .todo-item { flex-direction: column; diff --git a/packages/dashboard/app/components/TodoView.tsx b/packages/dashboard/app/components/TodoView.tsx index c76e777438..fa83dc7a46 100644 --- a/packages/dashboard/app/components/TodoView.tsx +++ b/packages/dashboard/app/components/TodoView.tsx @@ -14,6 +14,8 @@ import { 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"; @@ -21,6 +23,7 @@ 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 { @@ -34,6 +37,19 @@ 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, @@ -78,6 +94,7 @@ export function TodoView({ const [agentsLoading, setAgentsLoading] = useState(false); const [showAgentPicker, setShowAgentPicker] = useState(false); const [activeItemForAgent, setActiveItemForAgent] = useState(null); + const [hideDone, setHideDone] = useState(() => readHideDoneTodos(projectId)); const agentPickerRef = useRef(null); const { confirm } = useConfirm(); @@ -95,6 +112,14 @@ export function TodoView({ () => 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(); for (const list of lists) { @@ -165,6 +190,12 @@ export function TodoView({ setActiveItemForAgent(null); }, [selectedListId]); + useEffect(() => { + if (typeof window !== "undefined") { + setScopedItem("kb-dashboard-todo-hide-done", hideDone.toString(), projectId); + } + }, [hideDone, projectId]); + useEffect(() => { if (!showAgentPicker) { return; @@ -282,14 +313,21 @@ export function TodoView({ } async function handleMoveItem(itemId: string, direction: "up" | "down"): Promise { - const ids = sortedItems.map((item) => item.id); - const index = ids.findIndex((id) => id === itemId); - if (index < 0) { + const visibleIds = visibleItems.map((item) => item.id); + const visibleIndex = visibleIds.findIndex((id) => id === itemId); + if (visibleIndex < 0) { return; } - const targetIndex = direction === "up" ? index - 1 : index + 1; - if (targetIndex < 0 || targetIndex >= ids.length) { + 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; } @@ -569,6 +607,16 @@ export function TodoView({ )} +
@@ -603,9 +651,13 @@ export function TodoView({

{t("todo.noItemsEmpty", "No items in this list. Add one above.")}

+ ) : visibleItems.length === 0 ? ( +
+

{t("todo.allDoneHidden", "All items are complete. Choose Show done to view them.")}

+
) : (
- {sortedItems.map((item, index) => { + {visibleItems.map((item, index) => { const isEditing = item.id === editingItemId; return ( @@ -692,7 +744,7 @@ export function TodoView({ onClick={() => { void handleMoveItem(item.id, "down"); }} - disabled={index === sortedItems.length - 1} + disabled={index === visibleItems.length - 1} aria-label={t("todo.moveItemDown", "Move {{text}} down", { text: item.text })} data-testid={`move-down-${item.id}`} > diff --git a/packages/dashboard/app/components/__tests__/TodoView.test.tsx b/packages/dashboard/app/components/__tests__/TodoView.test.tsx index 0574ea136f..ee981c6944 100644 --- a/packages/dashboard/app/components/__tests__/TodoView.test.tsx +++ b/packages/dashboard/app/components/__tests__/TodoView.test.tsx @@ -36,6 +36,8 @@ vi.mock("lucide-react", () => ({ Bot: () => , PlusCircle: () => , Lightbulb: () => , + Eye: () => , + EyeOff: () => , })); function createMockTodoLists(overrides: Record = {}) { @@ -76,6 +78,7 @@ describe("TodoView", () => { mockFetchAgents.mockResolvedValue([ { id: "agent-1", name: "Builder", role: "engineer", state: "active" }, ]); + window.localStorage.clear(); mockUseTodoLists.mockReturnValue(createMockTodoLists()); }); @@ -133,6 +136,80 @@ describe("TodoView", () => { mockUseTodoLists.mockReturnValue(createMockTodoLists({ selectedListId: null })); render(); expect(screen.getByText("Select a list from the sidebar")).toBeInTheDocument(); + expect(screen.queryByTestId("todo-hide-done-toggle")).not.toBeInTheDocument(); + }); + + it("renders the hide-done toggle in the selected list items header", () => { + render(); + + const toggle = screen.getByTestId("todo-hide-done-toggle"); + expect(toggle).toHaveTextContent("Hide done"); + expect(toggle).toHaveAttribute("aria-pressed", "false"); + }); + + it("keeps the toggle available when the selected list has no completed items", () => { + mockUseTodoLists.mockReturnValue(createMockTodoLists({ + items: [ + { id: "item-1", listId: "list-1", text: "Buy groceries", completed: false, sortOrder: 0 }, + { id: "item-4", listId: "list-1", text: "Pack bags", completed: false, sortOrder: 1 }, + ], + })); + + render(); + fireEvent.click(screen.getByTestId("todo-hide-done-toggle")); + + expect(screen.getByText("Buy groceries")).toBeInTheDocument(); + expect(screen.getByText("Pack bags")).toBeInTheDocument(); + }); + + it("hides and shows completed items without changing the all-item progress count", () => { + render(); + + expect(screen.getByText("1/2 complete")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("todo-hide-done-toggle")); + + expect(screen.getByText("Buy groceries")).toBeInTheDocument(); + expect(screen.queryByText("Clean house")).not.toBeInTheDocument(); + expect(screen.getByText("1/2 complete")).toBeInTheDocument(); + expect(screen.getByTestId("todo-hide-done-toggle")).toHaveTextContent("Show done"); + expect(screen.getByTestId("todo-hide-done-toggle")).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click(screen.getByTestId("todo-hide-done-toggle")); + + expect(screen.getByText("Clean house")).toBeInTheDocument(); + expect(screen.getByText("1/2 complete")).toBeInTheDocument(); + expect(screen.getByTestId("todo-hide-done-toggle")).toHaveTextContent("Hide done"); + }); + + it("shows a distinct all-done-hidden empty state when every selected item is completed", () => { + mockUseTodoLists.mockReturnValue(createMockTodoLists({ + items: [ + { id: "item-1", listId: "list-1", text: "Buy groceries", completed: true, sortOrder: 0 }, + { id: "item-2", listId: "list-1", text: "Clean house", completed: true, sortOrder: 1 }, + ], + })); + + render(); + fireEvent.click(screen.getByTestId("todo-hide-done-toggle")); + + expect(screen.getByTestId("todo-all-done-hidden-empty")).toHaveTextContent("All items are complete. Choose Show done to view them."); + expect(screen.queryByText("No items in this list. Add one above.")).not.toBeInTheDocument(); + }); + + it("persists the hide-done toggle per project and re-reads it on mount", async () => { + window.localStorage.setItem("kb:project-1:kb-dashboard-todo-hide-done", "true"); + + render(); + + expect(screen.getByTestId("todo-hide-done-toggle")).toHaveTextContent("Show done"); + expect(screen.queryByText("Clean house")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("todo-hide-done-toggle")); + + await waitFor(() => { + expect(window.localStorage.getItem("kb:project-1:kb-dashboard-todo-hide-done")).toBe("false"); + }); + expect(screen.getByText("Clean house")).toBeInTheDocument(); }); it("clicking a list item calls setSelectedListId", () => { @@ -301,6 +378,28 @@ describe("TodoView", () => { expect(screen.getByRole("button", { name: "Move Clean house down" })).toBeDisabled(); }); + it("computes reorder boundaries from visible items while preserving hidden done positions", () => { + const state = createMockTodoLists({ + items: [ + { id: "item-1", listId: "list-1", text: "Buy groceries", completed: false, sortOrder: 0 }, + { id: "item-2", listId: "list-1", text: "Clean house", completed: true, sortOrder: 1 }, + { id: "item-4", listId: "list-1", text: "Pack bags", completed: false, sortOrder: 2 }, + ], + }); + mockUseTodoLists.mockReturnValue(state); + + render(); + fireEvent.click(screen.getByTestId("todo-hide-done-toggle")); + + expect(screen.getByRole("button", { name: "Move Buy groceries up" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Move Buy groceries down" })).not.toBeDisabled(); + expect(screen.getByRole("button", { name: "Move Pack bags down" })).toBeDisabled(); + + fireEvent.click(screen.getByRole("button", { name: "Move Buy groceries down" })); + + expect(state.reorderItems).toHaveBeenCalledWith(["item-4", "item-2", "item-1"]); + }); + it("clicking trash icon on item calls deleteItem", () => { const state = createMockTodoLists(); mockUseTodoLists.mockReturnValue(state); diff --git a/packages/dashboard/app/utils/projectStorage.ts b/packages/dashboard/app/utils/projectStorage.ts index 05bb218133..8453ce355d 100644 --- a/packages/dashboard/app/utils/projectStorage.ts +++ b/packages/dashboard/app/utils/projectStorage.ts @@ -12,6 +12,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [ "kb-dashboard-task-view", "kb-dashboard-list-columns", "kb-dashboard-hide-done", + "kb-dashboard-todo-hide-done", "kb-dashboard-list-collapsed", "kb-dashboard-selected-tasks", "kb-dashboard-list-selected-task", diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 7ed9fb966a..2cdb6c5e44 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -8370,6 +8370,7 @@ "todo": { "addItemPlaceholder": "Add a todo item", "addList": "Add list", + "allDoneHidden": "All items are complete. Choose Show done to view them.", "assignAgent": "Assign {{text}} to agent", "cancelItemEdit": "Cancel item edit", "cancelList": "Cancel list", @@ -8398,6 +8399,7 @@ "failedToCreateTask": "Failed to create task: {{error}}", "failedToLoadAgents": "Failed to load agents: {{error}}", "failedUpdateItem": "Failed to update item", + "hideDone": "Hide done", "failedUpdateItemToast": "Failed to update todo item", "itemsLabel": "Todo items", "lists": "Lists", @@ -8417,6 +8419,7 @@ "saveListRename": "Save list rename", "selectList": "Select list {{title}}", "selectListEmpty": "Select a list from the sidebar", + "showDone": "Show done", "startPlanning": "Start planning from {{text}}", "taskCreatedAndAssigned": "Created {{id}} and assigned to {{agent}}", "taskCreatedFromTodo": "Created {{id}} from todo",