import { matchesRankQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core'; import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'; import { CSS as DndCSS } from '@dnd-kit/utilities'; import { Checkbox } from '@/components/ui/checkbox'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Icon } from '@/components/icon/Icon'; import { useI18n } from '@/lib/i18n'; import { PROJECT_TODO_TEXT_MAX_LENGTH, type ProjectTodoItem } from '@/lib/projectContextApi'; import { cn } from '@/lib/utils'; const createTodoId = (): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return `todo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }; const sortTodosWithCompletedLast = (items: ProjectTodoItem[]): ProjectTodoItem[] => [ ...items.filter((todo) => !todo.completed), ...items.filter((todo) => todo.completed), ]; const insertTodoBeforeCompleted = (items: ProjectTodoItem[], item: ProjectTodoItem): ProjectTodoItem[] => { const firstCompletedIndex = items.findIndex((todo) => todo.completed); if (firstCompletedIndex === -1) { return [...items, item]; } return [...items.slice(0, firstCompletedIndex), item, ...items.slice(firstCompletedIndex)]; }; type SortableTodoHandleProps = { attributes: ReturnType['attributes']; listeners: ReturnType['listeners']; setActivatorNodeRef: ReturnType['setActivatorNodeRef']; isDragging: boolean; }; const SortableTodoItem: React.FC<{ id: string; children: (dragHandleProps: SortableTodoHandleProps) => React.ReactNode; }> = ({ id, children }) => { const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging, } = useSortable({ id }); return (
  • {children({ attributes, listeners, setActivatorNodeRef, isDragging })}
  • ); }; export const TodosSection: React.FC<{ todos: ProjectTodoItem[]; /** Panel-wide filter. Mutations still act on the full list. */ query: string; disabled: boolean; canCreateWorktree: boolean; sendingTodoId: string | null; /** Persists the whole list through the container's store write. */ onPersistTodos: (next: ProjectTodoItem[]) => void; onSendToCurrentSession: (todoText: string) => void; onSendToNewSession: (todoId: string, todoText: string) => void; onSendToNewWorktreeSession: (todoId: string, todoText: string) => void; }> = ({ todos, query, disabled, canCreateWorktree, sendingTodoId, onPersistTodos, onSendToCurrentSession, onSendToNewSession, onSendToNewWorktreeSession, }) => { const { t } = useI18n(); const [newTodoText, setNewTodoText] = React.useState(''); const [expandedTodoIds, setExpandedTodoIds] = React.useState>(() => new Set()); const handleAddTodo = React.useCallback(() => { const trimmed = newTodoText.trim(); if (!trimmed) { return; } onPersistTodos(insertTodoBeforeCompleted(todos, { id: createTodoId(), text: trimmed.slice(0, PROJECT_TODO_TEXT_MAX_LENGTH), completed: false, createdAt: Date.now(), })); setNewTodoText(''); }, [newTodoText, onPersistTodos, todos]); const handleToggleTodoExpanded = React.useCallback((id: string) => { setExpandedTodoIds((previous) => { const next = new Set(previous); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }, []); const handleToggleTodo = React.useCallback( (id: string, completed: boolean) => { const todo = todos.find((item) => item.id === id); if (!todo || todo.completed === completed) { return; } const remaining = todos.filter((item) => item.id !== id); const updated = { ...todo, completed }; onPersistTodos(completed ? [...remaining, updated] : insertTodoBeforeCompleted(remaining, updated)); }, [onPersistTodos, todos] ); const handleDeleteTodo = React.useCallback( (id: string) => { onPersistTodos(todos.filter((todo) => todo.id !== id)); }, [onPersistTodos, todos] ); const handleClearCompletedTodos = React.useCallback(() => { const next = todos.filter((todo) => !todo.completed); if (next.length === todos.length) { return; } onPersistTodos(next); }, [onPersistTodos, todos]); const handleTodoReorder = React.useCallback( (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) { return; } const oldIndex = todos.findIndex((todo) => todo.id === active.id); const newIndex = todos.findIndex((todo) => todo.id === over.id); if (oldIndex === -1 || newIndex === -1) { return; } onPersistTodos(sortTodosWithCompletedLast(arrayMove(todos, oldIndex, newIndex))); }, [onPersistTodos, todos] ); const todoSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) ); const todoInputValue = newTodoText.slice(0, PROJECT_TODO_TEXT_MAX_LENGTH); const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0); // Filtering is display-only: every handler above still edits the full list, // so reordering or clearing while a filter is active cannot drop hidden items. const visibleTodos = React.useMemo( () => todos.filter((todo) => matchesRankQuery([todo.text], query)), [query, todos], ); return (
    {todoInputValue.length}/{PROJECT_TODO_TEXT_MAX_LENGTH}
    setNewTodoText(event.target.value.slice(0, PROJECT_TODO_TEXT_MAX_LENGTH))} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); handleAddTodo(); } }} placeholder={t('rightSidebar.contextNotesTodo.todo.inputPlaceholder')} disabled={disabled} className="h-8" />
    {visibleTodos.length === 0 ? (

    {query.trim() ? t('rightSidebar.contextNotesTodo.search.noResults', { query: query.trim() }) : t('rightSidebar.contextNotesTodo.todo.empty')}

    ) : ( todo.id)} strategy={verticalListSortingStrategy} >
      {visibleTodos.map((todo) => { const isExpandedTodo = expandedTodoIds.has(todo.id); return ( {(dragHandleProps) => (
      handleToggleTodo(todo.id, checked)} ariaLabel={t('rightSidebar.contextNotesTodo.todo.actions.markComplete', { text: todo.text })} />
      onSendToCurrentSession(todo.text)}> {t('rightSidebar.contextNotesTodo.todo.sendMenu.currentSession')} onSendToNewSession(todo.id, todo.text)}> {t('rightSidebar.contextNotesTodo.todo.sendMenu.newSession')} onSendToNewWorktreeSession(todo.id, todo.text)} disabled={!canCreateWorktree} > {t('rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession')}
      )}
      ); })}
    )}
    ); };