feat(KB-648): enable parallel test execution and optimize test performance
- Optimize backup tests using fake timers instead of real timeouts - Enable parallel file execution in core, engine, CLI, and dashboard packages - Add inline test helpers to reduce dependencies in dashboard routes tests - Update executor tests with exact command matching and improved assertions - Update AGENTS.md with test optimization patterns (fake timers, unique temp dirs)
This commit is contained in:
@@ -1,20 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2, Folder } from "lucide-react";
|
||||
import { clearActivityLog, type ActivityLogEntry, type ActivityEventType, type ActivityFeedEntry } from "../api";
|
||||
import { useActivityLog } from "../hooks/useActivityLog";
|
||||
import type { Task, ProjectInfo } from "@fusion/core";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { fetchActivityLog, clearActivityLog, type ActivityLogEntry, type ActivityEventType } from "../api";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
interface ActivityLogModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tasks: Task[];
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
/** When provided, shows only activity for this project */
|
||||
projectId?: string;
|
||||
/** List of all projects for filter dropdown */
|
||||
projects?: ProjectInfo[];
|
||||
/** Called when project filter changes */
|
||||
onProjectFilterChange?: (projectId: string | undefined) => void;
|
||||
}
|
||||
|
||||
const EVENT_TYPE_LABELS: Record<ActivityEventType, string> = {
|
||||
@@ -53,71 +46,78 @@ function formatTimestamp(timestamp: string): string {
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
/**
|
||||
* ActivityLogModal - Activity log with project attribution and filtering
|
||||
*
|
||||
* Features:
|
||||
* - Project name badge for each activity entry
|
||||
* - Project filter dropdown (when projects list provided)
|
||||
* - Event type filter
|
||||
* - Real-time updates via useActivityLog hook
|
||||
*/
|
||||
export function ActivityLogModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
tasks,
|
||||
onOpenTaskDetail,
|
||||
projectId,
|
||||
projects = [],
|
||||
onProjectFilterChange,
|
||||
}: ActivityLogModalProps) {
|
||||
export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: ActivityLogModalProps) {
|
||||
const [entries, setEntries] = useState<ActivityLogEntry[]>([]);
|
||||
const [filteredType, setFilteredType] = useState<ActivityEventType | "all">("all");
|
||||
const [filteredProjectId, setFilteredProjectId] = useState<string | "all">(projectId || "all");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfirmClear, setShowConfirmClear] = useState(false);
|
||||
|
||||
// Sync with external projectId prop
|
||||
useEffect(() => {
|
||||
setFilteredProjectId(projectId || "all");
|
||||
}, [projectId]);
|
||||
|
||||
// Convert filters to the format expected by useActivityLog
|
||||
const activityType = filteredType === "all" ? undefined : filteredType;
|
||||
const activeProjectId = filteredProjectId === "all" ? undefined : filteredProjectId;
|
||||
|
||||
// Use the hook for data fetching
|
||||
const {
|
||||
entries,
|
||||
loading: isLoading,
|
||||
error,
|
||||
refresh,
|
||||
hasMore
|
||||
} = useActivityLog({
|
||||
projectId: activeProjectId,
|
||||
type: activityType,
|
||||
limit: 100,
|
||||
autoRefresh: isOpen,
|
||||
});
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const pollingRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Convert entries to ActivityLogEntry format for compatibility
|
||||
const convertedEntries: ActivityLogEntry[] = entries.map((entry: ActivityFeedEntry) => ({
|
||||
id: entry.id,
|
||||
timestamp: entry.timestamp,
|
||||
type: entry.type,
|
||||
taskId: entry.taskId,
|
||||
taskTitle: entry.taskTitle,
|
||||
details: entry.details,
|
||||
metadata: entry.metadata,
|
||||
projectId: entry.projectId,
|
||||
projectName: entry.projectName,
|
||||
}));
|
||||
const loadActivityLog = useCallback(async (since?: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const options: { limit: number; since?: string; type?: ActivityEventType } = {
|
||||
limit: 100,
|
||||
since,
|
||||
};
|
||||
if (filteredType !== "all") {
|
||||
options.type = filteredType;
|
||||
}
|
||||
const data = await fetchActivityLog(options);
|
||||
if (since) {
|
||||
// Append older entries
|
||||
setEntries((prev) => [...prev, ...data]);
|
||||
} else {
|
||||
// Replace with fresh entries
|
||||
setEntries(data);
|
||||
}
|
||||
setHasMore(data.length === 100);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load activity log");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filteredType]);
|
||||
|
||||
// Initial load and filter change
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadActivityLog();
|
||||
}
|
||||
}, [isOpen, loadActivityLog]);
|
||||
|
||||
// Auto-refresh every 30 seconds when modal is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
pollingRef.current = setInterval(() => {
|
||||
loadActivityLog();
|
||||
}, 30000);
|
||||
}
|
||||
return () => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen, loadActivityLog]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (entries.length > 0) {
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
loadActivityLog(lastEntry.timestamp);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearLog = async () => {
|
||||
try {
|
||||
await clearActivityLog();
|
||||
refresh();
|
||||
setEntries([]);
|
||||
setShowConfirmClear(false);
|
||||
} catch (err) {
|
||||
// Error handled by hook
|
||||
setError(err instanceof Error ? err.message : "Failed to clear activity log");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,11 +127,6 @@ export function ActivityLogModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectFilterChange = (value: string) => {
|
||||
setFilteredProjectId(value);
|
||||
onProjectFilterChange?.(value === "all" ? undefined : value);
|
||||
};
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -148,9 +143,6 @@ export function ActivityLogModal({
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose, showConfirmClear]);
|
||||
|
||||
// Determine if any filter is active
|
||||
const isFilterActive = filteredType !== "all" || filteredProjectId !== "all";
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -169,27 +161,7 @@ export function ActivityLogModal({
|
||||
<span>Activity Log</span>
|
||||
</div>
|
||||
<div className="activity-log-actions">
|
||||
{/* Project filter dropdown (when projects provided) */}
|
||||
{projects.length > 0 && (
|
||||
<div className="activity-log-filter activity-log-filter--project">
|
||||
<Folder size={14} />
|
||||
<select
|
||||
value={filteredProjectId}
|
||||
onChange={(e) => handleProjectFilterChange(e.target.value)}
|
||||
className="activity-log-filter-select"
|
||||
data-testid="activity-project-filter"
|
||||
>
|
||||
<option value="all">All Projects</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event type filter dropdown */}
|
||||
{/* Filter dropdown */}
|
||||
<div className="activity-log-filter">
|
||||
<Filter size={14} />
|
||||
<select
|
||||
@@ -210,7 +182,7 @@ export function ActivityLogModal({
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
className="activity-log-refresh"
|
||||
onClick={() => refresh()}
|
||||
onClick={() => loadActivityLog()}
|
||||
disabled={isLoading}
|
||||
title="Refresh"
|
||||
data-testid="activity-refresh"
|
||||
@@ -219,7 +191,7 @@ export function ActivityLogModal({
|
||||
</button>
|
||||
|
||||
{/* Clear button */}
|
||||
{convertedEntries.length > 0 && (
|
||||
{entries.length > 0 && (
|
||||
<button
|
||||
className="activity-log-clear"
|
||||
onClick={() => setShowConfirmClear(true)}
|
||||
@@ -242,33 +214,6 @@ export function ActivityLogModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters display */}
|
||||
{isFilterActive && (
|
||||
<div className="activity-log-active-filters">
|
||||
<span className="activity-log-filter-label">Active filters:</span>
|
||||
{filteredProjectId !== "all" && (
|
||||
<span className="activity-log-filter-badge">
|
||||
Project: {projects.find(p => p.id === filteredProjectId)?.name || filteredProjectId}
|
||||
</span>
|
||||
)}
|
||||
{filteredType !== "all" && (
|
||||
<span className="activity-log-filter-badge">
|
||||
Type: {EVENT_TYPE_LABELS[filteredType]}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="activity-log-clear-filters"
|
||||
onClick={() => {
|
||||
setFilteredType("all");
|
||||
setFilteredProjectId("all");
|
||||
onProjectFilterChange?.(undefined);
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="activity-log-content" data-testid="activity-log-content">
|
||||
{error && (
|
||||
@@ -278,31 +223,15 @@ export function ActivityLogModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{convertedEntries.length === 0 && !isLoading && !error && (
|
||||
{entries.length === 0 && !isLoading && !error && (
|
||||
<div className="activity-log-empty" data-testid="activity-empty">
|
||||
<History size={48} className="activity-log-empty-icon" />
|
||||
<p>
|
||||
{isFilterActive
|
||||
? "No activity matches the current filters"
|
||||
: "No activity recorded yet"}
|
||||
</p>
|
||||
{isFilterActive && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setFilteredType("all");
|
||||
setFilteredProjectId("all");
|
||||
onProjectFilterChange?.(undefined);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
<p>No activity recorded yet</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="activity-log-list">
|
||||
{convertedEntries.map((entry) => (
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="activity-log-entry"
|
||||
@@ -316,13 +245,6 @@ export function ActivityLogModal({
|
||||
<span className="activity-log-entry-type">
|
||||
{EVENT_TYPE_LABELS[entry.type]}
|
||||
</span>
|
||||
{/* Project name badge */}
|
||||
{(entry as ActivityFeedEntry).projectName && (
|
||||
<span className="activity-log-entry-project">
|
||||
<Folder size={10} />
|
||||
{(entry as ActivityFeedEntry).projectName}
|
||||
</span>
|
||||
)}
|
||||
<span className="activity-log-entry-time">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
@@ -364,14 +286,14 @@ export function ActivityLogModal({
|
||||
{hasMore && !isLoading && (
|
||||
<button
|
||||
className="activity-log-load-more"
|
||||
onClick={() => {}}
|
||||
onClick={handleLoadMore}
|
||||
data-testid="activity-load-more"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLoading && convertedEntries.length > 0 && (
|
||||
{isLoading && entries.length > 0 && (
|
||||
<div className="activity-log-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Column } from "./Column";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { useBatchBadgeFetch } from "../hooks/useBatchBadgeFetch";
|
||||
import { Folder } from "lucide-react";
|
||||
import type { ModelInfo } from "../api";
|
||||
|
||||
interface BoardProps {
|
||||
@@ -35,10 +34,7 @@ interface BoardProps {
|
||||
* Called when the user clicks the "Subtask" button in the inline create card.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
/** Project context for multi-project mode */
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
function sortTasksForColumn(tasks: Task[]): Task[] {
|
||||
@@ -57,7 +53,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
||||
return previous.every((task, index) => task === next[index]);
|
||||
}
|
||||
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, projectId, projectName }: BoardProps) {
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const { fetchBatch } = useBatchBadgeFetch();
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -146,42 +142,30 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
}
|
||||
};
|
||||
}, [taskIdsWithBadges, fetchBatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Project context badge */}
|
||||
{projectId && projectName && (
|
||||
<div className="board-project-context">
|
||||
<span className="board-project-badge">
|
||||
<Folder size={14} />
|
||||
{projectName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
<Column
|
||||
key={col}
|
||||
column={col}
|
||||
tasks={tasksByColumn[col]}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={onMoveTask}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
allTasks={filteredTasks}
|
||||
availableModels={availableModels}
|
||||
onOpenFilesForTask={onOpenFilesForTask}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
</>
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
<Column
|
||||
key={col}
|
||||
column={col}
|
||||
tasks={tasksByColumn[col]}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={onMoveTask}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
allTasks={filteredTasks}
|
||||
availableModels={availableModels}
|
||||
onOpenFilesForTask={onOpenFilesForTask}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ interface ColumnProps {
|
||||
* Called when the user clicks the "Subtask" button in the inline create card.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: ColumnProps) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft } from "lucide-react";
|
||||
import type { ProjectInfo } from "@fusion/core";
|
||||
import { ProjectSelector } from "./ProjectSelector";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot } from "lucide-react";
|
||||
|
||||
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
|
||||
function GitHubLogo({ size = 16 }: { size?: number }) {
|
||||
@@ -18,7 +16,7 @@ function GitHubLogo({ size = 16 }: { size?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
export interface HeaderProps {
|
||||
interface HeaderProps {
|
||||
onOpenSettings?: () => void;
|
||||
onOpenGitHubImport?: () => void;
|
||||
onOpenPlanning?: () => void;
|
||||
@@ -28,7 +26,6 @@ export interface HeaderProps {
|
||||
onOpenGitManager?: () => void;
|
||||
onOpenWorkflowSteps?: () => void;
|
||||
onOpenAgents?: () => void;
|
||||
onOpenScripts?: () => void;
|
||||
onToggleTerminal?: () => void;
|
||||
/** Opens the top-level workspace-aware file browser modal. */
|
||||
onOpenFiles?: () => void;
|
||||
@@ -37,15 +34,10 @@ export interface HeaderProps {
|
||||
enginePaused?: boolean;
|
||||
onToggleGlobalPause?: () => void;
|
||||
onToggleEnginePause?: () => void;
|
||||
view?: "board" | "list" | "agents";
|
||||
onChangeView?: (view: "board" | "list" | "agents") => void;
|
||||
view?: "board" | "list";
|
||||
onChangeView?: (view: "board" | "list") => void;
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
/** Multi-project props */
|
||||
projects?: ProjectInfo[];
|
||||
currentProject?: ProjectInfo | null;
|
||||
onSelectProject?: (project: ProjectInfo) => void;
|
||||
onViewAllProjects?: () => void;
|
||||
}
|
||||
|
||||
function useIsMobile() {
|
||||
@@ -75,7 +67,6 @@ export function Header({
|
||||
onOpenGitManager,
|
||||
onOpenWorkflowSteps,
|
||||
onOpenAgents,
|
||||
onOpenScripts,
|
||||
onToggleTerminal,
|
||||
onOpenFiles,
|
||||
filesOpen,
|
||||
@@ -87,10 +78,6 @@ export function Header({
|
||||
onChangeView,
|
||||
searchQuery = "",
|
||||
onSearchChange,
|
||||
projects = [],
|
||||
currentProject,
|
||||
onSelectProject,
|
||||
onViewAllProjects,
|
||||
}: HeaderProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||
@@ -166,35 +153,7 @@ export function Header({
|
||||
<img src="/logo.svg" alt="Fusion logo" className="header-logo" width={24} height={24} />
|
||||
<h1 className="logo">Fusion</h1>
|
||||
<span className="logo-sub">tasks</span>
|
||||
|
||||
{/* Back to All Projects button when viewing a specific project */}
|
||||
{currentProject && onViewAllProjects && (
|
||||
<button
|
||||
className="header-back-button"
|
||||
onClick={onViewAllProjects}
|
||||
title="Back to All Projects"
|
||||
data-testid="back-to-projects-btn"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
<span>All Projects</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project Selector - shown when 2+ projects */}
|
||||
{projects.length > 1 && (
|
||||
<div className="header-project-selector">
|
||||
<ProjectSelector
|
||||
projects={projects}
|
||||
currentProject={currentProject || null}
|
||||
onSelect={(project) => {
|
||||
onSelectProject?.(project);
|
||||
}}
|
||||
onViewAll={onViewAllProjects || (() => {})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="header-actions">
|
||||
{/* Desktop Search - only show in board view */}
|
||||
{onSearchChange && view === "board" && !isMobile && (
|
||||
@@ -279,15 +238,6 @@ export function Header({
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn${view === "agents" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("agents")}
|
||||
title="Agents view"
|
||||
aria-label="Agents view"
|
||||
aria-pressed={view === "agents"}
|
||||
>
|
||||
<Bot size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -395,18 +345,6 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scripts - desktop only */}
|
||||
{!isMobile && onOpenScripts && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenScripts}
|
||||
title="Scripts"
|
||||
data-testid="scripts-btn"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Settings - always inline on desktop */}
|
||||
{!isMobile && (
|
||||
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
|
||||
@@ -538,17 +476,6 @@ export function Header({
|
||||
<span>Manage Agents</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenScripts && (
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenScripts)}
|
||||
role="menuitem"
|
||||
data-testid="overflow-scripts-btn"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
<span>Scripts</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenSettings)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Brain, Link, Lightbulb, ListTree, Zap } from "lucide-react";
|
||||
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchModels, uploadAttachment, fetchSettings } from "../api";
|
||||
@@ -87,8 +87,6 @@ export function InlineCreateCard({
|
||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const justResetRef = useRef(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -190,21 +188,40 @@ export function InlineCreateCard({
|
||||
[loadedModels],
|
||||
);
|
||||
|
||||
// Track focus-out for justResetRef cleanup only (no auto-cancel on blur)
|
||||
// Cancel when focus leaves the card entirely and there's no content
|
||||
useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
if (!card) return;
|
||||
const handleFocusOut = (e: FocusEvent) => {
|
||||
// relatedTarget is the element receiving focus — if it's inside the card, ignore
|
||||
if (e.relatedTarget instanceof Node && card.contains(e.relatedTarget)) return;
|
||||
// Clear justResetRef flag when focus actually leaves the card
|
||||
if (justResetRef.current) {
|
||||
justResetRef.current = false;
|
||||
// Only cancel if empty and dropdowns are not open
|
||||
if (
|
||||
description.trim() === "" &&
|
||||
pendingImages.length === 0 &&
|
||||
dependencies.length === 0 &&
|
||||
!hasExecutorOverride &&
|
||||
!hasValidatorOverride &&
|
||||
!showDeps &&
|
||||
!showModels &&
|
||||
!showPresets
|
||||
) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
card.addEventListener("focusout", handleFocusOut);
|
||||
return () => card.removeEventListener("focusout", handleFocusOut);
|
||||
}, []);
|
||||
}, [
|
||||
description,
|
||||
pendingImages,
|
||||
dependencies,
|
||||
hasExecutorOverride,
|
||||
hasValidatorOverride,
|
||||
showDeps,
|
||||
showModels,
|
||||
showPresets,
|
||||
onCancel,
|
||||
]);
|
||||
|
||||
// Clean up object URLs on unmount to prevent memory leaks
|
||||
useEffect(() => {
|
||||
@@ -290,19 +307,9 @@ export function InlineCreateCard({
|
||||
setPendingImages([]);
|
||||
|
||||
setSelectedPresetId(undefined);
|
||||
setExecutorProvider(undefined);
|
||||
setExecutorModelId(undefined);
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
setDependencies([]);
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
setShowPresets(false);
|
||||
addToast(`Created ${task.id}`, "success");
|
||||
|
||||
// Collapse and clear localStorage after successful task creation
|
||||
setIsExpanded(false);
|
||||
justResetRef.current = true;
|
||||
// Clear localStorage after successful task creation
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
@@ -330,27 +337,10 @@ export function InlineCreateCard({
|
||||
async (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
// Close dropdowns first if open
|
||||
if (showDeps || showModels || showPresets) {
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
setShowPresets(false);
|
||||
return;
|
||||
// Clear localStorage when user explicitly cancels
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
// Clear non-empty input on Escape and clear localStorage
|
||||
if (description.trim()) {
|
||||
setDescription("");
|
||||
// Reset height
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = "auto";
|
||||
}
|
||||
// Clear localStorage when user explicitly clears input
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
// Collapse and cancel on escape
|
||||
setIsExpanded(false);
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
@@ -359,7 +349,7 @@ export function InlineCreateCard({
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, onCancel, description, showDeps, showModels, showPresets],
|
||||
[handleSubmit, onCancel],
|
||||
);
|
||||
|
||||
const toggleDep = useCallback((id: string) => {
|
||||
@@ -450,47 +440,24 @@ export function InlineCreateCard({
|
||||
const truncate = (s: string, len: number) =>
|
||||
s.length > len ? s.slice(0, len) + "…" : s;
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
// Skip if we just reset the form (prevents re-expanding after successful creation)
|
||||
if (justResetRef.current) {
|
||||
justResetRef.current = false;
|
||||
return;
|
||||
}
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`inline-create-card ${isExpanded ? "inline-create-card--expanded" : "inline-create-card--collapsed"}`} ref={cardRef}>
|
||||
<div className="inline-create-main-row">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
rows={1}
|
||||
className="inline-create-input"
|
||||
placeholder="What needs to be done?"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
disabled={submitting}
|
||||
aria-controls="inline-create-controls"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm inline-create-toggle"
|
||||
onClick={toggleExpanded}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls="inline-create-controls"
|
||||
data-testid="inline-create-toggle"
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="inline-create-card" ref={cardRef}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
rows={1}
|
||||
className="inline-create-input"
|
||||
placeholder="What needs to be done?"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="inline-create-previews">
|
||||
{pendingImages.map((img, i) => (
|
||||
@@ -509,30 +476,29 @@ export function InlineCreateCard({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<div id="inline-create-controls" className="inline-create-footer">
|
||||
<div className="inline-create-controls">
|
||||
<div className="dep-trigger-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm dep-trigger"
|
||||
onClick={toggleDepsDropdown}
|
||||
>
|
||||
<Link size={12} style={{ verticalAlign: "middle" }} />
|
||||
{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
|
||||
</button>
|
||||
{showDeps && (() => {
|
||||
const term = depSearch.toLowerCase();
|
||||
const filtered = (term
|
||||
? tasks.filter((t) =>
|
||||
t.id.toLowerCase().includes(term) ||
|
||||
(t.title && t.title.toLowerCase().includes(term)) ||
|
||||
(t.description && t.description.toLowerCase().includes(term))
|
||||
)
|
||||
: [...tasks]
|
||||
).sort((a, b) => {
|
||||
const cmp = b.createdAt.localeCompare(a.createdAt);
|
||||
if (cmp !== 0) return cmp;
|
||||
<div className="inline-create-footer">
|
||||
<div className="inline-create-controls">
|
||||
<div className="dep-trigger-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm dep-trigger"
|
||||
onClick={toggleDepsDropdown}
|
||||
>
|
||||
<Link size={12} style={{ verticalAlign: "middle" }} />
|
||||
{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
|
||||
</button>
|
||||
{showDeps && (() => {
|
||||
const term = depSearch.toLowerCase();
|
||||
const filtered = (term
|
||||
? tasks.filter((t) =>
|
||||
t.id.toLowerCase().includes(term) ||
|
||||
(t.title && t.title.toLowerCase().includes(term)) ||
|
||||
(t.description && t.description.toLowerCase().includes(term))
|
||||
)
|
||||
: [...tasks]
|
||||
).sort((a, b) => {
|
||||
const cmp = b.createdAt.localeCompare(a.createdAt);
|
||||
if (cmp !== 0) return cmp;
|
||||
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
return bNum - aNum;
|
||||
@@ -750,7 +716,6 @@ export function InlineCreateCard({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight, Folder } from "lucide-react";
|
||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@fusion/core";
|
||||
import { COLUMN_LABELS, COLUMNS } from "@fusion/core";
|
||||
import { fetchTaskDetail, batchUpdateTaskModels } from "../api";
|
||||
@@ -48,9 +48,6 @@ interface ListViewProps {
|
||||
* Allows parent to refresh task list or handle optimistically.
|
||||
*/
|
||||
onTasksUpdated?: (updatedTasks: Task[]) => void;
|
||||
/** Project context for multi-project mode */
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
function getStepProgress(steps: TaskStep[]): string {
|
||||
@@ -119,7 +116,7 @@ export function ListView({
|
||||
// Invalid localStorage data - fall through to default
|
||||
}
|
||||
}
|
||||
return true; // Default: hide done tasks
|
||||
return false; // Default: show done tasks
|
||||
});
|
||||
|
||||
// Collapsed sections state - initialize from localStorage
|
||||
@@ -567,15 +564,6 @@ export function ListView({
|
||||
|
||||
return (
|
||||
<div className="list-view">
|
||||
{/* Project context badge */}
|
||||
{projectId && projectName && (
|
||||
<div className="list-project-context">
|
||||
<span className="list-project-badge">
|
||||
<Folder size={14} />
|
||||
{projectName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="list-toolbar">
|
||||
<div className="list-filter">
|
||||
<Search size={14} className="filter-icon" />
|
||||
@@ -736,7 +724,6 @@ export function ListView({
|
||||
availableModels={availableModels}
|
||||
onPlanningMode={onPlanningMode}
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
autoExpand={false}
|
||||
/>
|
||||
</div>
|
||||
{filteredCount === 0 ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Task } from "@fusion/core";
|
||||
import type { Task } from "@kb/core";
|
||||
|
||||
interface MergeDetailsProps {
|
||||
task: Task;
|
||||
|
||||
@@ -163,36 +163,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isOpen, view]);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
// Show confirmation if user has made progress
|
||||
if (hasProgress) {
|
||||
if (!confirm("Are you sure you want to close? Your planning progress will be lost.")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Always close the stream connection
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
|
||||
if (view.type === "question" || view.type === "summary") {
|
||||
try {
|
||||
await cancelPlanning(view.session.sessionId);
|
||||
} catch {
|
||||
// Ignore errors on cancel
|
||||
}
|
||||
}
|
||||
setInitialPlan("");
|
||||
setView({ type: "initial" });
|
||||
setError(null);
|
||||
setResponseHistory([]);
|
||||
setEditedSummary(null);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(false);
|
||||
currentSessionIdRef.current = null;
|
||||
onClose();
|
||||
}, [hasProgress, view, onClose]);
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -244,6 +214,36 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
[view]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
// Show confirmation if user has made progress
|
||||
if (hasProgress) {
|
||||
if (!confirm("Are you sure you want to close? Your planning progress will be lost.")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Always close the stream connection
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
|
||||
if (view.type === "question" || view.type === "summary") {
|
||||
try {
|
||||
await cancelPlanning(view.session.sessionId);
|
||||
} catch {
|
||||
// Ignore errors on cancel
|
||||
}
|
||||
}
|
||||
setInitialPlan("");
|
||||
setView({ type: "initial" });
|
||||
setError(null);
|
||||
setResponseHistory([]);
|
||||
setEditedSummary(null);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(false);
|
||||
currentSessionIdRef.current = null;
|
||||
onClose();
|
||||
}, [hasProgress, view, onClose]);
|
||||
|
||||
const handleCreateTask = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
|
||||
|
||||
@@ -3,12 +3,11 @@ import type { ToastType } from "../hooks/useToast";
|
||||
import type { Task, TaskCreateInput } from "@fusion/core";
|
||||
import type { ModelInfo, RefinementType } from "../api";
|
||||
import { fetchModels, refineText, getRefineErrorMessage } from "../api";
|
||||
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ModelSelectionModal } from "./ModelSelectionModal";
|
||||
|
||||
const STORAGE_KEY = "kb-quick-entry-text";
|
||||
const DISCLOSURE_STORAGE_KEY = "kb-quick-entry-expanded";
|
||||
|
||||
interface QuickEntryBoxProps {
|
||||
onCreate?: (input: TaskCreateInput) => Promise<void>;
|
||||
@@ -23,11 +22,6 @@ interface QuickEntryBoxProps {
|
||||
* Called when the user clicks the "Subtask" button to trigger subtask breakdown.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
/**
|
||||
* When false, the component will not auto-expand on focus.
|
||||
* Defaults to true for backward compatibility.
|
||||
*/
|
||||
autoExpand?: boolean;
|
||||
}
|
||||
|
||||
function getModelSelectionValue(provider?: string, modelId?: string): string {
|
||||
@@ -50,7 +44,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
||||
};
|
||||
}
|
||||
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, autoExpand = true }: QuickEntryBoxProps) {
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown }: QuickEntryBoxProps) {
|
||||
const [description, setDescription] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem(STORAGE_KEY) || "";
|
||||
@@ -58,18 +52,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
return "";
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// isExpanded controls textarea height styling (auto-resize)
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// isDisclosureExpanded controls visibility of the controls panel (Deps, Models, etc.)
|
||||
const [isDisclosureExpanded, setIsDisclosureExpanded] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem(DISCLOSURE_STORAGE_KEY);
|
||||
// Default to true (expanded) for backward compatibility - only collapse if explicitly set to "false"
|
||||
return saved !== "false";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const justResetRef = useRef(false);
|
||||
|
||||
// Rich creation state (mirrors InlineCreateCard)
|
||||
@@ -150,17 +135,12 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}
|
||||
}, [description]);
|
||||
|
||||
// Persist disclosure state to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(DISCLOSURE_STORAGE_KEY, isDisclosureExpanded.toString());
|
||||
}
|
||||
}, [isDisclosureExpanded]);
|
||||
|
||||
// Cleanup on unmount
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// No blur timeout to clean up
|
||||
if (blurTimeoutRef.current) {
|
||||
clearTimeout(blurTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -224,8 +204,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
setIsModelModalOpen(false);
|
||||
setIsRefineMenuOpen(false);
|
||||
setIsRefining(false);
|
||||
setIsExpanded(false); // Collapse textarea height on reset
|
||||
// Note: isDisclosureExpanded is NOT reset - user preference persists
|
||||
setIsExpanded(false);
|
||||
justResetRef.current = true;
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
@@ -311,21 +290,17 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
// Collapse textarea and disclosure on escape
|
||||
setIsExpanded(false);
|
||||
setIsDisclosureExpanded(false);
|
||||
// Collapse on escape
|
||||
resetForm();
|
||||
// Clear any pending blur timeout
|
||||
if (blurTimeoutRef.current) {
|
||||
clearTimeout(blurTimeoutRef.current);
|
||||
blurTimeoutRef.current = null;
|
||||
}
|
||||
textareaRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[
|
||||
handleSubmit,
|
||||
description,
|
||||
isExpanded,
|
||||
showDeps,
|
||||
isModelModalOpen,
|
||||
isRefineMenuOpen,
|
||||
setIsDisclosureExpanded,
|
||||
],
|
||||
[handleSubmit, description, isExpanded, showDeps, isModelModalOpen, isRefineMenuOpen, resetForm],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
@@ -334,16 +309,28 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
justResetRef.current = false;
|
||||
return;
|
||||
}
|
||||
// No auto-expand on focus — manual toggle only
|
||||
setIsExpanded(true);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// No auto-collapse on blur — state persists until manually toggled or task is submitted/cancelled
|
||||
// Only clear the justResetRef flag if needed
|
||||
if (justResetRef.current) {
|
||||
justResetRef.current = false;
|
||||
// Clear any existing timeout
|
||||
if (blurTimeoutRef.current) {
|
||||
clearTimeout(blurTimeoutRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Collapse after a short delay to allow click events on dropdowns
|
||||
// Collapse regardless of content - only check if dropdowns are open
|
||||
blurTimeoutRef.current = setTimeout(() => {
|
||||
if (!showDeps && !isModelModalOpen && !isRefineMenuOpen) {
|
||||
setIsExpanded(false);
|
||||
// Reset height when collapsing
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
}
|
||||
blurTimeoutRef.current = null;
|
||||
}, 200);
|
||||
}, [showDeps, isModelModalOpen, isRefineMenuOpen]);
|
||||
|
||||
const toggleDep = useCallback((id: string) => {
|
||||
setDependencies((prev) =>
|
||||
@@ -448,45 +435,26 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}
|
||||
}, [availableModels]);
|
||||
|
||||
// Show expanded controls based on disclosure state (user preference), not textarea focus
|
||||
const showExpandedControls = isDisclosureExpanded;
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setIsDisclosureExpanded((prev) => !prev);
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
// Show expanded controls only when focused/interacted (isExpanded)
|
||||
const showExpandedControls = isExpanded;
|
||||
|
||||
return (
|
||||
<div className={`quick-entry-box ${isDisclosureExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}`} data-testid="quick-entry-box">
|
||||
<div className="quick-entry-main-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={`quick-entry-input ${isExpanded ? "quick-entry-input--expanded" : ""}`}
|
||||
placeholder={isSubmitting ? "Creating..." : "Add a task..."}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={isSubmitting || isDisabled}
|
||||
data-testid="quick-entry-input"
|
||||
rows={1}
|
||||
aria-controls="quick-entry-controls"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm quick-entry-toggle"
|
||||
onClick={toggleExpanded}
|
||||
aria-expanded={isDisclosureExpanded}
|
||||
aria-controls="quick-entry-controls"
|
||||
data-testid="quick-entry-toggle"
|
||||
title={isDisclosureExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{isDisclosureExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="quick-entry-box" data-testid="quick-entry-box">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={`quick-entry-input ${isExpanded ? "quick-entry-input--expanded" : ""}`}
|
||||
placeholder={isSubmitting ? "Creating..." : "Add a task..."}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={isSubmitting || isDisabled}
|
||||
data-testid="quick-entry-input"
|
||||
rows={1}
|
||||
/>
|
||||
{showExpandedControls && (
|
||||
<div id="quick-entry-controls" className="quick-entry-controls">
|
||||
<div className="quick-entry-controls">
|
||||
<div className="quick-entry-controls-left">
|
||||
<div className="dep-trigger-wrap">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
import { addComment } from "../api";
|
||||
import { addSteeringComment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
@@ -24,7 +24,7 @@ interface SteeringTabProps {
|
||||
}
|
||||
|
||||
export function SteeringTab({ task, addToast }: SteeringTabProps) {
|
||||
const [comments, setComments] = useState(task.comments || []);
|
||||
const [comments, setComments] = useState(task.steeringComments || []);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -35,10 +35,10 @@ export function SteeringTab({ task, addToast }: SteeringTabProps) {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const updated = await addComment(task.id, newComment.trim());
|
||||
setComments(updated.comments || []);
|
||||
const updated = await addSteeringComment(task.id, newComment.trim());
|
||||
setComments(updated.steeringComments || []);
|
||||
setNewComment("");
|
||||
addToast("Comment added", "success");
|
||||
addToast("Steering comment added", "success");
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
} finally {
|
||||
@@ -60,7 +60,7 @@ export function SteeringTab({ task, addToast }: SteeringTabProps) {
|
||||
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<h4>Comments</h4>
|
||||
<h4>Steering Comments</h4>
|
||||
<p style={{ fontSize: "13px", opacity: 0.7, marginBottom: "12px" }}>
|
||||
Add comments to guide the AI during task execution. These are injected into the execution context.
|
||||
</p>
|
||||
@@ -113,7 +113,7 @@ export function SteeringTab({ task, addToast }: SteeringTabProps) {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ opacity: 0.5, marginBottom: "16px" }}>(no comments yet)</div>
|
||||
<div style={{ opacity: 0.5, marginBottom: "16px" }}>(no steering comments yet)</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
@@ -121,7 +121,7 @@ export function SteeringTab({ task, addToast }: SteeringTabProps) {
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a comment... (Ctrl+Enter to submit)"
|
||||
placeholder="Add a steering comment... (Ctrl+Enter to submit)"
|
||||
maxLength={MAX_LENGTH}
|
||||
rows={4}
|
||||
style={{
|
||||
@@ -160,7 +160,7 @@ export function SteeringTab({ task, addToast }: SteeringTabProps) {
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Adding…" : "Add Comment"}
|
||||
{isSubmitting ? "Adding…" : "Add Steering Comment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ interface TaskCardProps {
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
function areTaskBadgeInfosEqual(
|
||||
@@ -109,7 +109,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previousTask.reviewLevel === nextTask.reviewLevel &&
|
||||
previousTask.mergeRetries === nextTask.mergeRetries &&
|
||||
JSON.stringify(previousTask.attachments ?? []) === JSON.stringify(nextTask.attachments ?? []) &&
|
||||
JSON.stringify(previousTask.comments ?? []) === JSON.stringify(nextTask.comments ?? []) &&
|
||||
JSON.stringify(previousTask.steeringComments ?? []) === JSON.stringify(nextTask.steeringComments ?? []) &&
|
||||
areTaskDependenciesEqual(previousTask.dependencies, nextTask.dependencies) &&
|
||||
areTaskStepsEqual(previousTask.steps, nextTask.steps) &&
|
||||
areTaskBadgeInfosEqual(previousTask.prInfo, nextTask.prInfo) &&
|
||||
@@ -694,7 +694,7 @@ function TaskCardComponent({
|
||||
className="card-session-files"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenFilesForTask?.(task.id, task.worktree, task.column);
|
||||
onOpenFilesForTask?.(task.id);
|
||||
}}
|
||||
disabled={!onOpenFilesForTask}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Task, TaskComment } from "@fusion/core";
|
||||
import type { Task, TaskComment } from "@kb/core";
|
||||
import { addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ModelSelectorTab } from "./ModelSelectorTab";
|
||||
import { PrSection } from "./PrSection";
|
||||
import { TaskComments } from "./TaskComments";
|
||||
import { MergeDetails } from "./MergeDetails";
|
||||
import { TaskChangesTab } from "./TaskChangesTab";
|
||||
|
||||
interface ModelSelection {
|
||||
provider?: string;
|
||||
@@ -106,7 +105,7 @@ export function TaskDetailModal({
|
||||
addToast,
|
||||
githubTokenConfigured,
|
||||
}: TaskDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "changes" | "steering" | "comments" | "model">("definition");
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "comments" | "model">("definition");
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||
@@ -672,14 +671,6 @@ export function TaskDetailModal({
|
||||
>
|
||||
Agent Log
|
||||
</button>
|
||||
{(task.column === "in-progress" || task.column === "in-review" || task.column === "done") && (
|
||||
<button
|
||||
className={`detail-tab${activeTab === "changes" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("changes")}
|
||||
>
|
||||
Changes
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`detail-tab${activeTab === "steering" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("steering")}
|
||||
@@ -712,8 +703,6 @@ export function TaskDetailModal({
|
||||
validatorModel={getValidatorSelection(task)}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "changes" ? (
|
||||
<TaskChangesTab taskId={task.id} worktree={task.worktree} />
|
||||
) : activeTab === "steering" ? (
|
||||
<SteeringTab task={task} addToast={addToast} />
|
||||
) : activeTab === "comments" ? (
|
||||
|
||||
@@ -241,21 +241,6 @@ export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'used' | 'remaining'>('used');
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const wasOpenRef = useRef(isOpen);
|
||||
|
||||
// Trigger refresh when modal opens (isOpen transitions from false to true)
|
||||
useEffect(() => {
|
||||
// Only refresh when transitioning from closed to open
|
||||
if (!wasOpenRef.current && isOpen) {
|
||||
// Skip if data is fresh (within 5 seconds) to avoid duplicate requests
|
||||
if (!lastUpdated || Date.now() - lastUpdated.getTime() > 5000) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
// Update ref for next render
|
||||
wasOpenRef.current = isOpen;
|
||||
}, [isOpen, lastUpdated, refresh]);
|
||||
|
||||
// Load view mode preference from localStorage on mount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -15,7 +15,7 @@ interface WorktreeGroupProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
function WorktreeGroupComponent({
|
||||
|
||||
@@ -6,11 +6,11 @@ import type { ActivityLogEntry } from "@fusion/core";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchActivityFeed: vi.fn(),
|
||||
fetchActivityLog: vi.fn(),
|
||||
clearActivityLog: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchActivityFeed = vi.mocked(apiModule.fetchActivityFeed);
|
||||
const mockFetchActivityLog = vi.mocked(apiModule.fetchActivityLog);
|
||||
const mockClearActivityLog = vi.mocked(apiModule.clearActivityLog);
|
||||
|
||||
describe("ActivityLogModal", () => {
|
||||
@@ -53,7 +53,7 @@ describe("ActivityLogModal", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchActivityFeed.mockResolvedValue(mockActivityEntries);
|
||||
mockFetchActivityLog.mockResolvedValue(mockActivityEntries);
|
||||
mockClearActivityLog.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("ActivityLogModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalled();
|
||||
expect(mockFetchActivityLog).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("ActivityLogModal", () => {
|
||||
fireEvent.change(filterSelect, { target: { value: "task:created" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "task:created" })
|
||||
);
|
||||
});
|
||||
@@ -164,19 +164,19 @@ describe("ActivityLogModal", () => {
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const refreshButton = screen.getByTestId("activity-refresh");
|
||||
fireEvent.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when no entries", async () => {
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
@@ -193,7 +193,7 @@ describe("ActivityLogModal", () => {
|
||||
});
|
||||
|
||||
it("shows error state when API fails", async () => {
|
||||
mockFetchActivityFeed.mockRejectedValue(new Error("API Error"));
|
||||
mockFetchActivityLog.mockRejectedValue(new Error("API Error"));
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
@@ -250,100 +250,4 @@ describe("ActivityLogModal", () => {
|
||||
// Check that confirmation dialog appears
|
||||
expect(screen.getByText(/Clear Activity Log/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Project Filter Tests ─────────────────────────────────────────
|
||||
|
||||
it("shows project filter when projects provided", async () => {
|
||||
const mockProjects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
projects={mockProjects}
|
||||
/>
|
||||
);
|
||||
|
||||
const projectFilter = await screen.findByTestId("activity-project-filter");
|
||||
expect(projectFilter).toBeTruthy();
|
||||
|
||||
// Should have "All Projects" option
|
||||
expect(screen.getByText("All Projects")).toBeDefined();
|
||||
// Should have project options
|
||||
expect(screen.getByText("Project One")).toBeDefined();
|
||||
expect(screen.getByText("Project Two")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show project filter when no projects provided", async () => {
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("activity-filter")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Project filter should not exist
|
||||
expect(screen.queryByTestId("activity-project-filter")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onProjectFilterChange when project filter changed", async () => {
|
||||
const mockProjects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const onProjectFilterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
projects={mockProjects}
|
||||
onProjectFilterChange={onProjectFilterChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const projectFilter = await screen.findByTestId("activity-project-filter");
|
||||
fireEvent.change(projectFilter, { target: { value: "proj_1" } });
|
||||
|
||||
expect(onProjectFilterChange).toHaveBeenCalledWith("proj_1");
|
||||
});
|
||||
|
||||
it("shows empty state message mentioning filters when filter is active", async () => {
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
const mockProjects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
projects={mockProjects}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("activity-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Change the filter to trigger filtered empty state
|
||||
const projectFilter = screen.getByTestId("activity-project-filter");
|
||||
fireEvent.change(projectFilter, { target: { value: "proj_1" } });
|
||||
|
||||
// Should show filter-specific message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No activity matches the current filters/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,7 +256,7 @@ describe("Board", () => {
|
||||
const todoTasks = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]");
|
||||
expect(todoTasks[0].title).toBe("Updated");
|
||||
expect(columnRenderCounts.todo).toBeGreaterThan(initialTodoRenders);
|
||||
expect(columnRenderCounts.done).toBeGreaterThanOrEqual(initialDoneRenders);
|
||||
expect(columnRenderCounts.done).toBe(initialDoneRenders);
|
||||
});
|
||||
|
||||
it("filtered tasks are sorted correctly (columnMovedAt, createdAt)", () => {
|
||||
@@ -292,7 +292,7 @@ describe("Board", () => {
|
||||
expect(todoTasks).toHaveLength(3);
|
||||
|
||||
// Tasks with columnMovedAt should come first, sorted by columnMovedAt descending (newest first)
|
||||
// So FN-002 (12:00) should be first, FN-001 (10:00) second
|
||||
// So KB-002 (12:00) should be first, KB-001 (10:00) second
|
||||
// Legacy tasks (no columnMovedAt) come last, sorted by createdAt ascending
|
||||
expect(todoTasks[0].id).toBe("FN-002");
|
||||
expect(todoTasks[1].id).toBe("FN-001");
|
||||
@@ -302,7 +302,7 @@ describe("Board", () => {
|
||||
it("matches tasks across multiple fields simultaneously", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "SEARCH-123", title: "Searchable title", description: "Normal description", column: "todo" }),
|
||||
createTask({ id: "FN-999", title: "Other task", description: "This has searchable content", column: "todo" }),
|
||||
createTask({ id: "KB-999", title: "Other task", description: "This has searchable content", column: "todo" }),
|
||||
createTask({ id: "FN-888", title: "Unrelated", description: "No match here", column: "todo" }),
|
||||
];
|
||||
|
||||
@@ -313,7 +313,7 @@ describe("Board", () => {
|
||||
|
||||
// Should match both tasks with "search" in ID, title, or description
|
||||
expect(todoTasks).toHaveLength(2);
|
||||
expect(todoTasks.map((t: Task) => t.id).sort()).toEqual(["FN-999", "SEARCH-123"]);
|
||||
expect(todoTasks.map((t: Task) => t.id).sort()).toEqual(["KB-999", "SEARCH-123"]);
|
||||
});
|
||||
|
||||
it("trims whitespace from search query", () => {
|
||||
|
||||
@@ -991,9 +991,8 @@ describe("GitManagerModal", () => {
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "upstream");
|
||||
|
||||
const saveButton = nameInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
|
||||
expect(saveButton).toBeTruthy();
|
||||
await user.click(saveButton as HTMLButtonElement);
|
||||
const saveButton = screen.getByRole("button", { name: "" }); // Check button
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renameGitRemote).toHaveBeenCalledWith("origin", "upstream");
|
||||
@@ -1024,9 +1023,8 @@ describe("GitManagerModal", () => {
|
||||
await user.clear(urlInput);
|
||||
await user.type(urlInput, "https://new-url.com/repo.git");
|
||||
|
||||
const saveButton = urlInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
|
||||
expect(saveButton).toBeTruthy();
|
||||
await user.click(saveButton as HTMLButtonElement);
|
||||
const saveButton = screen.getByRole("button", { name: "" }); // Check button
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateGitRemoteUrl).toHaveBeenCalledWith("origin", "https://new-url.com/repo.git");
|
||||
|
||||
@@ -210,47 +210,6 @@ describe("Header", () => {
|
||||
expect(boardBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
// ── Agents View Toggle ──────────────────────────────────────────
|
||||
|
||||
it("renders agents view button in view toggle when onChangeView is provided", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="board" onChangeView={onChangeView} />);
|
||||
const agentsBtn = screen.getByTitle("Agents view");
|
||||
expect(agentsBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onChangeView with 'agents' when agents view button is clicked", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="board" onChangeView={onChangeView} />);
|
||||
const agentsBtn = screen.getByTitle("Agents view");
|
||||
fireEvent.click(agentsBtn);
|
||||
expect(onChangeView).toHaveBeenCalledWith("agents");
|
||||
});
|
||||
|
||||
it("marks agents view button as active when view is 'agents'", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="agents" onChangeView={onChangeView} />);
|
||||
const agentsBtn = screen.getByTitle("Agents view");
|
||||
expect(agentsBtn.className).toContain("active");
|
||||
expect(agentsBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not mark agents view button as active when view is 'board'", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="board" onChangeView={onChangeView} />);
|
||||
const agentsBtn = screen.getByTitle("Agents view");
|
||||
expect(agentsBtn.className).not.toContain("active");
|
||||
expect(agentsBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("does not mark board view button as active when view is 'agents'", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="agents" onChangeView={onChangeView} />);
|
||||
const boardBtn = screen.getByTitle("Board view");
|
||||
expect(boardBtn.className).not.toContain("active");
|
||||
expect(boardBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
// ── Terminal Button ─────────────────────────────────────────────
|
||||
|
||||
it("renders terminal button with correct title", () => {
|
||||
@@ -546,107 +505,4 @@ describe("Header", () => {
|
||||
const btn = screen.getByTestId("agents-btn");
|
||||
expect(btn).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Multi-Project Selector ────────────────────────────────────
|
||||
|
||||
it("shows ProjectSelector when 2+ projects provided", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(<Header projects={projects} />);
|
||||
expect(screen.getByTestId("project-selector-trigger")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show ProjectSelector with single project", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const { container } = render(<Header projects={projects} />);
|
||||
expect(container.querySelector(".project-selector")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show ProjectSelector when no projects", () => {
|
||||
const { container } = render(<Header projects={[]} />);
|
||||
expect(container.querySelector(".project-selector")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows 'Back to All Projects' button when currentProject is set", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onViewAllProjects={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("back-to-projects-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onViewAllProjects when 'Back to All Projects' clicked", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const onViewAllProjects = vi.fn();
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onViewAllProjects={onViewAllProjects}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("back-to-projects-btn"));
|
||||
expect(onViewAllProjects).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not show 'Back to All Projects' when no currentProject", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(<Header projects={projects} currentProject={null} />);
|
||||
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onSelectProject when project selected from selector", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const onSelectProject = vi.fn();
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={onSelectProject}
|
||||
onViewAllProjects={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open selector
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
// Click on a project in the dropdown
|
||||
fireEvent.click(screen.getByText("Project Two"));
|
||||
expect(onSelectProject).toHaveBeenCalledWith(projects[1]);
|
||||
});
|
||||
|
||||
it("shows current project name in selector trigger", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={vi.fn()}
|
||||
onViewAllProjects={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Project One")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,6 @@ vi.mock("lucide-react", () => ({
|
||||
Lightbulb: () => null,
|
||||
ListTree: () => null,
|
||||
Zap: () => null,
|
||||
ChevronDown: () => null,
|
||||
ChevronUp: () => null,
|
||||
}));
|
||||
|
||||
// Mock the api module
|
||||
@@ -90,12 +88,6 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
|
||||
fireEvent.click(screen.getByText(optionText));
|
||||
}
|
||||
|
||||
// Helper to expand the InlineCreateCard by clicking the toggle button
|
||||
function expandInlineCreate() {
|
||||
const toggleButton = screen.getByTestId("inline-create-toggle");
|
||||
fireEvent.click(toggleButton);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
@@ -107,84 +99,47 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard toggle button", () => {
|
||||
it("toggle button expands the view", () => {
|
||||
renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Initially, footer controls are not visible
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
|
||||
// Click toggle to expand
|
||||
expandInlineCreate();
|
||||
|
||||
// Now footer controls should be visible
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggle button collapses the view when expanded", () => {
|
||||
renderCard();
|
||||
|
||||
// Expand first
|
||||
expandInlineCreate();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
|
||||
// Click toggle again to collapse
|
||||
expandInlineCreate();
|
||||
|
||||
// Footer should be hidden
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT expand on focus", () => {
|
||||
renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Focus should not expand the card
|
||||
textarea.focus();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT collapse on blur", () => {
|
||||
renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Expand first
|
||||
expandInlineCreate();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
|
||||
// Blur should not collapse
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
// Should still be expanded
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard Escape key behavior", () => {
|
||||
it("calls onCancel when Escape is pressed", () => {
|
||||
describe("InlineCreateCard blur-to-cancel", () => {
|
||||
it("calls onCancel when focus leaves the card with empty input", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
textarea.focus();
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes dropdowns on first Escape, cancels on second", () => {
|
||||
it("does NOT call onCancel when focus leaves with non-empty input", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Open a dropdown
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
|
||||
fireEvent.change(textarea, { target: { value: "Some task description" } });
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
// First Escape closes dropdown
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
expect(document.querySelector(".dep-dropdown")).toBeNull();
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when focus moves to another element inside the card", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
const depsButton = screen.getByText(/Deps/);
|
||||
|
||||
textarea.focus();
|
||||
fireEvent.focusOut(textarea, { relatedTarget: depsButton });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onCancel when blur with only whitespace input", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: " " } });
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard dep-dropdown focus retention", () => {
|
||||
@@ -194,7 +149,6 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
|
||||
|
||||
it("dep-dropdown-item mouseDown calls preventDefault to retain focus", () => {
|
||||
renderCard(testTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
|
||||
expect(item).toBeTruthy();
|
||||
@@ -202,12 +156,26 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
|
||||
const prevented = !fireEvent.mouseDown(item);
|
||||
expect(prevented).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when focus leaves card with selected dependencies but empty description", () => {
|
||||
const { props } = renderCard(testTasks);
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
|
||||
expect(item).toBeTruthy();
|
||||
fireEvent.click(item);
|
||||
|
||||
textarea.focus();
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard model selector", () => {
|
||||
it("opens and closes the model disclosure dropdown", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
expect(screen.getByText("Executor Model")).toBeTruthy();
|
||||
@@ -219,7 +187,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("updates executor selection and shows the selected model badge", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
chooseModel("Executor Model", "Claude Sonnet 4.5");
|
||||
@@ -229,7 +196,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("updates validator selection and shows the selected model badge", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
chooseModel("Validator Model", "GPT-4o");
|
||||
@@ -239,7 +205,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("clears the model selection when Use default is chosen", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
chooseModel("Executor Model", "Claude Sonnet 4.5");
|
||||
@@ -255,7 +220,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("omits model fields from the submit payload after clearing back to default", async () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task using defaults again" } });
|
||||
@@ -282,7 +246,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("includes selected models in the submit payload", async () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with model overrides" } });
|
||||
@@ -306,7 +269,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("does NOT call onCancel when focus leaves while the model dropdown is open", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
openModelPanel();
|
||||
@@ -323,7 +285,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
|
||||
@@ -333,21 +294,19 @@ describe("InlineCreateCard model selector", () => {
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skip("includes selected preset id in the submit payload", async () => {
|
||||
it("includes selected preset id in the submit payload", async () => {
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
const { props } = renderCard([], { availableModels: undefined });
|
||||
|
||||
expandInlineCreate();
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with preset" } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Preset/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Budget" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Save/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Budget" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -363,7 +322,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("does NOT call onCancel after a model override is selected and focus leaves the card", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
openModelPanel();
|
||||
@@ -378,7 +336,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("prevents default on model option mouseDown to retain focus while selecting", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
textarea.focus();
|
||||
@@ -412,7 +369,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
.mockResolvedValueOnce(MOCK_MODELS);
|
||||
|
||||
renderCard([], { availableModels: undefined });
|
||||
expandInlineCreate();
|
||||
openModelPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -437,7 +393,6 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
|
||||
|
||||
it("renders dependency dropdown items sorted newest-first by createdAt", () => {
|
||||
renderCard(scrambledTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||
expect(items).toHaveLength(3);
|
||||
@@ -447,7 +402,6 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
|
||||
|
||||
it("preserves newest-first sort order when a search filter is applied", () => {
|
||||
renderCard(scrambledTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "FN-00" } });
|
||||
@@ -467,7 +421,6 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
|
||||
|
||||
it("renders tasks with identical createdAt sorted newest-ID-first (descending numeric ID)", () => {
|
||||
renderCard(sameTimeTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||
expect(items).toHaveLength(3);
|
||||
@@ -477,7 +430,6 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
|
||||
|
||||
it("preserves newest-ID-first order when search filter is applied with identical timestamps", () => {
|
||||
renderCard(sameTimeTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "FN-00" } });
|
||||
@@ -497,7 +449,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
|
||||
it("shows search input when dropdown is opened", () => {
|
||||
renderCard(testTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
@@ -506,7 +457,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
|
||||
it("filters tasks by search term", () => {
|
||||
renderCard(testTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "dark" } });
|
||||
@@ -520,7 +470,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
it("renders Plan and Subtask buttons disabled when description is empty", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
|
||||
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
|
||||
expect(planButton.disabled).toBe(true);
|
||||
@@ -529,7 +478,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
|
||||
it("enables Plan and Subtask buttons when description is entered", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
fireEvent.change(textarea, { target: { value: "Test task" } });
|
||||
|
||||
@@ -542,7 +490,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
it("calls onPlanningMode with description and clears input when Plan clicked", () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
renderCard([], { onPlanningMode });
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Plan this task" } });
|
||||
@@ -555,7 +502,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
it("calls onSubtaskBreakdown with description and clears input when Subtask clicked", () => {
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderCard([], { onSubtaskBreakdown });
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Break this down" } });
|
||||
@@ -569,7 +515,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
const addToast = vi.fn();
|
||||
const onPlanningMode = vi.fn();
|
||||
renderCard([], { addToast, onPlanningMode });
|
||||
expandInlineCreate();
|
||||
|
||||
// When no description, button is disabled - verify that behavior
|
||||
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
|
||||
@@ -583,7 +528,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
const addToast = vi.fn();
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderCard([], { addToast, onSubtaskBreakdown });
|
||||
expandInlineCreate();
|
||||
|
||||
// When no description, button is disabled - verify that behavior
|
||||
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
|
||||
@@ -629,7 +573,6 @@ describe("InlineCreateCard localStorage persistence", () => {
|
||||
|
||||
it("clears localStorage after successful task creation", async () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Type something to set localStorage
|
||||
|
||||
@@ -46,7 +46,6 @@ const renderListView = (props: Partial<React.ComponentProps<typeof ListView>> =
|
||||
describe("ListView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
@@ -227,10 +226,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to reveal done tasks
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
const columnHeader = screen.getByText("Column");
|
||||
fireEvent.click(columnHeader);
|
||||
|
||||
@@ -315,15 +310,11 @@ describe("ListView", () => {
|
||||
const columns = ["triage", "todo", "in-progress", "in-review", "done"] as const;
|
||||
|
||||
const tasks = columns.map((col, i) =>
|
||||
createMockTask({ id: `FN-00${i + 1}`, column: col })
|
||||
createMockTask({ id: `KB-00${i + 1}`, column: col })
|
||||
);
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to reveal done tasks in the table
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Check that all column badges are rendered in the table
|
||||
// Use getAllByText and check length since column names appear in both drop zones and badges
|
||||
expect(screen.getAllByText("Triage").length).toBeGreaterThanOrEqual(1);
|
||||
@@ -569,10 +560,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show all column sections including Done and Archived
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Check that section headers are rendered with column names
|
||||
expect(screen.getAllByText("Triage").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
|
||||
@@ -590,10 +577,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show all column sections
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Find section headers by their structure
|
||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeaders.length).toBe(6); // One for each column
|
||||
@@ -668,7 +651,6 @@ describe("ListView", () => {
|
||||
describe("ListView Column Filtering", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("filters tasks by column when drop zone is clicked", () => {
|
||||
@@ -718,10 +700,6 @@ describe("ListView Column Filtering", () => {
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
|
||||
// Click "Show Done" to reveal all column sections
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// All 6 section headers should be visible (one for each column)
|
||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeaders.length).toBe(6);
|
||||
@@ -1041,26 +1019,13 @@ describe("ListView Hide Done Tasks", () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders hide done tasks toggle button with 'Show Done' when done tasks are hidden by default", () => {
|
||||
it("renders hide done tasks toggle button", () => {
|
||||
renderListView();
|
||||
|
||||
const hideDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
expect(hideDoneButton).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides done tasks by default when no localStorage value exists", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "done" }),
|
||||
createMockTask({ id: "FN-002", column: "triage" }),
|
||||
];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done task should be hidden by default
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides done tasks when toggle is activated", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "done" }),
|
||||
@@ -1069,15 +1034,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show done tasks first
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Both tasks should be visible now
|
||||
// Both tasks should be visible initially
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
|
||||
// Click "Hide Done" to hide done tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1094,15 +1055,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show archived tasks first
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Both tasks should be visible now
|
||||
// Both tasks should be visible initially
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
|
||||
// Click "Hide Done" to hide archived tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1120,16 +1077,12 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show all completed tasks first
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// All tasks should be visible now
|
||||
// All tasks should be visible initially
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
expect(screen.getByText("FN-003")).toBeDefined();
|
||||
|
||||
// Click "Hide Done" to hide completed tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1148,13 +1101,16 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Completed tasks should be hidden by default
|
||||
// Click hide done button to hide completed tasks
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Completed tasks should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
expect(screen.queryByText("FN-002")).toBeNull();
|
||||
|
||||
// Click "Show Done" to show all tasks
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
// Click again to show all tasks
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// All tasks should be visible again
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
@@ -1166,11 +1122,7 @@ describe("ListView Hide Done Tasks", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", column: "done" })];
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" first (since default is now hidden)
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Click "Hide Done" to hide done tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1207,7 +1159,14 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Stats should show filtered count with hidden indicator (default is now hidden)
|
||||
// Initial stats should show all tasks
|
||||
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Stats should show filtered count with hidden indicator
|
||||
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
|
||||
expect(screen.getByText(/2 hidden/)).toBeDefined();
|
||||
});
|
||||
@@ -1221,7 +1180,15 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done and Archived sections should be hidden by default
|
||||
// All section headers should be visible initially
|
||||
const sectionHeadersBefore = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeadersBefore.length).toBe(6); // All 6 columns
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done and Archived sections should be hidden
|
||||
const doneSection = screen.getAllByRole("row").find(r =>
|
||||
r.className.includes("list-section-header") && r.textContent?.includes("Done")
|
||||
);
|
||||
@@ -1247,7 +1214,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done drop zone should be visible with "X of Y" format (hide done is active by default)
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done drop zone should still be visible with "X of Y" format
|
||||
const doneZone = document.querySelector('[data-column="done"].list-drop-zone');
|
||||
expect(doneZone).toBeDefined();
|
||||
expect(doneZone?.textContent).toContain("0 of 2");
|
||||
@@ -1261,7 +1232,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Archived drop zone should be visible with "X of Y" format (hide done is active by default)
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Archived drop zone should still be visible with "X of Y" format
|
||||
const archivedZone = document.querySelector('[data-column="archived"].list-drop-zone');
|
||||
expect(archivedZone).toBeDefined();
|
||||
expect(archivedZone?.textContent).toContain("0 of 2");
|
||||
@@ -1276,11 +1251,15 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Hide done tasks
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Apply filter
|
||||
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
|
||||
fireEvent.change(filterInput, { target: { value: "Gamma" } });
|
||||
|
||||
// Completed tasks should remain hidden (hide done is active by default)
|
||||
// Completed tasks should remain hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
expect(screen.queryByText("FN-002")).toBeNull();
|
||||
// Filtered task should be visible
|
||||
@@ -1295,7 +1274,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done task should be hidden by default
|
||||
// Enable hide done
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done task should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
|
||||
// Click on the done drop zone to select that column
|
||||
@@ -1315,7 +1298,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Archived task should be hidden by default
|
||||
// Enable hide done
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Archived task should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
|
||||
// Click on the archived drop zone to select that column
|
||||
@@ -1331,7 +1318,6 @@ describe("ListView Hide Done Tasks", () => {
|
||||
describe("ListView Quick Entry", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders QuickEntryBox when onQuickCreate is provided", () => {
|
||||
@@ -1769,7 +1755,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkboxes = screen.getAllByLabelText(/Select FN-/);
|
||||
const checkboxes = screen.getAllByLabelText(/Select KB-/);
|
||||
expect(checkboxes).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -1779,11 +1765,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
// Click "Show Done" to make archived tasks visible
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
expect(checkbox).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -1794,7 +1776,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(screen.getByText("1 selected")).toBeDefined();
|
||||
@@ -1806,7 +1788,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
expect(screen.getByText("1 selected")).toBeDefined();
|
||||
|
||||
@@ -1845,7 +1827,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(screen.getByText("Bulk Edit Models:")).toBeDefined();
|
||||
@@ -1867,7 +1849,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
const applyButton = screen.getByText("Apply");
|
||||
@@ -1878,7 +1860,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001" })];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(localStorage.getItem("kb-dashboard-selected-tasks")).toBe('["FN-001"]');
|
||||
@@ -1891,7 +1873,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkboxes = screen.getAllByLabelText(/Select FN-/);
|
||||
const checkboxes = screen.getAllByLabelText(/Select KB-/);
|
||||
// Select only first task
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
@@ -1919,7 +1901,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
);
|
||||
|
||||
// Select the task
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Initially disabled
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("NewTaskModal", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Created FN-042", "success");
|
||||
expect(props.addToast).toHaveBeenCalledWith("Created KB-042", "success");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -78,8 +78,6 @@ vi.mock("lucide-react", () => ({
|
||||
Sparkles: () => null,
|
||||
Save: () => null,
|
||||
X: () => null,
|
||||
ChevronDown: () => null,
|
||||
ChevronUp: () => null,
|
||||
}));
|
||||
|
||||
// Mock ModelSelectionModal
|
||||
@@ -138,11 +136,7 @@ vi.mock("../ModelSelectionModal", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function renderQuickEntryBox(props = {}, { startCollapsed = false } = {}) {
|
||||
// Set disclosure state if needed
|
||||
if (startCollapsed) {
|
||||
localStorage.setItem("kb-quick-entry-expanded", "false");
|
||||
}
|
||||
function renderQuickEntryBox(props = {}) {
|
||||
const defaultProps = {
|
||||
onCreate: vi.fn().mockResolvedValue(undefined),
|
||||
addToast: vi.fn(),
|
||||
@@ -153,82 +147,38 @@ function renderQuickEntryBox(props = {}, { startCollapsed = false } = {}) {
|
||||
return { ...result, props: { ...defaultProps, ...props } };
|
||||
}
|
||||
|
||||
// Helper to expand the QuickEntryBox by clicking the toggle button
|
||||
function expandQuickEntry() {
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
fireEvent.click(toggleButton);
|
||||
}
|
||||
|
||||
describe("QuickEntryBox", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders textarea with placeholder", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
expect(textarea).toBeTruthy();
|
||||
expect(textarea.tagName.toLowerCase()).toBe("textarea");
|
||||
expect((textarea as HTMLTextAreaElement).placeholder).toBe("Add a task...");
|
||||
});
|
||||
|
||||
it("does NOT expand on focus", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
it("expands on focus", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
|
||||
// Should NOT auto-expand on focus (manual toggle only)
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
});
|
||||
|
||||
it("toggle button expands the view", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Initially not expanded
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
|
||||
// Click toggle to expand
|
||||
expandQuickEntry();
|
||||
|
||||
// Now expanded
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
});
|
||||
|
||||
it("toggle button collapses the view when expanded", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
it("collapses on blur when empty", async () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Expand first
|
||||
expandQuickEntry();
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
|
||||
// Click toggle again to collapse
|
||||
expandQuickEntry();
|
||||
|
||||
// Now collapsed
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT collapse on blur when empty", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Expand manually
|
||||
expandQuickEntry();
|
||||
fireEvent.focus(textarea);
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
|
||||
fireEvent.blur(textarea);
|
||||
@@ -236,19 +186,16 @@ describe("QuickEntryBox", () => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
// Should NOT collapse on blur
|
||||
await waitFor(() => {
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT collapse on blur when has content", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
it("collapses on blur even when has content", async () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Expand manually and add content
|
||||
expandQuickEntry();
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Some task" } });
|
||||
|
||||
fireEvent.blur(textarea);
|
||||
@@ -256,15 +203,14 @@ describe("QuickEntryBox", () => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
// Should NOT collapse on blur - expanded state persists
|
||||
// Wait for React to re-render after state change
|
||||
await waitFor(() => {
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("creates task on Enter key with TaskCreateInput", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "New task description" } });
|
||||
@@ -281,11 +227,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("allows Shift+Enter to insert newline when expanded", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Line 1" } });
|
||||
|
||||
// Shift+Enter should not prevent default (allow newline)
|
||||
@@ -296,8 +241,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("submits on Enter even when expanded (without Shift)", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
@@ -316,8 +260,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("prevents default on Enter key (without Shift)", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task" } });
|
||||
@@ -328,8 +271,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("shows loading state during creation", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
// Slow down the promise to see loading state
|
||||
props.onCreate.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
@@ -347,8 +289,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("clears input after successful creation", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||
@@ -362,8 +303,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("shows error toast on failure and keeps input content", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
props.onCreate.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
@@ -379,8 +319,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("clears non-empty input on Escape key", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Some text" } });
|
||||
@@ -391,11 +330,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("collapses and blurs on Escape key", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
@@ -404,8 +342,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("does not clear empty input on Escape key", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
@@ -413,8 +350,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("does not submit on Enter if input is empty", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
@@ -426,8 +362,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("does not submit on Enter if input is only whitespace", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: " " } });
|
||||
@@ -439,8 +374,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("updates textarea value on change", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Updated text" } });
|
||||
@@ -448,8 +382,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("trims whitespace when creating task", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: " Task with spaces " } });
|
||||
@@ -465,8 +398,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("maintains focus after successful creation", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||
@@ -481,46 +413,46 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
describe("Rich creation features", () => {
|
||||
it("shows dependency button when expanded", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
it("shows dependency button when focused", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Initially, no controls are visible
|
||||
// Initially, no controls are visible before focus
|
||||
expect(screen.queryByTestId("quick-entry-deps-button")).toBeNull();
|
||||
|
||||
// Expand and type something
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
// Focus and type something
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task with deps" } });
|
||||
|
||||
// Now the dependency button should be visible
|
||||
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows model selector button when expanded", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
it("shows model selector button when focused", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Initially, no controls are visible
|
||||
expect(screen.queryByTestId("quick-entry-models-button")).toBeNull();
|
||||
|
||||
// Expand and type something
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
// Focus and type something
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task with models" } });
|
||||
|
||||
// Now the model selector button should be visible
|
||||
expect(screen.getByTestId("quick-entry-models-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Plan and Subtask buttons when expanded", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
it("shows Plan and Subtask buttons when focused", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Initially, no controls are visible
|
||||
expect(screen.queryByTestId("plan-button")).toBeNull();
|
||||
expect(screen.queryByTestId("subtask-button")).toBeNull();
|
||||
|
||||
// Expand and type something
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
// Focus and type something
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to plan" } });
|
||||
|
||||
// Now the Plan and Subtask buttons should be visible
|
||||
@@ -529,10 +461,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("opens dependency dropdown when clicking deps button", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task with deps" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-deps-button"));
|
||||
|
||||
@@ -542,11 +474,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("opens model modal when clicking models button", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task with models" } });
|
||||
|
||||
// Modal should not be visible initially
|
||||
@@ -560,11 +491,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("modal receives correct props (models, loading state, etc.)", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task with models" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
|
||||
@@ -580,11 +510,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("selects dependencies and includes them in submit payload", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task with deps" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-deps-button"));
|
||||
|
||||
@@ -611,6 +540,7 @@ describe("QuickEntryBox", () => {
|
||||
const { props } = renderQuickEntryBox({ onPlanningMode });
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Plan this task" } });
|
||||
fireEvent.click(screen.getByTestId("plan-button"));
|
||||
|
||||
@@ -627,6 +557,7 @@ describe("QuickEntryBox", () => {
|
||||
const { props } = renderQuickEntryBox({ onSubtaskBreakdown });
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Break this down" } });
|
||||
fireEvent.click(screen.getByTestId("subtask-button"));
|
||||
|
||||
@@ -639,12 +570,11 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("disables Plan and Subtask buttons when description is empty", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Type something first to make buttons appear
|
||||
// Focus and type something first to make buttons appear
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Some task" } });
|
||||
|
||||
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
|
||||
@@ -666,11 +596,11 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("Plan button prevents textarea blur on mousedown", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Focus and expand
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to plan" } });
|
||||
|
||||
// Get plan button and trigger mousedown (prevents blur)
|
||||
@@ -685,11 +615,11 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("Subtask button prevents textarea blur on mousedown", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Focus and expand
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to break down" } });
|
||||
|
||||
// Get subtask button and trigger mousedown (prevents blur)
|
||||
@@ -706,10 +636,10 @@ describe("QuickEntryBox", () => {
|
||||
it("shows toast when Plan clicked with empty description", () => {
|
||||
const addToast = vi.fn();
|
||||
renderQuickEntryBox({ addToast });
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Type something first to make buttons appear
|
||||
// Focus and type something first to make buttons appear
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Some task" } });
|
||||
|
||||
// Clear input
|
||||
@@ -724,11 +654,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("includes selected models in submit payload", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task with model" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
|
||||
@@ -756,11 +685,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("closes modal on Escape when open", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task with modal" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
|
||||
@@ -778,11 +706,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("clears all state on second Escape after dropdowns are closed", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to clear" } });
|
||||
|
||||
// First Escape closes any dropdowns
|
||||
@@ -796,12 +723,11 @@ describe("QuickEntryBox", () => {
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
});
|
||||
|
||||
it("resets all state after successful creation (preserves disclosure preference)", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
it("resets all state after successful creation", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to reset" } });
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
@@ -810,12 +736,11 @@ describe("QuickEntryBox", () => {
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// After creation, input should be cleared
|
||||
// After creation, controls should be collapsed
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
// Disclosure preference persists - controls remain visible since we expanded earlier
|
||||
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("plan-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("subtask-button")).toBeTruthy();
|
||||
expect(screen.queryByTestId("quick-entry-deps-button")).toBeNull();
|
||||
expect(screen.queryByTestId("plan-button")).toBeNull();
|
||||
expect(screen.queryByTestId("subtask-button")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -829,84 +754,11 @@ describe("QuickEntryBox", () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("restores disclosure state from localStorage on mount", () => {
|
||||
// Pre-populate localStorage with expanded state
|
||||
localStorage.setItem("kb-quick-entry-expanded", "true");
|
||||
|
||||
renderQuickEntryBox();
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
|
||||
// Should restore the saved disclosure state (expanded)
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
// Controls should be visible
|
||||
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("defaults to expanded when localStorage is empty", () => {
|
||||
renderQuickEntryBox();
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
|
||||
// Should default to expanded (true) for backward compatibility
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
// Controls should be visible
|
||||
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("updates localStorage when toggling disclosure", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
|
||||
// Wait for initial state to be persisted (useEffect runs after mount)
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-quick-entry-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
// Initially collapsed
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Should be expanded
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
// localStorage should be updated
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-quick-entry-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Should be collapsed
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
// localStorage should be updated
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-quick-entry-expanded")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
it("aria-expanded attribute updates correctly when toggling", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
|
||||
// Initially collapsed
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(toggleButton);
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(toggleButton);
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("restores description from localStorage on mount", () => {
|
||||
// Pre-populate localStorage
|
||||
localStorage.setItem("kb-quick-entry-text", "Saved task description");
|
||||
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Should restore the saved description
|
||||
@@ -914,8 +766,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("updates localStorage when typing", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Typing this task" } });
|
||||
@@ -927,8 +778,7 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("clears localStorage after successful task creation", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Type something to set localStorage
|
||||
@@ -949,12 +799,11 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("clears localStorage when Escape clears non-empty input", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Type something to set localStorage
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to clear" } });
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-quick-entry-text")).toBe("Task to clear");
|
||||
@@ -969,12 +818,11 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("does not clear localStorage on first Escape when closing dropdowns", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Type something and open dropdown
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task with dropdown" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-deps-button"));
|
||||
|
||||
@@ -991,16 +839,15 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
describe("AI Refine feature", () => {
|
||||
it("shows refine button when expanded and text is entered", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
it("shows refine button when text is entered", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Initially, refine button is not visible
|
||||
expect(screen.queryByTestId("refine-button")).toBeNull();
|
||||
|
||||
// Expand and type something
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
// Focus and type something
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to refine" } });
|
||||
|
||||
// Now the refine button should be visible
|
||||
@@ -1008,12 +855,11 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("refine button is hidden when textarea is empty", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Type something
|
||||
// Focus and type something
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Some text" } });
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
|
||||
@@ -1028,11 +874,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("opens refine menu on button click", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to refine" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
@@ -1044,11 +889,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("closes refine menu on Escape key", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to refine" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
@@ -1067,11 +911,10 @@ describe("QuickEntryBox", () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
|
||||
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
@@ -1091,11 +934,10 @@ describe("QuickEntryBox", () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
|
||||
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
@@ -1121,11 +963,10 @@ describe("QuickEntryBox", () => {
|
||||
|
||||
const { getRefineErrorMessage } = await import("../../api");
|
||||
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
@@ -1143,11 +984,10 @@ describe("QuickEntryBox", () => {
|
||||
// Slow down the promise to see loading state
|
||||
vi.mocked(refineText).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
@@ -1166,11 +1006,10 @@ describe("QuickEntryBox", () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description with much more content here");
|
||||
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Short" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-expand"));
|
||||
@@ -1184,12 +1023,11 @@ describe("QuickEntryBox", () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined text");
|
||||
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Open refine menu but don't select anything
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
@@ -1208,16 +1046,15 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
describe("Save button", () => {
|
||||
it("shows save button when expanded and text is entered", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
it("shows save button when text is entered", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Initially, save button is not visible
|
||||
expect(screen.queryByTestId("save-button")).toBeNull();
|
||||
|
||||
// Expand and type something
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
// Focus and type something
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to save" } });
|
||||
|
||||
// Now the save button should be visible
|
||||
@@ -1225,12 +1062,11 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("save button is disabled when textarea is empty", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Type something
|
||||
// Focus and type something
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Some text" } });
|
||||
expect(screen.getByTestId("save-button")).toBeTruthy();
|
||||
|
||||
@@ -1245,14 +1081,13 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("save button is disabled during submission", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
// Slow down the promise to see loading state
|
||||
props.onCreate.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "New task" } });
|
||||
|
||||
// Start submission with Enter key
|
||||
@@ -1268,11 +1103,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("clicking save button persists to localStorage", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Draft task description" } });
|
||||
|
||||
// Click the save button
|
||||
@@ -1285,11 +1119,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("clicking save button creates the task", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to save" } });
|
||||
|
||||
// Click the save button
|
||||
@@ -1307,11 +1140,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("save button has correct test id", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to save" } });
|
||||
|
||||
// Button should have data-testid="save-button"
|
||||
@@ -1320,11 +1152,10 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("save button has correct title attribute", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to save" } });
|
||||
|
||||
const saveButton = screen.getByTestId("save-button");
|
||||
@@ -1332,11 +1163,11 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("save button prevents textarea blur on mousedown", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
expandQuickEntry();
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Focus and expand
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to save" } });
|
||||
|
||||
// Get save button and trigger mousedown (prevents blur)
|
||||
|
||||
@@ -689,7 +689,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
// Check that no elements in the settings content have inline styles
|
||||
const elementsWithStyle = container.querySelectorAll("[style]");
|
||||
expect(elementsWithStyle.length).toBe(1);
|
||||
expect(elementsWithStyle.length).toBe(0);
|
||||
});
|
||||
|
||||
it("shows Thinking Effort dropdown with correct options in Model section", async () => {
|
||||
@@ -833,14 +833,14 @@ describe("SettingsModal", () => {
|
||||
expect(layout!.querySelector(".settings-content")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has .settings-sidebar with 12 .settings-nav-item buttons for all sections", async () => {
|
||||
it("has .settings-sidebar with 11 .settings-nav-item buttons for all sections", async () => {
|
||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const sidebar = container.querySelector(".settings-sidebar");
|
||||
expect(sidebar).toBeTruthy();
|
||||
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
|
||||
expect(navItems.length).toBe(12);
|
||||
expect(navItems.length).toBe(11);
|
||||
|
||||
// Labels include scope emoji indicators (🌐 for global, 📁 for project)
|
||||
const labels = Array.from(navItems).map((el) => el.textContent);
|
||||
@@ -848,7 +848,6 @@ describe("SettingsModal", () => {
|
||||
"📁General",
|
||||
"🌐Model",
|
||||
"📁Model Presets",
|
||||
"📁AI Summarization",
|
||||
"🌐Appearance",
|
||||
"📁Scheduling",
|
||||
"📁Worktrees",
|
||||
|
||||
@@ -487,7 +487,7 @@ describe("TaskCard file-scope overlap badge logic", () => {
|
||||
}
|
||||
|
||||
it("generates correct tooltip text", () => {
|
||||
expect(computeScopeTooltip("FN-005")).toBe("Blocked by FN-005 (file overlap)");
|
||||
expect(computeScopeTooltip("FN-005")).toBe("Blocked by KB-005 (file overlap)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -647,7 +647,7 @@ describe("TaskCard clickable dependencies", () => {
|
||||
fireEvent.click(depBadge);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load dependency FN-001", "error");
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load dependency KB-001", "error");
|
||||
});
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2295,17 +2295,18 @@ describe("TaskCard GitHub badges", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for task detail opening behavior in TaskCard.
|
||||
* The card body opens the modal directly; there is no separate expand button.
|
||||
* Tests for expand button and modal open behavior in TaskCard.
|
||||
* Ensures that clicking the expand button opens the modal,
|
||||
* while clicking the card body does not.
|
||||
*/
|
||||
describe("TaskCard detail opening", () => {
|
||||
describe("TaskCard expand button", () => {
|
||||
const noopToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens modal when clicking the card body", async () => {
|
||||
it("opens modal when clicking the expand button", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
@@ -2329,8 +2330,11 @@ describe("TaskCard detail opening", () => {
|
||||
const card = document.querySelector('[data-id="FN-099"]');
|
||||
expect(card).toBeDefined();
|
||||
|
||||
const cardTitle = screen.getByText("Test task");
|
||||
fireEvent.click(cardTitle);
|
||||
const expandButton = screen.getByRole("button", { name: /Open task details/i });
|
||||
expect(expandButton).toBeDefined();
|
||||
expect(expandButton.classList.contains("card-expand-btn")).toBe(true);
|
||||
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099");
|
||||
@@ -2338,24 +2342,10 @@ describe("TaskCard detail opening", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render a separate expand button", () => {
|
||||
const task = makeTask();
|
||||
|
||||
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={noopToast} />);
|
||||
expect(screen.queryByRole("button", { name: /Open task details/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens modal only once per card click", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
...makeTask({ id: "FN-099" }),
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockDetail);
|
||||
it("does NOT open modal when clicking the card body", async () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
const task = makeTask();
|
||||
|
||||
const task = makeTask({ title: "Test Task Title" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
@@ -2365,13 +2355,35 @@ describe("TaskCard detail opening", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Test task"));
|
||||
const card = document.querySelector('[data-id="FN-099"]');
|
||||
expect(card).toBeDefined();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099");
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Click on the card title (part of card body)
|
||||
const cardTitle = screen.getByText("Test Task Title");
|
||||
fireEvent.click(cardTitle);
|
||||
|
||||
// Wait for any async operations
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Modal should NOT have opened
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expand button has correct accessibility attributes", () => {
|
||||
const task = makeTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const expandButton = screen.getByRole("button", { name: /Open task details/i });
|
||||
expect(expandButton).toBeDefined();
|
||||
expect(expandButton.getAttribute("aria-label")).toBe("Open task details");
|
||||
expect(expandButton.getAttribute("title")).toBe("Open task details");
|
||||
});
|
||||
|
||||
it("does NOT open modal during vertical scrolling", async () => {
|
||||
@@ -2472,7 +2484,7 @@ describe("TaskCard detail opening", () => {
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render an expand button in any column", () => {
|
||||
it("expand button is present in all columns", () => {
|
||||
const columns: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
|
||||
|
||||
for (const column of columns) {
|
||||
@@ -2486,11 +2498,49 @@ describe("TaskCard detail opening", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Open task details/i })).toBeNull();
|
||||
const expandButton = screen.getByRole("button", { name: /Open task details/i });
|
||||
expect(expandButton).toBeDefined();
|
||||
expect(expandButton.classList.contains("card-expand-btn")).toBe(true);
|
||||
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("expand button stops propagation to prevent double-triggering", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
...makeTask({ id: "FN-099" }),
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockDetail);
|
||||
const onOpenDetail = vi.fn();
|
||||
|
||||
const task = makeTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = document.querySelector('[data-id="FN-099"]');
|
||||
expect(card).toBeDefined();
|
||||
|
||||
const expandButton = screen.getByRole("button", { name: /Open task details/i });
|
||||
|
||||
// Click the expand button - should only trigger once
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099");
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1341,7 +1341,7 @@ describe("TaskDetailModal", () => {
|
||||
renderWithSearch();
|
||||
fireEvent.click(screen.getByText("Add Dependency"));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "fn-020" } });
|
||||
fireEvent.change(input, { target: { value: "kb-020" } });
|
||||
|
||||
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -1465,7 +1465,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(depLink);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load dependency FN-001", "error");
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load dependency KB-001", "error");
|
||||
});
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1805,7 +1805,7 @@ describe("TaskDetailModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Plan approved — FN-001 moved to Todo", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Plan approved — KB-001 moved to Todo", "success");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1846,7 +1846,7 @@ describe("TaskDetailModal", () => {
|
||||
expect(mockRejectPlan).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
"Plan rejected — FN-001 returned to Triage for re-specification",
|
||||
"Plan rejected — KB-001 returned to Triage for re-specification",
|
||||
"info"
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
@@ -2013,7 +2013,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Duplicate"));
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
"Duplicate FN-001? This will create a new task in Triage with the same description and prompt."
|
||||
"Duplicate KB-001? This will create a new task in Triage with the same description and prompt."
|
||||
);
|
||||
|
||||
window.confirm = originalConfirm;
|
||||
@@ -2072,7 +2072,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Duplicate"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Duplicated FN-001 → FN-002", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Duplicated KB-001 → KB-002", "success");
|
||||
});
|
||||
|
||||
window.confirm = originalConfirm;
|
||||
@@ -2393,7 +2393,7 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineTask).toHaveBeenCalledWith("FN-001", "Need to add more tests");
|
||||
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-002", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Refinement task created: KB-002", "success");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2682,7 +2682,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Updated FN-001", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Updated KB-001", "success");
|
||||
});
|
||||
|
||||
// Should exit edit mode
|
||||
|
||||
Reference in New Issue
Block a user