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>
This commit is contained in:
gsxdsm
2026-07-12 12:52:30 -07:00
parent 06ec0e606e
commit bb86844f8d
6 changed files with 210 additions and 7 deletions

View File

@@ -904,6 +904,9 @@ Navigation:
- Desktop/tablet: **Left sidebar → Todos** when the Todo view is enabled
- Mobile: **More** sheet → **Todos**
<!-- FNXC:Todos 2026-07-12-00:00: Todo operators can declutter long selected lists with a per-project Hide done / Show done toggle while the completion count remains based on all items. -->
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

View File

@@ -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;

View File

@@ -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<string | null>(null);
const [hideDone, setHideDone] = useState<boolean>(() => readHideDoneTodos(projectId));
const agentPickerRef = useRef<HTMLDivElement>(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<string, { total: number; completed: number }>();
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<void> {
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({
</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">
@@ -603,9 +651,13 @@ export function TodoView({
<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">
{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}`}
>

View File

@@ -36,6 +36,8 @@ vi.mock("lucide-react", () => ({
Bot: () => <span data-testid="icon-bot" />,
PlusCircle: () => <span data-testid="icon-plus-circle" />,
Lightbulb: () => <span data-testid="icon-lightbulb" />,
Eye: () => <span data-testid="icon-eye" />,
EyeOff: () => <span data-testid="icon-eye-off" />,
}));
function createMockTodoLists(overrides: Record<string, unknown> = {}) {
@@ -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(<TodoView addToast={addToast} />);
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(<TodoView addToast={addToast} />);
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(<TodoView addToast={addToast} />);
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(<TodoView addToast={addToast} />);
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(<TodoView addToast={addToast} />);
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(<TodoView addToast={addToast} projectId="project-1" />);
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(<TodoView addToast={addToast} />);
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);

View File

@@ -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",

View File

@@ -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",