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 { toast } from '@/components/ui'; import { Checkbox } from '@/components/ui/checkbox'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Icon } from "@/components/icon/Icon"; import { deleteProjectPlanFile, getProjectContextData, importProjectPlanFileFromContent, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, readProjectPlanFile, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH, saveProjectNotesAndTodos, type OpenChamberProjectPlanFileLink, type OpenChamberProjectTodoItem, type ProjectRef, } from '@/lib/openchamberConfig'; import { requestFileAccess } from '@/lib/desktop'; import { generateBranchName } from '@/lib/git/branchNameGenerator'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useUIStore } from '@/stores/useUIStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import { useInputStore } from '@/sync/input-store'; import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; import { cn } from '@/lib/utils'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { TodoSendDialog, type TodoSendExecution } from './TodoSendDialog'; const TODO_PANEL_MIN_ITEMS = 5; const TODO_PANEL_MAX_ITEMS = 15; // Per-project chain of in-flight saveProjectNotesAndTodos calls. Subsequent // saves await the previous one so a fast todo toggle or blur that lands // while the debounced notes save is still on the wire is appended, not // racing against it. The chain is module-scoped so it survives remounts // (e.g. when the user switches the right sidebar tab away and back). const projectSaveChainByProject = new Map>(); const getEffectiveItemHeight = (padding: number) => { const scale = Math.sqrt(padding / 100); const paddingPx = 12 * scale; const contentPx = 24 * scale; // h-6 uses --spacing-6 which also scales with --padding-scale const borderPx = 1; return Math.ceil(paddingPx + contentPx + borderPx); }; const getPanelHeightForItems = (itemCount: number, padding: number) => { const itemHeight = getEffectiveItemHeight(padding); return Math.max( itemHeight * TODO_PANEL_MIN_ITEMS, Math.min(itemHeight * TODO_PANEL_MAX_ITEMS, itemHeight * itemCount) ); }; interface ProjectNotesTodoPanelProps { projectRef: ProjectRef | null; projectLabel?: string | null; canCreateWorktree?: boolean; onActionComplete?: () => void; /** When provided, opening a plan calls this instead of the desktop context panel tab — hosts without ContextPanel (mobile) render their own viewer. */ onOpenPlan?: (plan: { path: string; title: string }) => void; className?: string; } type PendingSendTarget = { kind: 'session' | 'worktree'; todoId: string; todoText: string; }; type ProjectPlanListItem = OpenChamberProjectPlanFileLink & { title: string; }; const toPlanListItem = async ( plan: OpenChamberProjectPlanFileLink, fallbackTitle: string, ): Promise => { const file = await readProjectPlanFile(plan.path); return { ...plan, title: file?.title || plan.path.split('/').pop() || fallbackTitle, }; }; 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: OpenChamberProjectTodoItem[]): OpenChamberProjectTodoItem[] => [ ...items.filter((todo) => !todo.completed), ...items.filter((todo) => todo.completed), ]; const insertTodoBeforeCompleted = (items: OpenChamberProjectTodoItem[], item: OpenChamberProjectTodoItem): OpenChamberProjectTodoItem[] => { 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 ProjectNotesTodoPanel: React.FC = ({ projectRef, projectLabel, canCreateWorktree = false, onActionComplete, onOpenPlan, className, }) => { const { t } = useI18n(); const [isLoading, setIsLoading] = React.useState(false); const [notes, setNotes] = React.useState(''); const [todos, setTodos] = React.useState([]); const [newTodoText, setNewTodoText] = React.useState(''); const [sendingTodoId, setSendingTodoId] = React.useState(null); const [expandedTodoIds, setExpandedTodoIds] = React.useState>(() => new Set()); const [plans, setPlans] = React.useState([]); const [pendingSendTarget, setPendingSendTarget] = React.useState(null); const [isSendDialogSubmitting, setIsSendDialogSubmitting] = React.useState(false); const [contextReloadTick, setContextReloadTick] = React.useState(0); const notesHydratedRef = React.useRef(false); const lastSavedNotesRef = React.useRef(''); const notesDebounceTimerRef = React.useRef(null); const todoPanelHeight = useUIStore((state) => state.todoPanelHeight); const setTodoPanelHeight = useUIStore((state) => state.setTodoPanelHeight); const notesPanelHeight = useUIStore((state) => state.notesPanelHeight); const setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight); const [isTodoPanelResizing, setIsTodoPanelResizing] = React.useState(false); const todoPanelStartYRef = React.useRef(0); const todoPanelStartHeightRef = React.useRef(todoPanelHeight); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const createSession = useSessionUIStore((state) => state.createSession); const initializeNewOpenChamberSession = useSessionUIStore((state) => state.initializeNewOpenChamberSession); const sendMessage = useSessionUIStore((state) => state.sendMessage); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setPendingInputText = useInputStore((state) => state.setPendingInputText); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const padding = useUIStore((state) => state.padding); const persistProjectData = React.useCallback( async (nextNotes: string, nextTodos: OpenChamberProjectTodoItem[]) => { if (!projectRef) { return false; } const key = projectRef.id; // Serialize concurrent saves per project: a fast toggle/strike while the // debounce-driven notes save is in flight no longer races the network. const previous = projectSaveChainByProject.get(key) ?? Promise.resolve(); const next = previous.catch(() => undefined).then(() => saveProjectNotesAndTodos(projectRef, { notes: nextNotes, todos: nextTodos, }) ); projectSaveChainByProject.set(key, next); try { const saved = await next; if (!saved) { toast.error(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed')); } return saved; } finally { if (projectSaveChainByProject.get(key) === next) { projectSaveChainByProject.delete(key); } } }, [projectRef, t] ); React.useEffect(() => { if (!projectRef) { setNotes(''); setTodos([]); setPlans([]); setNewTodoText(''); setExpandedTodoIds(new Set()); return; } let cancelled = false; setIsLoading(true); (async () => { try { const data = await getProjectContextData(projectRef); const nextPlans = await Promise.all( data.plans.map((plan) => toPlanListItem(plan, t('rightSidebar.contextNotesTodo.plan.defaultTitle'))) ); if (cancelled) { return; } setNotes(data.notes); setTodos(sortTodosWithCompletedLast(data.todos)); setPlans(nextPlans); lastSavedNotesRef.current = data.notes; notesHydratedRef.current = true; setNewTodoText(''); setExpandedTodoIds(new Set()); } catch { if (!cancelled) { toast.error(t('rightSidebar.contextNotesTodo.toast.loadNotesFailed')); setNotes(''); setTodos([]); setPlans([]); lastSavedNotesRef.current = ''; notesHydratedRef.current = true; } } finally { if (!cancelled) { setIsLoading(false); } } })(); return () => { cancelled = true; }; }, [contextReloadTick, projectRef, t]); React.useEffect(() => { if (!projectRef) { return; } const handleProjectContextRefresh = (event: Event) => { const detail = (event as CustomEvent<{ projectId?: string }>).detail; if (detail?.projectId && detail.projectId !== projectRef.id) { return; } setContextReloadTick((previous) => previous + 1); }; window.addEventListener('openchamber:project-plan-saved', handleProjectContextRefresh); window.addEventListener('openchamber:project-notes-updated', handleProjectContextRefresh); return () => { window.removeEventListener('openchamber:project-plan-saved', handleProjectContextRefresh); window.removeEventListener('openchamber:project-notes-updated', handleProjectContextRefresh); }; }, [projectRef]); React.useEffect(() => { if (todos.length < 7) { return; } const targetHeight = getPanelHeightForItems(todos.length, padding); const minHeight = getEffectiveItemHeight(padding) * TODO_PANEL_MIN_ITEMS; if ( todoPanelHeight !== targetHeight && (todoPanelHeight < minHeight || todoPanelHeight > targetHeight) ) { setTodoPanelHeight(targetHeight); } }, [todos.length, padding, todoPanelHeight, setTodoPanelHeight]); React.useEffect(() => { if (!isTodoPanelResizing) { return; } const handlePointerMove = (event: PointerEvent) => { const delta = event.clientY - todoPanelStartYRef.current; const nextHeight = Math.min( getEffectiveItemHeight(padding) * TODO_PANEL_MAX_ITEMS, Math.max(getEffectiveItemHeight(padding) * TODO_PANEL_MIN_ITEMS, todoPanelStartHeightRef.current + delta) ); setTodoPanelHeight(nextHeight); }; const handlePointerEnd = () => { setIsTodoPanelResizing(false); }; window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerEnd, { once: true }); window.addEventListener('pointercancel', handlePointerEnd, { once: true }); return () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerEnd); window.removeEventListener('pointercancel', handlePointerEnd); }; }, [isTodoPanelResizing, padding, setTodoPanelHeight]); const handleTodoPanelResizeStart = React.useCallback((event: React.PointerEvent) => { setIsTodoPanelResizing(true); todoPanelStartYRef.current = event.clientY; todoPanelStartHeightRef.current = todoPanelHeight; event.preventDefault(); }, [todoPanelHeight]); const cancelNotesDebounce = React.useCallback(() => { if (notesDebounceTimerRef.current !== null) { window.clearTimeout(notesDebounceTimerRef.current); notesDebounceTimerRef.current = null; } }, []); const handleNotesBlur = React.useCallback(() => { cancelNotesDebounce(); lastSavedNotesRef.current = notes; void persistProjectData(notes, todos); }, [cancelNotesDebounce, notes, persistProjectData, todos]); React.useEffect(() => { if (!projectRef || !notesHydratedRef.current) { return; } if (notes === lastSavedNotesRef.current) { return; } notesDebounceTimerRef.current = window.setTimeout(() => { notesDebounceTimerRef.current = null; lastSavedNotesRef.current = notes; void persistProjectData(notes, todos); }, 400); return () => { cancelNotesDebounce(); }; }, [cancelNotesDebounce, notes, persistProjectData, projectRef, todos]); React.useEffect(() => () => cancelNotesDebounce(), [cancelNotesDebounce]); const handleAddTodo = React.useCallback(() => { const trimmed = newTodoText.trim(); if (!trimmed) { return; } const nextTodos = insertTodoBeforeCompleted(todos, { id: createTodoId(), text: trimmed.slice(0, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH), completed: false, createdAt: Date.now(), }); setTodos(nextTodos); setNewTodoText(''); void persistProjectData(notes, nextTodos); }, [newTodoText, notes, persistProjectData, 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 remainingTodos = todos.filter((item) => item.id !== id); const updatedTodo = { ...todo, completed }; const nextTodos = completed ? [...remainingTodos, updatedTodo] : insertTodoBeforeCompleted(remainingTodos, updatedTodo); setTodos(nextTodos); void persistProjectData(notes, nextTodos); }, [notes, persistProjectData, todos] ); const handleDeleteTodo = React.useCallback( (id: string) => { const nextTodos = todos.filter((todo) => todo.id !== id); setTodos(nextTodos); void persistProjectData(notes, nextTodos); }, [notes, persistProjectData, todos] ); const handleClearCompletedTodos = React.useCallback(() => { const nextTodos = todos.filter((todo) => !todo.completed); if (nextTodos.length === todos.length) { return; } setTodos(nextTodos); void persistProjectData(notes, nextTodos); }, [notes, persistProjectData, 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; } const nextTodos = sortTodosWithCompletedLast(arrayMove(todos, oldIndex, newIndex)); setTodos(nextTodos); void persistProjectData(notes, nextTodos); }, [notes, persistProjectData, todos] ); const todoSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) ); const todoInputValue = newTodoText.slice(0, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH); const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0); const routeToChat = React.useCallback(() => { setActiveMainTab('chat'); setSessionSwitcherOpen(false); }, [setActiveMainTab, setSessionSwitcherOpen]); const handleSendToNewSession = React.useCallback( (todoId: string, todoText: string) => { if (!projectRef || sendingTodoId) { return; } setPendingSendTarget({ kind: 'session', todoId, todoText }); }, [projectRef, sendingTodoId] ); const handleSendToCurrentSession = React.useCallback( (todoText: string) => { if (!currentSessionId) { toast.error(t('rightSidebar.contextNotesTodo.toast.noActiveSession')); return; } routeToChat(); const fenced = `\`\`\`md\n${todoText}\n\`\`\``; setPendingInputText(fenced, 'append'); toast.success(t('rightSidebar.contextNotesTodo.toast.sentToCurrentSession')); onActionComplete?.(); }, [currentSessionId, onActionComplete, routeToChat, setPendingInputText, t] ); const handleSendToNewWorktreeSession = React.useCallback( (todoId: string, todoText: string) => { if (!projectRef || sendingTodoId) { return; } if (!canCreateWorktree) { toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo')); return; } setPendingSendTarget({ kind: 'worktree', todoId, todoText }); }, [canCreateWorktree, projectRef, sendingTodoId, t] ); const handleConfirmSend = React.useCallback( async (execution: TodoSendExecution) => { if (!projectRef || !pendingSendTarget) { return; } const visiblePrompt = await renderMagicPrompt('plan.todo.visible', { todo_text: pendingSendTarget.todoText, }); const instructionsText = await renderMagicPrompt('plan.todo.instructions', { todo_text: pendingSendTarget.todoText, }); const syntheticParts = [{ synthetic: true as const, text: instructionsText }]; setIsSendDialogSubmitting(true); setSendingTodoId(pendingSendTarget.todoId); try { routeToChat(); let sessionId: string | null = null; let directoryHint: string | null = projectRef.path; if (pendingSendTarget.kind === 'worktree') { if (!canCreateWorktree) { toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo')); return; } const created = await createWorktreeSessionForNewBranch(projectRef.path, generateBranchName()); if (!created?.id) { return; } sessionId = created.id; directoryHint = created.path; } else { const session = await createSession(undefined, projectRef.path, null); if (!session?.id) { toast.error(t('rightSidebar.contextNotesTodo.toast.createSessionFailed')); return; } sessionId = session.id; directoryHint = session.directory ?? projectRef.path; initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents ?? []); } if (!sessionId) { return; } const selectionState = useSelectionStore.getState(); selectionState.saveSessionModelSelection(sessionId, execution.providerID, execution.modelID); if (execution.agent.trim()) { selectionState.saveSessionAgentSelection(sessionId, execution.agent); selectionState.saveAgentModelForSession(sessionId, execution.agent, execution.providerID, execution.modelID); selectionState.saveAgentModelVariantForSession( sessionId, execution.agent, execution.providerID, execution.modelID, execution.variant || undefined, ); } setCurrentSession(sessionId, directoryHint); await sendMessage( visiblePrompt, execution.providerID, execution.modelID, execution.agent.trim() || undefined, undefined, undefined, syntheticParts, execution.variant || undefined, ); toast.success( pendingSendTarget.kind === 'worktree' ? t('rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession') : t('rightSidebar.contextNotesTodo.toast.sentToNewSession') ); setPendingSendTarget(null); onActionComplete?.(); } catch (error) { const description = error instanceof Error ? error.message : undefined; toast.error(t('rightSidebar.contextNotesTodo.toast.sendTodoFailed'), description ? { description } : undefined); } finally { setIsSendDialogSubmitting(false); setSendingTodoId(null); } }, [canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession, t] ); const planFileInputRef = React.useRef(null); const [isImportingPlan, setIsImportingPlan] = React.useState(false); const [deletingPlanId, setDeletingPlanId] = React.useState(null); const handleDeletePlan = React.useCallback( async (planId: string) => { if (!projectRef || deletingPlanId) { return; } setDeletingPlanId(planId); try { const ok = await deleteProjectPlanFile(projectRef, planId); if (!ok) { toast.error(t('rightSidebar.contextNotesTodo.toast.deletePlanFailed')); return; } setPlans((previous) => previous.filter((entry) => entry.id !== planId)); window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { detail: { projectId: projectRef.id }, })); } finally { setDeletingPlanId(null); } }, [deletingPlanId, projectRef, t] ); const handleTriggerUploadPlan = React.useCallback(async () => { if (!projectRef || isImportingPlan) { return; } const result = await requestFileAccess({ defaultPath: projectRef.path, filters: [ { name: 'Plan files', extensions: ['md', 'markdown', 'txt'] }, { name: 'All files', extensions: ['*'] }, ], }); if (result.success && result.path) { setIsImportingPlan(true); try { const params = new URLSearchParams({ path: result.path, allowOutsideWorkspace: 'true', }); if (result.outsideFileGrant) { params.set('outsideFileGrant', result.outsideFileGrant); } const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' }); if (!response.ok) { toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed')); return; } const text = await response.text(); if (!text.trim()) { toast.error(t('rightSidebar.contextNotesTodo.toast.planFileEmpty')); return; } const fallbackTitle = result.path.split('/').pop()?.replace(/\.(md|markdown|txt)$/i, '').trim() || ''; const created = await importProjectPlanFileFromContent(projectRef, text, fallbackTitle); if (!created) { toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed')); return; } window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { detail: { projectId: projectRef.id }, })); toast.success(t('rightSidebar.contextNotesTodo.toast.planImported')); } catch (error) { const description = error instanceof Error ? error.message : undefined; toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined); } finally { setIsImportingPlan(false); } } else if (result.error === 'Native file picker not available') { // Fall back to HTML file input for web/non-desktop runtimes planFileInputRef.current?.click(); } }, [isImportingPlan, projectRef, t]); const handleUploadPlanFile = React.useCallback( async (file: File | null) => { if (!projectRef || !file) { return; } setIsImportingPlan(true); try { const text = await file.text(); if (!text.trim()) { toast.error(t('rightSidebar.contextNotesTodo.toast.planFileEmpty')); return; } const fallbackTitle = file.name.replace(/\.(md|markdown|txt)$/i, '').trim(); const created = await importProjectPlanFileFromContent(projectRef, text, fallbackTitle); if (!created) { toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed')); return; } window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', { detail: { projectId: projectRef.id }, })); toast.success(t('rightSidebar.contextNotesTodo.toast.planImported')); } catch (error) { const description = error instanceof Error ? error.message : undefined; toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined); } finally { setIsImportingPlan(false); } }, [projectRef, t] ); const handleOpenPlan = React.useCallback( (plan: ProjectPlanListItem) => { if (onOpenPlan) { onOpenPlan({ path: plan.path, title: plan.title }); return; } const projectPath = projectRef?.path?.trim(); const panelDirectory = currentDirectory?.trim() || projectPath; if (!panelDirectory) { return; } openContextPanelTab(panelDirectory, { mode: 'plan', targetPath: plan.path, dedupeKey: plan.path, label: plan.title, }); }, [currentDirectory, onOpenPlan, openContextPanelTab, projectRef] ); if (!projectRef) { return (

    {t('rightSidebar.contextNotesTodo.empty.selectProject')}

    ); } return (

    {t('rightSidebar.contextNotesTodo.notes.title', { project: projectLabel?.trim() || projectRef.path.split('/').filter(Boolean).pop() || projectRef.path, })}

    {notes.length}/{OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH}