feat(FN-3104): switch todos to modal-only navigation
- Add TodoModal component and styling, and mount it through AppModals - Remove dedicated Todos view routing and drive todos access through modal state/actions - Update Header and MobileNavBar interactions plus modal manager/view-state hooks for modal flow - Expand dashboard tests to cover modal rendering, open/close behavior, and updated app/header expectations Fusion-Task-Id: FN-3104
This commit is contained in:
@@ -78,7 +78,7 @@ const RoadmapsView = lazy(() => import("./components/RoadmapsView").then((m) =>
|
|||||||
const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ default: m.SkillsView })));
|
const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ default: m.SkillsView })));
|
||||||
const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView })));
|
const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView })));
|
||||||
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
|
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 })));
|
||||||
|
|
||||||
// Warm lazy chunks during browser idle so first navigation to each view is
|
// Warm lazy chunks during browser idle so first navigation to each view is
|
||||||
// instant. Each chunk is ~10–80 kB; total prefetch finishes well under a
|
// instant. Each chunk is ~10–80 kB; total prefetch finishes well under a
|
||||||
@@ -405,13 +405,10 @@ function AppInner() {
|
|||||||
if ((taskView === "devserver" || taskView === "dev-server") && !devServerEnabled) {
|
if ((taskView === "devserver" || taskView === "dev-server") && !devServerEnabled) {
|
||||||
handleChangeTaskView("board");
|
handleChangeTaskView("board");
|
||||||
}
|
}
|
||||||
if (taskView === "todos" && !todosEnabled) {
|
|
||||||
handleChangeTaskView("board");
|
|
||||||
}
|
|
||||||
if (taskView === "research" && !researchEnabled) {
|
if (taskView === "research" && !researchEnabled) {
|
||||||
handleChangeTaskView("board");
|
handleChangeTaskView("board");
|
||||||
}
|
}
|
||||||
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, todosEnabled, researchEnabled]);
|
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled]);
|
||||||
|
|
||||||
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
|
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -822,24 +819,6 @@ function AppInner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (taskView === "todos") {
|
|
||||||
if (!settingsLoaded || !todosEnabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<PageErrorBoundary>
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<TodoView
|
|
||||||
addToast={addToast}
|
|
||||||
projectId={currentProject?.id}
|
|
||||||
onPlanningMode={modalManager.openPlanningWithInitialPlan}
|
|
||||||
onTaskCreated={(task) => ingestCreatedTasks([task])}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</PageErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (taskView === "devserver" || taskView === "dev-server") {
|
if (taskView === "devserver" || taskView === "dev-server") {
|
||||||
if (!settingsLoaded || !devServerEnabled) {
|
if (!settingsLoaded || !devServerEnabled) {
|
||||||
return null;
|
return null;
|
||||||
@@ -961,6 +940,9 @@ function AppInner() {
|
|||||||
onToggleTerminal={modalManager.toggleTerminal}
|
onToggleTerminal={modalManager.toggleTerminal}
|
||||||
onOpenFiles={modalManager.openFiles}
|
onOpenFiles={modalManager.openFiles}
|
||||||
filesOpen={modalManager.filesOpen}
|
filesOpen={modalManager.filesOpen}
|
||||||
|
onOpenTodos={modalManager.openTodos}
|
||||||
|
todosOpen={modalManager.todosOpen}
|
||||||
|
todosEnabled={todosEnabled}
|
||||||
globalPaused={globalPaused}
|
globalPaused={globalPaused}
|
||||||
enginePaused={enginePaused}
|
enginePaused={enginePaused}
|
||||||
onToggleGlobalPause={toggleGlobalPause}
|
onToggleGlobalPause={toggleGlobalPause}
|
||||||
@@ -994,7 +976,6 @@ function AppInner() {
|
|||||||
memoryView: memoryEnabled,
|
memoryView: memoryEnabled,
|
||||||
devServer: devServerEnabled,
|
devServer: devServerEnabled,
|
||||||
devServerView: devServerEnabled,
|
devServerView: devServerEnabled,
|
||||||
todoView: todosEnabled,
|
|
||||||
researchView: researchEnabled,
|
researchView: researchEnabled,
|
||||||
}}
|
}}
|
||||||
pluginDashboardViews={pluginDashboardViews}
|
pluginDashboardViews={pluginDashboardViews}
|
||||||
@@ -1072,6 +1053,8 @@ function AppInner() {
|
|||||||
onOpenScripts={modalManager.openScripts}
|
onOpenScripts={modalManager.openScripts}
|
||||||
onToggleTerminal={modalManager.toggleTerminal}
|
onToggleTerminal={modalManager.toggleTerminal}
|
||||||
onOpenFiles={modalManager.openFiles}
|
onOpenFiles={modalManager.openFiles}
|
||||||
|
onOpenTodos={modalManager.openTodos}
|
||||||
|
todosOpen={modalManager.todosOpen}
|
||||||
onOpenGitHubImport={modalManager.openGitHubImport}
|
onOpenGitHubImport={modalManager.openGitHubImport}
|
||||||
onOpenPlanning={modalManager.openPlanning}
|
onOpenPlanning={modalManager.openPlanning}
|
||||||
onResumePlanning={modalManager.resumePlanning}
|
onResumePlanning={modalManager.resumePlanning}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
|
|||||||
import { TerminalModal } from "./TerminalModal";
|
import { TerminalModal } from "./TerminalModal";
|
||||||
import { ScriptsModal } from "./ScriptsModal";
|
import { ScriptsModal } from "./ScriptsModal";
|
||||||
import { FileBrowserModal } from "./FileBrowserModal";
|
import { FileBrowserModal } from "./FileBrowserModal";
|
||||||
|
import { TodoModal } from "./TodoModal";
|
||||||
import { UsageIndicator } from "./UsageIndicator";
|
import { UsageIndicator } from "./UsageIndicator";
|
||||||
import { ScheduledTasksModal } from "./ScheduledTasksModal";
|
import { ScheduledTasksModal } from "./ScheduledTasksModal";
|
||||||
import { NewTaskModal } from "./NewTaskModal";
|
import { NewTaskModal } from "./NewTaskModal";
|
||||||
@@ -254,6 +255,16 @@ export function AppModals({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{modalManager.todosOpen && (
|
||||||
|
<TodoModal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={modalManager.closeTodos}
|
||||||
|
addToast={addToast}
|
||||||
|
projectId={projectId}
|
||||||
|
onPlanningMode={modalManager.openPlanningWithInitialPlan}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<UsageIndicator
|
<UsageIndicator
|
||||||
isOpen={modalManager.usageOpen}
|
isOpen={modalManager.usageOpen}
|
||||||
onClose={modalManager.closeUsage}
|
onClose={modalManager.closeUsage}
|
||||||
|
|||||||
@@ -192,6 +192,9 @@ export interface HeaderProps {
|
|||||||
/** Opens the top-level workspace-aware file browser modal. */
|
/** Opens the top-level workspace-aware file browser modal. */
|
||||||
onOpenFiles?: () => void;
|
onOpenFiles?: () => void;
|
||||||
filesOpen?: boolean;
|
filesOpen?: boolean;
|
||||||
|
onOpenTodos?: () => void;
|
||||||
|
todosOpen?: boolean;
|
||||||
|
todosEnabled?: boolean;
|
||||||
globalPaused?: boolean;
|
globalPaused?: boolean;
|
||||||
enginePaused?: boolean;
|
enginePaused?: boolean;
|
||||||
onToggleGlobalPause?: () => void;
|
onToggleGlobalPause?: () => void;
|
||||||
@@ -222,7 +225,7 @@ export interface HeaderProps {
|
|||||||
/** Whether the current view is a remote node */
|
/** Whether the current view is a remote node */
|
||||||
isRemote?: boolean;
|
isRemote?: boolean;
|
||||||
/** Experimental feature flags controlling visibility of nav items. */
|
/** Experimental feature flags controlling visibility of nav items. */
|
||||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean; researchView?: boolean };
|
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean };
|
||||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +250,9 @@ export function Header({
|
|||||||
onToggleTerminal,
|
onToggleTerminal,
|
||||||
onOpenFiles,
|
onOpenFiles,
|
||||||
filesOpen,
|
filesOpen,
|
||||||
|
onOpenTodos,
|
||||||
|
todosOpen,
|
||||||
|
todosEnabled,
|
||||||
globalPaused,
|
globalPaused,
|
||||||
enginePaused,
|
enginePaused,
|
||||||
onToggleGlobalPause,
|
onToggleGlobalPause,
|
||||||
@@ -334,7 +340,7 @@ export function Header({
|
|||||||
const hasViewOverflowItems = useMemo(() => {
|
const hasViewOverflowItems = useMemo(() => {
|
||||||
return !!(
|
return !!(
|
||||||
experimentalFeatures?.researchView ||
|
experimentalFeatures?.researchView ||
|
||||||
experimentalFeatures?.todoView ||
|
todosEnabled ||
|
||||||
experimentalFeatures?.insights ||
|
experimentalFeatures?.insights ||
|
||||||
experimentalFeatures?.roadmap ||
|
experimentalFeatures?.roadmap ||
|
||||||
showSkillsTab ||
|
showSkillsTab ||
|
||||||
@@ -343,7 +349,7 @@ export function Header({
|
|||||||
!hideFullNav ||
|
!hideFullNav ||
|
||||||
pluginDashboardViews.some((entry) => entry.view.placement !== "primary")
|
pluginDashboardViews.some((entry) => entry.view.placement !== "primary")
|
||||||
);
|
);
|
||||||
}, [experimentalFeatures, showSkillsTab, hideFullNav, pluginDashboardViews]);
|
}, [experimentalFeatures, todosEnabled, showSkillsTab, hideFullNav, pluginDashboardViews]);
|
||||||
|
|
||||||
const getEffectiveViewport = useCallback(() => {
|
const getEffectiveViewport = useCallback(() => {
|
||||||
const vv = window.visualViewport;
|
const vv = window.visualViewport;
|
||||||
@@ -1117,7 +1123,7 @@ export function Header({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
ref={viewOverflowTriggerRef}
|
ref={viewOverflowTriggerRef}
|
||||||
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (experimentalFeatures?.todoView && view === "todos") || view.startsWith("plugin:") ? " active" : ""}`}
|
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (todosEnabled && todosOpen) || view.startsWith("plugin:") ? " active" : ""}`}
|
||||||
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||||
title="More views"
|
title="More views"
|
||||||
aria-label="More views"
|
aria-label="More views"
|
||||||
@@ -1219,11 +1225,11 @@ export function Header({
|
|||||||
<span className="visually-hidden" data-testid="view-toggle-dev-server" />
|
<span className="visually-hidden" data-testid="view-toggle-dev-server" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{experimentalFeatures?.todoView && (
|
{todosEnabled && onOpenTodos && (
|
||||||
<button
|
<button
|
||||||
className={`view-toggle-overflow-item${view === "todos" ? " active" : ""}`}
|
className={`view-toggle-overflow-item${todosOpen ? " active" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChangeView("todos");
|
onOpenTodos();
|
||||||
setIsViewOverflowOpen(false);
|
setIsViewOverflowOpen(false);
|
||||||
}}
|
}}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -1438,6 +1444,17 @@ export function Header({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isCompact && todosEnabled && onOpenTodos && (
|
||||||
|
<button
|
||||||
|
className={`btn-icon${todosOpen ? " btn-icon--active" : ""}`}
|
||||||
|
onClick={onOpenTodos}
|
||||||
|
title="Open todos"
|
||||||
|
data-testid="todos-toggle-btn"
|
||||||
|
>
|
||||||
|
<CheckSquare size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Git Manager button - desktop only (moved to overflow on mobile/tablet) */}
|
{/* Git Manager button - desktop only (moved to overflow on mobile/tablet) */}
|
||||||
{!isCompact && onOpenGitManager && (
|
{!isCompact && onOpenGitManager && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export interface MobileNavBarProps {
|
|||||||
onOpenScripts?: () => void;
|
onOpenScripts?: () => void;
|
||||||
onToggleTerminal?: () => void;
|
onToggleTerminal?: () => void;
|
||||||
onOpenFiles?: () => void;
|
onOpenFiles?: () => void;
|
||||||
|
onOpenTodos?: () => void;
|
||||||
|
todosOpen?: boolean;
|
||||||
onOpenGitHubImport?: () => void;
|
onOpenGitHubImport?: () => void;
|
||||||
onOpenPlanning?: () => void;
|
onOpenPlanning?: () => void;
|
||||||
onResumePlanning?: () => void;
|
onResumePlanning?: () => void;
|
||||||
@@ -108,6 +110,8 @@ export function MobileNavBar({
|
|||||||
onOpenScripts,
|
onOpenScripts,
|
||||||
onToggleTerminal,
|
onToggleTerminal,
|
||||||
onOpenFiles,
|
onOpenFiles,
|
||||||
|
onOpenTodos,
|
||||||
|
todosOpen = false,
|
||||||
onOpenGitHubImport,
|
onOpenGitHubImport,
|
||||||
onOpenPlanning,
|
onOpenPlanning,
|
||||||
onResumePlanning,
|
onResumePlanning,
|
||||||
@@ -200,7 +204,7 @@ export function MobileNavBar({
|
|||||||
|| view === "memory"
|
|| view === "memory"
|
||||||
|| view === "devserver"
|
|| view === "devserver"
|
||||||
|| view === "dev-server"
|
|| view === "dev-server"
|
||||||
|| (view === "todos" && todoViewEnabled)
|
|| (todosOpen && todoViewEnabled)
|
||||||
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
||||||
|| (view === "skills" && !showSkillsTopLevel)
|
|| (view === "skills" && !showSkillsTopLevel)
|
||||||
|| view.startsWith("plugin:");
|
|| view.startsWith("plugin:");
|
||||||
@@ -625,7 +629,7 @@ export function MobileNavBar({
|
|||||||
type="button"
|
type="button"
|
||||||
className="mobile-more-item"
|
className="mobile-more-item"
|
||||||
data-testid="mobile-more-item-todos"
|
data-testid="mobile-more-item-todos"
|
||||||
onClick={() => handleMoreAction(() => onChangeView("todos"))}
|
onClick={() => handleMoreAction(() => onOpenTodos?.())}
|
||||||
>
|
>
|
||||||
<CheckSquare />
|
<CheckSquare />
|
||||||
<span>Todos</span>
|
<span>Todos</span>
|
||||||
|
|||||||
64
packages/dashboard/app/components/TodoModal.css
Normal file
64
packages/dashboard/app/components/TodoModal.css
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
packages/dashboard/app/components/TodoModal.tsx
Normal file
56
packages/dashboard/app/components/TodoModal.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import "./TodoModal.css";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { ListChecks, X } from "lucide-react";
|
||||||
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
|
import { TodoView } from "./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 overlayDismissProps = useOverlayDismiss(onClose);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="modal-header todo-modal-header">
|
||||||
|
<div className="todo-modal-header-title">
|
||||||
|
<ListChecks size={18} />
|
||||||
|
<div>
|
||||||
|
<h2>Todos</h2>
|
||||||
|
<p>Manage reusable todo lists for your project.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="todo-modal-body">
|
||||||
|
<TodoView
|
||||||
|
projectId={projectId}
|
||||||
|
addToast={addToast}
|
||||||
|
onPlanningMode={onPlanningMode}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,34 +6,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-view-header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--space-lg) var(--space-xl);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
gap: var(--space-md);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-view-header h2 {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-view-description {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
margin: 2px 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-view-layout {
|
.todo-view-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface TodoViewProps {
|
|||||||
addToast: (message: string, type?: "success" | "error" | "info") => void;
|
addToast: (message: string, type?: "success" | "error" | "info") => void;
|
||||||
onPlanningMode?: (initialPlan: string) => void;
|
onPlanningMode?: (initialPlan: string) => void;
|
||||||
onTaskCreated?: (task: Task) => void;
|
onTaskCreated?: (task: Task) => void;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortItems(items: TodoItem[]): TodoItem[] {
|
function sortItems(items: TodoItem[]): TodoItem[] {
|
||||||
@@ -307,13 +308,6 @@ export function TodoView({ projectId, addToast, onPlanningMode, onTaskCreated }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="todo-view">
|
<div className="todo-view">
|
||||||
<div className="todo-view-header">
|
|
||||||
<div>
|
|
||||||
<h2>Todos</h2>
|
|
||||||
<p className="todo-view-description">Manage reusable todo lists for your project.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="todo-view-layout">
|
<div className="todo-view-layout">
|
||||||
<aside className="todo-view-sidebar" aria-label="Todo lists sidebar">
|
<aside className="todo-view-sidebar" aria-label="Todo lists sidebar">
|
||||||
<div className="todo-sidebar-header">
|
<div className="todo-sidebar-header">
|
||||||
|
|||||||
@@ -1504,7 +1504,6 @@ describe("App view switching", () => {
|
|||||||
|
|
||||||
it("opens planning mode when TodoView triggers planning from todo item", async () => {
|
it("opens planning mode when TodoView triggers planning from todo item", async () => {
|
||||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
localStorage.setItem(taskViewStorageKey(), "todos");
|
|
||||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
experimentalFeatures: {
|
experimentalFeatures: {
|
||||||
@@ -1515,6 +1514,12 @@ describe("App view switching", () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("todos-toggle-btn")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("todos-toggle-btn"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("todo-view")).toBeInTheDocument();
|
expect(screen.getByTestId("todo-view")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ vi.mock("../FileBrowserModal", () => ({
|
|||||||
FileBrowserModal: () => null,
|
FileBrowserModal: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockTodoModalProps = vi.fn();
|
||||||
|
vi.mock("../TodoModal", () => ({
|
||||||
|
TodoModal: (props: any) => {
|
||||||
|
mockTodoModalProps(props);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../UsageIndicator", () => ({
|
vi.mock("../UsageIndicator", () => ({
|
||||||
UsageIndicator: () => null,
|
UsageIndicator: () => null,
|
||||||
}));
|
}));
|
||||||
@@ -145,6 +153,7 @@ describe("AppModals", () => {
|
|||||||
terminalInitialCommand: undefined,
|
terminalInitialCommand: undefined,
|
||||||
scriptsOpen: false,
|
scriptsOpen: false,
|
||||||
filesOpen: false,
|
filesOpen: false,
|
||||||
|
todosOpen: false,
|
||||||
fileBrowserWorkspace: "project",
|
fileBrowserWorkspace: "project",
|
||||||
usageOpen: false,
|
usageOpen: false,
|
||||||
usageAnchorRect: null,
|
usageAnchorRect: null,
|
||||||
@@ -182,6 +191,8 @@ describe("AppModals", () => {
|
|||||||
runScript: vi.fn(),
|
runScript: vi.fn(),
|
||||||
openFiles: vi.fn(),
|
openFiles: vi.fn(),
|
||||||
closeFiles: vi.fn(),
|
closeFiles: vi.fn(),
|
||||||
|
openTodos: vi.fn(),
|
||||||
|
closeTodos: vi.fn(),
|
||||||
setFileWorkspace: vi.fn(),
|
setFileWorkspace: vi.fn(),
|
||||||
openUsage: vi.fn(),
|
openUsage: vi.fn(),
|
||||||
closeUsage: vi.fn(),
|
closeUsage: vi.fn(),
|
||||||
@@ -224,6 +235,7 @@ describe("AppModals", () => {
|
|||||||
mockModelOnboardingModalProps.mockClear();
|
mockModelOnboardingModalProps.mockClear();
|
||||||
mockSettingsModalProps.mockClear();
|
mockSettingsModalProps.mockClear();
|
||||||
mockSystemStatsModalProps.mockClear();
|
mockSystemStatsModalProps.mockClear();
|
||||||
|
mockTodoModalProps.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders without crashing", () => {
|
it("renders without crashing", () => {
|
||||||
@@ -247,6 +259,50 @@ describe("AppModals", () => {
|
|||||||
expect(document.body).toBeDefined();
|
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 () => {
|
it("passes the live board task snapshot into the open detail modal while preserving prompt data", async () => {
|
||||||
const manager = {
|
const manager = {
|
||||||
...mockModalManager,
|
...mockModalManager,
|
||||||
|
|||||||
@@ -146,13 +146,13 @@ describe("Header", () => {
|
|||||||
expect(listBtn.getAttribute("aria-pressed")).toBe("false");
|
expect(listBtn.getAttribute("aria-pressed")).toBe("false");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders view overflow trigger when todoView experimental flag is enabled", () => {
|
it("renders view overflow trigger when todos are enabled", () => {
|
||||||
renderHeader({ onChangeView: noop, experimentalFeatures: { todoView: true } });
|
renderHeader({ onChangeView: noop, todosEnabled: true });
|
||||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the Todos entry in view overflow when todoView is enabled", () => {
|
it("shows the Todos entry in view overflow when todos are enabled", () => {
|
||||||
renderHeader({ onChangeView: noop, experimentalFeatures: { todoView: true } });
|
renderHeader({ onChangeView: noop, onOpenTodos: vi.fn(), todosEnabled: true });
|
||||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||||
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
|
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -190,7 +190,7 @@ describe("Header", () => {
|
|||||||
renderHeader({
|
renderHeader({
|
||||||
onChangeView: noop,
|
onChangeView: noop,
|
||||||
showSkillsTab: false,
|
showSkillsTab: false,
|
||||||
experimentalFeatures: { insights: false, roadmap: false, memoryView: false, devServerView: false, todoView: false },
|
experimentalFeatures: { insights: false, roadmap: false, memoryView: false, devServerView: false },
|
||||||
});
|
});
|
||||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -340,6 +340,25 @@ describe("Header", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("todos button", () => {
|
||||||
|
it("renders todos button on desktop when enabled", () => {
|
||||||
|
renderHeader({ onOpenTodos: vi.fn(), todosEnabled: true }, "desktop");
|
||||||
|
expect(screen.getByTitle("Open todos")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render todos button when disabled", () => {
|
||||||
|
renderHeader({ onOpenTodos: vi.fn(), todosEnabled: false }, "desktop");
|
||||||
|
expect(screen.queryByTitle("Open todos")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onOpenTodos when clicked", () => {
|
||||||
|
const onOpenTodos = vi.fn();
|
||||||
|
renderHeader({ onOpenTodos, todosEnabled: true }, "desktop");
|
||||||
|
fireEvent.click(screen.getByTitle("Open todos"));
|
||||||
|
expect(onOpenTodos).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("pause controls", () => {
|
describe("pause controls", () => {
|
||||||
it("renders engine control split-button", () => {
|
it("renders engine control split-button", () => {
|
||||||
renderHeader();
|
renderHeader();
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { TodoModal } from "../TodoModal";
|
||||||
|
|
||||||
|
const mockTodoView = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../TodoView", () => ({
|
||||||
|
TodoView: (props: unknown) => {
|
||||||
|
mockTodoView(props);
|
||||||
|
return <div data-testid="todo-view-content">Todo content</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("TodoModal", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const addToast = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 projectId and addToast through to TodoView", () => {
|
||||||
|
render(<TodoModal onClose={onClose} addToast={addToast} projectId="proj-1" />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("todo-view-content")).toBeInTheDocument();
|
||||||
|
expect(mockTodoView).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ projectId: "proj-1", addToast }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,11 +78,6 @@ describe("TodoView", () => {
|
|||||||
mockUseTodoLists.mockReturnValue(createMockTodoLists());
|
mockUseTodoLists.mockReturnValue(createMockTodoLists());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders header with Todos heading", () => {
|
|
||||||
render(<TodoView addToast={addToast} />);
|
|
||||||
expect(screen.getByRole("heading", { name: "Todos" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders sidebar with list names", () => {
|
it("renders sidebar with list names", () => {
|
||||||
render(<TodoView addToast={addToast} />);
|
render(<TodoView addToast={addToast} />);
|
||||||
expect(screen.getByTestId("todo-list-list-1")).toHaveTextContent("My List");
|
expect(screen.getByTestId("todo-list-list-1")).toHaveTextContent("My List");
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface ModalManager {
|
|||||||
terminalOpen: boolean;
|
terminalOpen: boolean;
|
||||||
terminalInitialCommand: string | undefined;
|
terminalInitialCommand: string | undefined;
|
||||||
filesOpen: boolean;
|
filesOpen: boolean;
|
||||||
|
todosOpen: boolean;
|
||||||
fileBrowserWorkspace: string;
|
fileBrowserWorkspace: string;
|
||||||
activityLogOpen: boolean;
|
activityLogOpen: boolean;
|
||||||
gitManagerOpen: boolean;
|
gitManagerOpen: boolean;
|
||||||
@@ -90,6 +91,8 @@ export interface ModalManager {
|
|||||||
|
|
||||||
openFiles: () => void;
|
openFiles: () => void;
|
||||||
closeFiles: () => void;
|
closeFiles: () => void;
|
||||||
|
openTodos: () => void;
|
||||||
|
closeTodos: () => void;
|
||||||
setFileWorkspace: (workspace: string) => void;
|
setFileWorkspace: (workspace: string) => void;
|
||||||
|
|
||||||
openActivityLog: () => void;
|
openActivityLog: () => void;
|
||||||
@@ -148,6 +151,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||||
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
||||||
const [filesOpen, setFilesOpen] = useState(false);
|
const [filesOpen, setFilesOpen] = useState(false);
|
||||||
|
const [todosOpen, setTodosOpen] = useState(false);
|
||||||
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
|
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
|
||||||
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
||||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||||
@@ -165,6 +169,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
isSubtaskOpen ||
|
isSubtaskOpen ||
|
||||||
terminalOpen ||
|
terminalOpen ||
|
||||||
filesOpen ||
|
filesOpen ||
|
||||||
|
todosOpen ||
|
||||||
activityLogOpen ||
|
activityLogOpen ||
|
||||||
gitManagerOpen ||
|
gitManagerOpen ||
|
||||||
workflowStepsOpen ||
|
workflowStepsOpen ||
|
||||||
@@ -268,6 +273,8 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
|
|
||||||
const openFiles = useCallback(() => setFilesOpen(true), []);
|
const openFiles = useCallback(() => setFilesOpen(true), []);
|
||||||
const closeFiles = useCallback(() => setFilesOpen(false), []);
|
const closeFiles = useCallback(() => setFilesOpen(false), []);
|
||||||
|
const openTodos = useCallback(() => setTodosOpen(true), []);
|
||||||
|
const closeTodos = useCallback(() => setTodosOpen(false), []);
|
||||||
const setFileWorkspace = useCallback((workspace: string) => {
|
const setFileWorkspace = useCallback((workspace: string) => {
|
||||||
setFileBrowserWorkspace(workspace);
|
setFileBrowserWorkspace(workspace);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -338,6 +345,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
terminalOpen,
|
terminalOpen,
|
||||||
terminalInitialCommand,
|
terminalInitialCommand,
|
||||||
filesOpen,
|
filesOpen,
|
||||||
|
todosOpen,
|
||||||
fileBrowserWorkspace,
|
fileBrowserWorkspace,
|
||||||
activityLogOpen,
|
activityLogOpen,
|
||||||
gitManagerOpen,
|
gitManagerOpen,
|
||||||
@@ -375,6 +383,8 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
closeTerminal,
|
closeTerminal,
|
||||||
openFiles,
|
openFiles,
|
||||||
closeFiles,
|
closeFiles,
|
||||||
|
openTodos,
|
||||||
|
closeTodos,
|
||||||
setFileWorkspace,
|
setFileWorkspace,
|
||||||
openActivityLog,
|
openActivityLog,
|
||||||
closeActivityLog,
|
closeActivityLog,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ProjectInfo } from "../api";
|
|||||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||||
|
|
||||||
export type ViewMode = "overview" | "project";
|
export type ViewMode = "overview" | "project";
|
||||||
export type BuiltInTaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
|
export type BuiltInTaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
|
||||||
export type PluginTaskView = `plugin:${string}:${string}`;
|
export type PluginTaskView = `plugin:${string}:${string}`;
|
||||||
export type TaskView = BuiltInTaskView | PluginTaskView;
|
export type TaskView = BuiltInTaskView | PluginTaskView;
|
||||||
|
|
||||||
@@ -23,7 +23,6 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
|
|||||||
"memory",
|
"memory",
|
||||||
"devserver",
|
"devserver",
|
||||||
"dev-server",
|
"dev-server",
|
||||||
"todos",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {
|
function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {
|
||||||
|
|||||||
Reference in New Issue
Block a user