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:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;[^}]*\}/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user