FN-6829: dock todos as a project view

Convert Todos into the standard project right-content navigation surface.

- Route Todos through taskView state instead of modal manager state.
- Remove TodoModal wiring, styles, and tests while updating header, sidebar, mobile nav, and history coverage.
- Polish TodoView as a standalone content page with refreshed header and layout styling.
- Update dashboard and todo documentation for the full-view behavior.

Files changed:
 docs/dashboard-guide.md                            |   5 +-
 docs/todo-view.md                                  |   5 +-
 packages/dashboard/app/App.tsx                     |  30 ++---
 .../app/__tests__/lazy-loaded-views-docs.test.ts   |  10 +-
 packages/dashboard/app/components/AppModals.tsx    |  16 ---
 packages/dashboard/app/components/Header.tsx       |  12 +-
 .../dashboard/app/components/LeftSidebarNav.tsx    |   8 +-
 packages/dashboard/app/components/MobileNavBar.tsx |   8 +-
 packages/dashboard/app/components/TodoModal.css    |  76 ------------
 packages/dashboard/app/components/TodoModal.tsx    |  79 ------------
 packages/dashboard/app/components/TodoView.css     |  55 ++++++---
 packages/dashboard/app/components/TodoView.tsx     |  24 ++--
 .../app/components/__tests__/AppModals.test.tsx    |  56 ---------
 .../app/components/__tests__/Header.test.tsx       |  26 ++--
 .../components/__tests__/LeftSidebarNav.test.tsx   |   7 +-
 .../app/components/__tests__/MobileNavBar.test.tsx |  22 +++-
 .../app/components/__tests__/TodoModal.test.tsx    | 136 ---------------------
 .../__tests__/TodoView.mobile-css.test.ts          |   7 --
 .../app/components/__tests__/TodoView.test.tsx     |  11 +-
 ...-merge-toggle-blank.mobile-integration.test.tsx |   1 -
 .../__tests__/navigation-history.test.tsx          |  29 ++++-
 .../dashboard/app/hooks/useMobileScrollLock.ts     |   2 +-
 packages/dashboard/app/hooks/useModalManager.ts    |  10 --
 packages/dashboard/app/hooks/useViewState.ts       |   7 +-
 24 files changed, 161 insertions(+), 481 deletions(-)

Fusion-Task-Id: FN-6829

Fusion-Task-Lineage: b4034bf2-b8e2-47ec-be7c-11c320d2fc44
This commit is contained in:
gsxdsm
2026-06-21 10:32:23 -07:00
parent 12aae94447
commit e67dd11b50
24 changed files with 161 additions and 481 deletions

View File

@@ -505,12 +505,13 @@ Project-file previews also support selection comments in both raw and rendered m
## Todo View
Todo View is an experimental dashboard surface for managing per-project todo lists and turning items into planning or task workflows.
Todo View is an experimental full-height dashboard surface for managing per-project todo lists and turning items into planning or task workflows. It renders in the right content area like other project views rather than as a modal overlay.
> Available when `experimentalFeatures.todoView` is enabled.
Navigation:
- Desktop: **Header → More views → Todos** (single canonical desktop entry)
- Desktop/tablet with Left Sidebar Navigation enabled: **Left sidebar → Todos**
- Desktop/tablet without the left sidebar: **Header → More views → Todos**
- Mobile: **More** sheet → **Todos**
For full behavior, API contracts, and storage details, use the canonical [Todo View guide](./todo-view.md).

View File

@@ -2,7 +2,7 @@
[← Docs index](./README.md)
Todo View is an experimental dashboard surface for personal/project todo lists that can feed directly into Fusion planning and task workflows.
Todo View is an experimental full-height dashboard surface for personal/project todo lists that can feed directly into Fusion planning and task workflows. It renders in the project right-content area like other views rather than opening a modal overlay.
## Overview
@@ -37,7 +37,8 @@ Behavior when disabled:
When enabled:
- Desktop: header overflow menu (**More views**) → **Todos** (the only desktop Todos navigation entry)
- Desktop/tablet with Left Sidebar Navigation enabled: left sidebar → **Todos**
- Desktop/tablet without the left sidebar: header overflow menu (**More views**) → **Todos**
- Mobile: **More** sheet in the mobile nav bar → **Todos**
## List management

View File

@@ -118,7 +118,7 @@ const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ d
const SecretsView = lazy(() => import("./components/SecretsView").then((m) => ({ default: m.SecretsView })));
const CommandCenter = lazy(() => import("./components/command-center/CommandCenter").then((m) => ({ default: m.CommandCenter })));
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
const _TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
const TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
const GoalsView = lazy(() => import("./components/GoalsView").then((m) => ({ default: m.GoalsView })));
const StashRecoveryView = lazy(() => import("./components/StashRecoveryView").then((m) => ({ default: m.StashRecoveryView })));
const PullRequestView = lazy(() => import("./components/PullRequestView").then((m) => ({ default: m.PullRequestView })));
@@ -1086,7 +1086,10 @@ function AppInner() {
if (taskView === "goalsView" && !goalsEnabled) {
handleChangeTaskView("board");
}
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, evalsEnabled, goalsEnabled, graphPluginTaskView]);
if (taskView === "todos" && !todosEnabled) {
handleChangeTaskView("board");
}
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, evalsEnabled, goalsEnabled, todosEnabled, graphPluginTaskView]);
const {
availableModels,
@@ -1289,11 +1292,6 @@ function AppInner() {
pushNav({ type: "modal", close: modalManager.closeFiles });
}, [modalManager, pushNav]);
const openTodosWithNav = useCallback(() => {
modalManager.openTodos();
pushNav({ type: "modal", close: modalManager.closeTodos });
}, [modalManager, pushNav]);
const openActivityLogWithNav = useCallback(() => {
modalManager.openActivityLog();
pushNav({ type: "modal", close: modalManager.closeActivityLog });
@@ -1773,7 +1771,17 @@ function AppInner() {
</PageErrorBoundary>
);
}
if (taskView === "todos") {
// FNXC:Todos 2026-06-21-09:21: Todos render as a docked right-content view, not a modal overlay, per FN-6829 so all dashboard navigation surfaces share the same taskView routing model.
if (!settingsLoaded || !todosEnabled) return null;
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<TodoView projectId={currentProject?.id} addToast={addToast} onPlanningMode={openPlanningWithInitialPlanWithNav} onTaskCreated={(task) => ingestCreatedTasks([task])} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "command-center") {
return (
<PageErrorBoundary>
@@ -1943,8 +1951,6 @@ function AppInner() {
onToggleTerminal={toggleTerminalWithNav}
onOpenFiles={openFilesWithNav}
filesOpen={modalManager.filesOpen}
onOpenTodos={openTodosWithNav}
todosOpen={modalManager.todosOpen}
todosEnabled={todosEnabled}
view={taskView}
onChangeView={viewMode === "project" && currentProject ? handleTaskViewChange : undefined}
@@ -2113,8 +2119,6 @@ function AppInner() {
view={taskView}
onChangeView={handleTaskViewChange}
onOpenSettings={openSettingsWithNav}
onOpenTodos={openTodosWithNav}
todosOpen={modalManager.todosOpen}
todosEnabled={todosEnabled}
mailboxUnreadCount={mailboxUnreadCount}
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
@@ -2181,8 +2185,6 @@ function AppInner() {
onOpenScripts={openScriptsWithNav}
onToggleTerminal={toggleTerminalWithNav}
onOpenFiles={openFilesWithNav}
onOpenTodos={openTodosWithNav}
todosOpen={modalManager.todosOpen}
onOpenGitHubImport={openGitHubImportWithNav}
onOpenPlanning={openPlanningWithNav}
onResumePlanning={resumePlanningWithNav}

View File

@@ -89,15 +89,7 @@ function extractConstLazyViews(source: string): string[] {
function extractAppLazyViews(appSource: string): Set<string> {
const normalized = extractConstLazyViews(appSource)
.map((name) => {
if (name === "_TodoView") {
return "TodoView";
}
if (name.startsWith("_")) {
return null;
}
return name;
})
.map((name) => (name.startsWith("_") ? null : name))
.filter((name): name is string => Boolean(name));
return new Set(normalized);
}

View File

@@ -13,7 +13,6 @@ import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
import { TerminalModal } from "./TerminalModal";
import { ScriptsModal } from "./ScriptsModal";
import { FileBrowserModal } from "./FileBrowserModal";
import { TodoModal } from "./TodoModal";
import { UsageIndicator } from "./UsageIndicator";
import { ScheduledTasksModal } from "./ScheduledTasksModal";
import { NewTaskModal } from "./NewTaskModal";
@@ -176,11 +175,6 @@ export function AppModals({
modalManager.closeFiles();
}, [modalManager.closeFiles, removeNav]);
const closeTodosWithNav = useCallback(() => {
removeNav(modalManager.closeTodos);
modalManager.closeTodos();
}, [modalManager.closeTodos, removeNav]);
const closeUsageWithNav = useCallback(() => {
removeNav(modalManager.closeUsage);
modalManager.closeUsage();
@@ -405,16 +399,6 @@ export function AppModals({
/>
)}
{modalManager.todosOpen && (
<TodoModal
isOpen={true}
onClose={closeTodosWithNav}
addToast={addToast}
projectId={projectId}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
/>
)}
<UsageIndicator
isOpen={modalManager.usageOpen}
onClose={closeUsageWithNav}

View File

@@ -84,8 +84,6 @@ export interface HeaderProps {
/** Opens the top-level workspace-aware file browser modal. */
onOpenFiles?: () => void;
filesOpen?: boolean;
onOpenTodos?: () => void;
todosOpen?: boolean;
todosEnabled?: boolean;
view?: TaskView;
onChangeView?: (view: TaskView) => void;
@@ -147,8 +145,6 @@ export function Header({
onToggleTerminal,
onOpenFiles,
filesOpen,
onOpenTodos,
todosOpen,
todosEnabled,
view = "board",
onChangeView,
@@ -1086,7 +1082,7 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${["research", "skills", "insights", "memory", "secrets", "dev-server", "devserver", "graph", "stash-recovery"].includes(view) || (isTablet && view === "documents") || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
className={`view-toggle-btn${["research", "skills", "insights", "memory", "secrets", "dev-server", "devserver", "graph", "stash-recovery", "todos"].includes(view) || (isTablet && view === "documents") || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || isPluginViewId(view) ? " active" : ""}`}
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
title={t("header.moreViews", "More views")}
aria-label={t("header.moreViews", "More views")}
@@ -1243,11 +1239,11 @@ export function Header({
<span className="visually-hidden" data-testid="view-toggle-dev-server" />
</button>
)}
{todosEnabled && onOpenTodos && (
{todosEnabled && onChangeView && (
<button
className={`view-toggle-overflow-item${todosOpen ? " active" : ""}`}
className={`view-toggle-overflow-item${view === "todos" ? " active" : ""}`}
onClick={() => {
onOpenTodos();
onChangeView("todos");
setIsViewOverflowOpen(false);
}}
role="menuitem"

View File

@@ -100,8 +100,6 @@ export interface LeftSidebarNavProps {
view: TaskView;
onChangeView: (view: TaskView) => void;
onOpenSettings?: () => void;
onOpenTodos?: () => void;
todosOpen?: boolean;
todosEnabled?: boolean;
mailboxUnreadCount?: number;
mailboxPendingApprovalCount?: number;
@@ -150,8 +148,6 @@ export function LeftSidebarNav({
view,
onChangeView,
onOpenSettings,
onOpenTodos,
todosOpen = false,
todosEnabled = false,
mailboxUnreadCount = 0,
mailboxPendingApprovalCount = 0,
@@ -349,8 +345,8 @@ export function LeftSidebarNav({
...(experimentalFeatures?.devServerView
? [{ id: "devserver", label: t("header.devServerView", "Dev Server"), view: "devserver" as TaskView, isActive: view === "dev-server" || view === "devserver", icon: Monitor, testId: "sidebar-nav-devserver", onSelect: () => onChangeView("devserver") }]
: []),
...(todosEnabled && onOpenTodos
? [{ id: "todos", label: t("header.todosView", "Todos"), isActive: todosOpen, icon: CheckSquare, testId: "sidebar-nav-todos", onSelect: onOpenTodos }]
...(todosEnabled
? [{ id: "todos", label: t("header.todosView", "Todos"), view: "todos" as TaskView, isActive: view === "todos", icon: CheckSquare, testId: "sidebar-nav-todos", onSelect: () => onChangeView("todos") }]
: []),
...overflowPluginViews.map((entry): SidebarNavEntry => {
const PluginIcon = getPluginNavIcon(entry.view.icon);

View File

@@ -85,8 +85,6 @@ export interface MobileNavBarProps {
onOpenScripts?: () => void;
onToggleTerminal?: () => void;
onOpenFiles?: () => void;
onOpenTodos?: () => void;
todosOpen?: boolean;
onOpenGitHubImport?: () => void;
onOpenPlanning?: () => void;
onResumePlanning?: () => void;
@@ -148,8 +146,6 @@ export function MobileNavBar({
onOpenScripts,
onToggleTerminal,
onOpenFiles,
onOpenTodos,
todosOpen = false,
onOpenGitHubImport,
onOpenPlanning,
onResumePlanning,
@@ -299,7 +295,7 @@ export function MobileNavBar({
|| view === "secrets"
|| view === "devserver"
|| view === "dev-server"
|| (todosOpen && todoViewEnabled)
|| (view === "todos" && todoViewEnabled)
|| (view === "skills" && !showSkillsTopLevel)
|| view === "graph"
|| view === "stash-recovery"
@@ -790,7 +786,7 @@ export function MobileNavBar({
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-todos"
onClick={() => handleMoreAction(() => onOpenTodos?.())}
onClick={() => handleMoreAction(() => onChangeView("todos"))}
>
<CheckSquare />
<span>{t("nav.todos", "Todos")}</span>

View File

@@ -1,76 +0,0 @@
.modal.todo-modal {
width: 80vw;
max-width: calc(var(--space-xl) * 37.5);
height: 75vh;
min-height: calc(var(--space-xs) * 100);
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - var(--space-lg));
overflow: hidden;
resize: both;
display: flex;
flex-direction: column;
}
.todo-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
}
.todo-modal-header-title {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.todo-modal-header-title h2 {
margin: 0;
font-size: calc(var(--space-md) + var(--space-xs) * 0.75);
}
.todo-modal-header-title p {
margin: 0;
color: var(--text-muted);
font-size: calc(var(--space-md) - var(--space-xs) * 0.25);
}
.todo-modal-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
@media (max-width: 768px) {
.modal-overlay:has(.todo-modal) {
padding-top: 0;
align-items: stretch;
justify-content: stretch;
}
.modal.todo-modal {
width: 100vw;
min-width: 0;
height: 100dvh;
min-height: 0;
max-width: 100vw;
max-height: 100dvh;
margin: 0;
border: none;
border-radius: 0;
resize: none;
}
.modal.todo-modal[style*="--keyboard-overlap"] {
height: var(--vv-height, 100dvh);
max-height: var(--vv-height, 100dvh);
transform: translateY(var(--vv-offset-top, 0px));
will-change: transform;
}
.modal.todo-modal[style*="--keyboard-overlap"] .todo-modal-body {
min-height: 0;
overflow: hidden;
}
}

View File

@@ -1,79 +0,0 @@
import "./TodoModal.css";
import { Suspense, lazy, useEffect } from "react";
import { ListChecks, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useViewportMode } from "./Header";
const TodoView = lazy(() => import("./TodoView").then((module) => ({ default: module.TodoView })));
interface TodoModalProps {
isOpen?: boolean;
onClose: () => void;
projectId?: string;
addToast: (message: string, type?: "success" | "error" | "info") => void;
onPlanningMode?: (initialPlan: string) => void;
}
export function TodoModal({ onClose, projectId, addToast, onPlanningMode }: TodoModalProps) {
const { t } = useTranslation("app");
const overlayDismissProps = useOverlayDismiss(onClose);
const mode = useViewportMode();
const isMobile = mode === "mobile";
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: isMobile,
});
useMobileScrollLock(isMobile);
const modalKeyboardStyle: React.CSSProperties =
keyboardOpen
? ({
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as React.CSSProperties)
: {};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
<div className="modal todo-modal" style={modalKeyboardStyle}>
<div className="modal-header todo-modal-header">
<div className="todo-modal-header-title">
<ListChecks size={18} />
<div>
<h2>{t("todo.todos", "Todos")}</h2>
<p>{t("todo.manageDescription", "Manage reusable todo lists for your project.")}</p>
</div>
</div>
<button className="modal-close" onClick={onClose} aria-label={t("common.close", "Close")}>
<X size={20} />
</button>
</div>
<div className="todo-modal-body">
<Suspense fallback={null}>
<TodoView
projectId={projectId}
addToast={addToast}
onPlanningMode={onPlanningMode}
onClose={onClose}
mobileKeyboardActive={isMobile && keyboardOpen}
/>
</Suspense>
</div>
</div>
</div>
);
}

View File

@@ -1,18 +1,53 @@
/* === TodoView === */
/*
FNXC:TodosStyling 2026-06-21-09:26:
FN-6829 mounts Todos as a flex child of .project-content like GoalsView; grow, zero min-width, and fill the viewport so the docked view never collapses to modal-era intrinsic sizing. The split-pane layout keeps overflow inside the list and item panes rather than scrolling the whole view.
*/
.todo-view {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--space-lg);
height: 100%;
min-height: 0;
min-width: 0;
width: 100%;
overflow: hidden;
padding: var(--space-lg);
}
.todo-view-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
}
.todo-view-title-group {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.todo-view-title-group h2 {
margin: 0;
color: var(--text);
font-size: calc(var(--space-lg) + var(--space-xs));
}
.todo-view-title-group p {
margin: 0;
color: var(--text-muted);
}
.todo-view-layout {
display: flex;
flex-direction: row;
flex: 1;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
gap: var(--space-lg);
padding: var(--space-lg);
}
.todo-view-sidebar {
@@ -444,20 +479,4 @@
.todo-agent-picker-item {
min-height: calc(var(--space-2xl) + var(--space-xs));
}
.todo-view--mobile-keyboard-active {
padding-bottom: 0;
}
.todo-view--mobile-keyboard-active .todo-view-layout {
height: 100%;
}
.todo-view--mobile-keyboard-active .todo-view-sidebar {
max-height: calc(var(--space-2xl) * 4);
}
.todo-view--mobile-keyboard-active .todo-view-main {
overscroll-behavior: contain;
}
}

View File

@@ -27,8 +27,6 @@ interface TodoViewProps {
addToast: (message: string, type?: "success" | "error" | "info") => void;
onPlanningMode?: (initialPlan: string) => void;
onTaskCreated?: (task: Task) => void;
onClose?: () => void;
mobileKeyboardActive?: boolean;
}
function sortItems(items: TodoItem[]): TodoItem[] {
@@ -40,7 +38,6 @@ export function TodoView({
addToast,
onPlanningMode,
onTaskCreated,
mobileKeyboardActive = false,
}: TodoViewProps) {
const { t } = useTranslation("app");
const {
@@ -305,9 +302,22 @@ export function TodoView({
}
}, [projectId, addToast, agents, onTaskCreated, t]);
const header = (
<header className="todo-view-header">
<div className="todo-view-title-group">
<ListChecks aria-hidden="true" />
<div>
<h2>{t("todo.todos", "Todos")}</h2>
<p>{t("todo.manageDescription", "Manage reusable todo lists for your project.")}</p>
</div>
</div>
</header>
);
if (loading) {
return (
<div className="todo-view">
<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>
@@ -317,10 +327,8 @@ export function TodoView({
}
return (
<div
className={`todo-view${mobileKeyboardActive ? " todo-view--mobile-keyboard-active" : ""}`}
data-testid="todo-view-root"
>
<div className="todo-view" data-testid="todo-view-root">
{header}
<div className="todo-view-layout">
<aside className="todo-view-sidebar" aria-label={t("todo.listsLabel", "Todo lists sidebar")}>
<div className="todo-sidebar-header">

View File

@@ -51,14 +51,6 @@ vi.mock("../FileBrowserModal", () => ({
FileBrowserModal: () => null,
}));
const mockTodoModalProps = vi.fn();
vi.mock("../TodoModal", () => ({
TodoModal: (props: any) => {
mockTodoModalProps(props);
return null;
},
}));
vi.mock("../UsageIndicator", () => ({
UsageIndicator: () => null,
}));
@@ -169,7 +161,6 @@ describe("AppModals", () => {
terminalInitialCommandGeneration: 0,
scriptsOpen: false,
filesOpen: false,
todosOpen: false,
fileBrowserWorkspace: "project",
fileBrowserInitialFile: null,
usageOpen: false,
@@ -206,8 +197,6 @@ describe("AppModals", () => {
runScript: vi.fn(),
openFiles: vi.fn(),
closeFiles: vi.fn(),
openTodos: vi.fn(),
closeTodos: vi.fn(),
setFileWorkspace: vi.fn(),
openUsage: vi.fn(),
closeUsage: vi.fn(),
@@ -246,7 +235,6 @@ describe("AppModals", () => {
mockModelOnboardingModalProps.mockClear();
mockActivityLogModalProps.mockClear();
mockSettingsModalProps.mockClear();
mockTodoModalProps.mockClear();
});
it("renders without crashing", () => {
@@ -270,50 +258,6 @@ describe("AppModals", () => {
expect(document.body).toBeDefined();
});
it("renders TodoModal when todosOpen is true", () => {
render(
<AppModals
projectId="proj-1"
tasks={[]}
projects={[]}
currentProject={null}
addToast={vi.fn()}
toasts={mockToasts}
removeToast={vi.fn()}
modalManager={{ ...mockModalManager, todosOpen: true }}
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
deepLink={{ handleDetailClose: vi.fn() }}
settings={mockSettings}
/>
);
expect(mockTodoModalProps).toHaveBeenCalledTimes(1);
});
it("does not render TodoModal when todosOpen is false", () => {
render(
<AppModals
projectId="proj-1"
tasks={[]}
projects={[]}
currentProject={null}
addToast={vi.fn()}
toasts={mockToasts}
removeToast={vi.fn()}
modalManager={{ ...mockModalManager, todosOpen: false }}
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
deepLink={{ handleDetailClose: vi.fn() }}
settings={mockSettings}
/>
);
expect(mockTodoModalProps).not.toHaveBeenCalled();
});
it("passes the live board task snapshot into the open detail modal while preserving prompt data", async () => {
const manager = {
...mockModalManager,

View File

@@ -251,7 +251,7 @@ describe("Header", () => {
});
it("shows the Todos entry in view overflow when todos are enabled", () => {
renderHeader({ onChangeView: noop, onOpenTodos: vi.fn(), todosEnabled: true });
renderHeader({ onChangeView: noop, todosEnabled: true });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
});
@@ -559,7 +559,7 @@ describe("Header", () => {
describe("todos navigation", () => {
for (const tier of ["desktop", "tablet"] as const) {
it(`shows Todos only in More views and Mailbox only top-level on ${tier}`, () => {
renderHeader({ onChangeView: noop, onOpenTodos: vi.fn(), todosEnabled: true }, tier);
renderHeader({ onChangeView: noop, todosEnabled: true }, tier);
expect(screen.queryByTestId("todos-toggle-btn")).toBeNull();
expect(screen.getByTitle("Mailbox view")).toBeInTheDocument();
@@ -572,17 +572,25 @@ describe("Header", () => {
}
it("does not show Todos entry in More views when disabled", () => {
renderHeader({ onChangeView: noop, onOpenTodos: vi.fn(), todosEnabled: false }, "desktop");
renderHeader({ onChangeView: noop, todosEnabled: false }, "desktop");
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-overflow-todos")).toBeNull();
});
it("calls onOpenTodos from More views", () => {
const onOpenTodos = vi.fn();
renderHeader({ onChangeView: noop, onOpenTodos, todosEnabled: true }, "desktop");
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
fireEvent.click(screen.getByTestId("view-overflow-todos"));
expect(onOpenTodos).toHaveBeenCalled();
it("routes to todos from More views and marks active state", () => {
const onChangeView = vi.fn();
renderHeader({ onChangeView, view: "todos", todosEnabled: true }, "desktop");
const trigger = screen.getByTestId("view-toggle-overflow-trigger");
expect(trigger.className).toContain("active");
fireEvent.click(trigger);
const todosItem = screen.getByTestId("view-overflow-todos");
expect(todosItem.className).toContain("active");
fireEvent.click(todosItem);
expect(onChangeView).toHaveBeenCalledWith("todos");
expect(screen.queryByTestId("view-overflow-todos")).toBeNull();
});
});

View File

@@ -401,10 +401,9 @@ describe("LeftSidebarNav", () => {
expect(window.localStorage.getItem("fusion:left-sidebar-width")).toBe("336");
});
it("routes clicks to view changes, todos callback, and settings callback", () => {
const onOpenTodos = vi.fn();
it("routes clicks to view changes, todos view, and settings callback", () => {
const onOpenSettings = vi.fn();
const { onChangeView } = renderSidebar({ todosEnabled: true, onOpenTodos, onOpenSettings });
const { onChangeView } = renderSidebar({ todosEnabled: true, onOpenSettings });
fireEvent.click(screen.getByTestId("sidebar-nav-list"));
expect(onChangeView).toHaveBeenCalledWith("list");
@@ -413,7 +412,7 @@ describe("LeftSidebarNav", () => {
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-overflow:overflow-view");
fireEvent.click(screen.getByTestId("sidebar-nav-todos"));
expect(onOpenTodos).toHaveBeenCalledOnce();
expect(onChangeView).toHaveBeenCalledWith("todos");
fireEvent.click(screen.getByTestId("sidebar-nav-settings"));
expect(onOpenSettings).toHaveBeenCalledOnce();

View File

@@ -222,12 +222,11 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-spacing-check-wide")).toBeDefined();
});
it("keeps Todos in the mobile More sheet when todoView is enabled", () => {
const onOpenTodos = vi.fn();
it("keeps Todos in the mobile More sheet and routes to the todos view", () => {
const props = createDefaultProps();
render(
<MobileNavBar
{...createDefaultProps()}
onOpenTodos={onOpenTodos}
{...props}
experimentalFeatures={{ todoView: true }}
/>,
);
@@ -235,7 +234,7 @@ describe("MobileNavBar", () => {
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-item-todos"));
expect(onOpenTodos).toHaveBeenCalled();
expect(props.onChangeView).toHaveBeenCalledWith("todos");
});
it("Mailbox is a primary tab and is not duplicated in the More sheet", () => {
@@ -251,7 +250,6 @@ describe("MobileNavBar", () => {
render(
<MobileNavBar
{...createDefaultProps()}
onOpenTodos={vi.fn()}
experimentalFeatures={{ todoView: true }}
/>,
);
@@ -262,6 +260,18 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-todos")).toBeInTheDocument();
});
it("marks the mobile More tab active for the todos view", () => {
render(
<MobileNavBar
{...createDefaultProps()}
view="todos"
experimentalFeatures={{ todoView: true }}
/>,
);
expect(screen.getByTestId("mobile-nav-tab-more")).toHaveClass("mobile-nav-tab--active");
});
it("shows secrets in More and routes to secrets view", () => {
const props = createDefaultProps();
render(<MobileNavBar {...props} />);

View File

@@ -1,136 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { TodoModal } from "../TodoModal";
const mockTodoView = vi.fn();
const mockUseMobileKeyboard = vi.fn();
const mockUseViewportMode = vi.fn();
vi.mock("../TodoView", () => ({
TodoView: (props: unknown) => {
mockTodoView(props);
return <div data-testid="todo-view-content">Todo content</div>;
},
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
getViewportMode: () => mockUseViewportMode(),
isMobileViewport: () => mockUseViewportMode() === "mobile",
useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args),
}));
describe("TodoModal", () => {
const onClose = vi.fn();
const addToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
});
it("renders modal dialog semantics and header content", () => {
render(<TodoModal onClose={onClose} addToast={addToast} />);
expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true");
expect(screen.getByRole("heading", { name: "Todos" })).toBeInTheDocument();
expect(screen.getByText("Manage reusable todo lists for your project.")).toBeInTheDocument();
});
it("closes on Escape", () => {
render(<TodoModal onClose={onClose} addToast={addToast} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("closes on overlay backdrop click", () => {
render(<TodoModal onClose={onClose} addToast={addToast} />);
const overlay = screen.getByRole("dialog");
fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("closes from close button", () => {
render(<TodoModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("passes expected props through the lazy-loaded TodoView", async () => {
const onPlanningMode = vi.fn();
render(
<TodoModal
onClose={onClose}
addToast={addToast}
projectId="proj-1"
onPlanningMode={onPlanningMode}
/>,
);
expect(await screen.findByTestId("todo-view-content")).toBeInTheDocument();
await waitFor(() => {
expect(mockTodoView).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "proj-1",
addToast,
onPlanningMode,
onClose,
mobileKeyboardActive: false,
}),
);
});
});
describe("mobile keyboard behavior", () => {
it("applies CSS variables when keyboard is open on mobile", () => {
mockUseViewportMode.mockReturnValue("mobile");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 250,
viewportHeight: 450,
viewportOffsetTop: 40,
keyboardOpen: true,
});
render(<TodoModal onClose={onClose} addToast={addToast} />);
const modal = screen.getByRole("dialog").querySelector(".modal.todo-modal");
expect(modal).toBeTruthy();
const style = (modal as HTMLElement).style;
expect(style.getPropertyValue("--keyboard-overlap")).toBe("250px");
expect(style.getPropertyValue("--vv-offset-top")).toBe("40px");
expect(style.getPropertyValue("--vv-height")).toBe("450px");
expect(mockTodoView).toHaveBeenCalledWith(expect.objectContaining({ mobileKeyboardActive: true }));
});
it("does not apply keyboard CSS variables when keyboard is closed", () => {
mockUseViewportMode.mockReturnValue("mobile");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
render(<TodoModal onClose={onClose} addToast={addToast} />);
const modal = screen.getByRole("dialog").querySelector(".modal.todo-modal");
expect(modal).toBeTruthy();
const style = (modal as HTMLElement).style;
expect(style.getPropertyValue("--keyboard-overlap")).toBe("");
expect(style.getPropertyValue("--vv-offset-top")).toBe("");
expect(style.getPropertyValue("--vv-height")).toBe("");
expect(mockTodoView).toHaveBeenCalledWith(expect.objectContaining({ mobileKeyboardActive: false }));
});
});
});

View File

@@ -16,11 +16,4 @@ describe("TodoView action row CSS contract", () => {
expect(css).toMatch(/\.todo-item-actions\s*\{[^}]*margin-left:\s*calc\(var\(--space-lg\) \+ var\(--space-sm\)\);/);
expect(css).toMatch(/@media \(max-width:\s*768px\)[^{]*\{[\s\S]*\.todo-item-actions\s*\{[^}]*opacity:\s*1;[^}]*\}/);
});
it("applies keyboard-active mobile layout containment rules", () => {
const css = loadAllAppCss();
expect(css).toMatch(/@media \(max-width:\s*768px\)[^{]*\{[\s\S]*\.todo-view--mobile-keyboard-active \.todo-view-layout\s*\{[^}]*height:\s*100%;[^}]*\}/);
expect(css).toMatch(/@media \(max-width:\s*768px\)[^{]*\{[\s\S]*\.todo-view--mobile-keyboard-active \.todo-view-main\s*\{[^}]*overscroll-behavior:\s*contain;[^}]*\}/);
});
});

View File

@@ -77,17 +77,18 @@ describe("TodoView", () => {
mockUseTodoLists.mockReturnValue(createMockTodoLists());
});
it("renders the docked view header", () => {
render(<TodoView addToast={addToast} />);
expect(screen.getByRole("heading", { level: 2, name: "Todos" })).toBeInTheDocument();
expect(screen.getByText("Manage reusable todo lists for your project.")).toBeInTheDocument();
});
it("renders sidebar with list names", () => {
render(<TodoView addToast={addToast} />);
expect(screen.getByTestId("todo-list-list-1")).toHaveTextContent("My List");
expect(screen.getByTestId("todo-list-list-2")).toHaveTextContent("Work Tasks");
});
it("applies keyboard-active root class when mobileKeyboardActive is true", () => {
render(<TodoView addToast={addToast} mobileKeyboardActive />);
expect(screen.getByTestId("todo-view-root")).toHaveClass("todo-view--mobile-keyboard-active");
});
it("renders only items for the selected list", () => {
render(<TodoView addToast={addToast} />);
expect(screen.getByText("Buy groceries")).toBeInTheDocument();

View File

@@ -325,7 +325,6 @@ function AppShellMobileHarness({ tasks }: { tasks: Task[] }) {
onOpenScripts={vi.fn()}
onToggleTerminal={vi.fn()}
onOpenFiles={vi.fn()}
onOpenTodos={vi.fn()}
onOpenGitHubImport={vi.fn()}
onOpenPlanning={vi.fn()}
onResumePlanning={vi.fn()}

View File

@@ -25,7 +25,7 @@ const defaultSettings: Settings = {
worktreeInitCommand: "",
testCommand: "",
buildCommand: "",
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, evalsView: true },
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, evalsView: true, todoView: true },
};
const mockSubscribeSse = vi.fn((..._args: any[]) => vi.fn());
@@ -85,6 +85,7 @@ const mockUseTasks = vi.fn(() => ({
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
refreshTasks: vi.fn(),
}));
vi.mock("../../hooks/useTasks", () => ({
@@ -358,6 +359,7 @@ describe("Navigation history integration", () => {
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
refreshTasks: vi.fn(),
}));
mockProjectsState.projects = [];
mockProjectsState.loading = false;
@@ -489,6 +491,29 @@ describe("Navigation history integration", () => {
expect((window.history.pushState as any).mock.calls.length).toBeGreaterThan(pushCallsBefore);
});
it("pushes history entry and reverts when switching to todos from overflow", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
const taskViewStorageKey = scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID);
localStorage.setItem(taskViewStorageKey, "board");
await renderAppAndWait();
const pushCallsBefore = (window.history.pushState as any).mock.calls.length;
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
fireEvent.click(screen.getByTestId("view-overflow-todos"));
await waitFor(() => {
expect(screen.getByTestId("todo-view")).toBeTruthy();
});
expect((window.history.pushState as any).mock.calls.length).toBeGreaterThan(pushCallsBefore);
dispatchPopState({ navIndex: 0 });
await waitFor(() => {
expect(screen.queryByTestId("todo-view")).toBeNull();
expect(screen.getByTestId("board-view")).toBeTruthy();
});
});
// 4. Desktop: popstate reverts view changes
it("reverts view change on popstate in desktop mode", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
@@ -529,6 +554,7 @@ describe("Navigation history integration", () => {
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
refreshTasks: vi.fn(),
}));
await renderMobileAppAndWait();
@@ -561,6 +587,7 @@ describe("Navigation history integration", () => {
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
refreshTasks: vi.fn(),
}));
await renderMobileAppAndWait();

View File

@@ -46,7 +46,7 @@ export function isIOS(): boolean {
* area aligned with the layout viewport.
*
* Reference counting matters because multiple overlays can be open at once
* (e.g. a confirm dialog over a TodoModal) — only the outermost lock should
* (e.g. a confirm dialog over another modal) — only the outermost lock should
* actually mutate styles, so an inner unmount doesn't release the lock for
* an outer overlay that is still open.
*/

View File

@@ -52,7 +52,6 @@ export interface ModalManager {
terminalInitialCommand: string | undefined;
terminalInitialCommandGeneration: number;
filesOpen: boolean;
todosOpen: boolean;
fileBrowserWorkspace: string;
fileBrowserInitialFile: string | null;
activityLogOpen: boolean;
@@ -114,8 +113,6 @@ export interface ModalManager {
openFiles: (workspace?: string, initialFile?: string | null) => void;
closeFiles: () => void;
openTodos: () => void;
closeTodos: () => void;
setFileWorkspace: (workspace: string) => void;
openActivityLog: () => void;
@@ -184,7 +181,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [terminalInitialCommandGeneration, setTerminalInitialCommandGeneration] = useState(0);
const [filesOpen, setFilesOpen] = useState(false);
const [todosOpen, setTodosOpen] = useState(false);
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
const [fileBrowserInitialFile, setFileBrowserInitialFile] = useState<string | null>(null);
const [activityLogOpen, setActivityLogOpen] = useState(false);
@@ -207,7 +203,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
isSubtaskOpen ||
terminalOpen ||
filesOpen ||
todosOpen ||
activityLogOpen ||
gitManagerOpen ||
workflowEditorOpen ||
@@ -376,8 +371,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
setFilesOpen(false);
setFileBrowserInitialFile(null);
}, []);
const openTodos = useCallback(() => setTodosOpen(true), []);
const closeTodos = useCallback(() => setTodosOpen(false), []);
const setFileWorkspace = useCallback((workspace: string) => {
if (typeof workspace === "string" && workspace) {
setFileBrowserWorkspace(workspace);
@@ -468,7 +461,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
terminalInitialCommand,
terminalInitialCommandGeneration,
filesOpen,
todosOpen,
fileBrowserWorkspace,
fileBrowserInitialFile,
activityLogOpen,
@@ -511,8 +503,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
closeTerminal,
openFiles,
closeFiles,
openTodos,
closeTodos,
setFileWorkspace,
openActivityLog,
closeActivityLog,

View File

@@ -5,7 +5,7 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
export type ViewMode = "overview" | "project";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "stash-recovery" | "pull-requests";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "stash-recovery" | "pull-requests";
export type PluginTaskView = `plugin:${string}:${string}`;
export type TaskView = BuiltInTaskView | PluginTaskView;
@@ -20,6 +20,11 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
"research",
"evals",
"goalsView",
/*
FNXC:ViewState 2026-06-21-09:14:
FN-6829 promotes project Todos from modal-only state into the persisted built-in task-view registry so dashboard navigation can dock it in the right content area.
*/
"todos",
"skills",
"mailbox",