From d28d548bbcd6c58c3609d115cbed68322bfd1939 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 11 Feb 2026 23:53:37 -0800 Subject: [PATCH] feat: add project notes and todos panel with persistence (#393) --- .../session/ProjectNotesTodoPanel.tsx | 360 ++++++++++++++++++ .../src/components/session/SessionSidebar.tsx | 88 ++++- packages/ui/src/lib/openchamberConfig.ts | 107 ++++++ 3 files changed, 549 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/components/session/ProjectNotesTodoPanel.tsx diff --git a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx new file mode 100644 index 00000000..41d95620 --- /dev/null +++ b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx @@ -0,0 +1,360 @@ +import React from 'react'; +import { RiAddLine, RiDeleteBinLine, RiSendPlaneLine } from '@remixicon/react'; +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 { + getProjectNotesAndTodos, + OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, + OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH, + saveProjectNotesAndTodos, + type OpenChamberProjectTodoItem, + type ProjectRef, +} from '@/lib/openchamberConfig'; +import { useUIStore } from '@/stores/useUIStore'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { createWorktreeOnly } from '@/lib/worktreeSessionCreator'; +import { cn } from '@/lib/utils'; + +interface ProjectNotesTodoPanelProps { + projectRef: ProjectRef | null; + canCreateWorktree?: boolean; + onActionComplete?: () => void; + className?: string; +} + +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)}`; +}; + +export const ProjectNotesTodoPanel: React.FC = ({ + projectRef, + canCreateWorktree = false, + onActionComplete, + className, +}) => { + 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 currentSessionId = useSessionStore((state) => state.currentSessionId); + const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); + const setPendingInputText = useSessionStore((state) => state.setPendingInputText); + const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); + const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); + + const persistProjectData = React.useCallback( + async (nextNotes: string, nextTodos: OpenChamberProjectTodoItem[]) => { + if (!projectRef) { + return false; + } + const saved = await saveProjectNotesAndTodos(projectRef, { + notes: nextNotes, + todos: nextTodos, + }); + if (!saved) { + toast.error('Failed to save project notes'); + } + return saved; + }, + [projectRef] + ); + + React.useEffect(() => { + if (!projectRef) { + setNotes(''); + setTodos([]); + setNewTodoText(''); + return; + } + + let cancelled = false; + setIsLoading(true); + + (async () => { + try { + const data = await getProjectNotesAndTodos(projectRef); + if (cancelled) { + return; + } + setNotes(data.notes); + setTodos(data.todos); + setNewTodoText(''); + } catch { + if (!cancelled) { + toast.error('Failed to load project notes'); + setNotes(''); + setTodos([]); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [projectRef]); + + const handleNotesBlur = React.useCallback(() => { + void persistProjectData(notes, todos); + }, [notes, persistProjectData, todos]); + + const handleAddTodo = React.useCallback(() => { + const trimmed = newTodoText.trim(); + if (!trimmed) { + return; + } + + const nextTodos = [ + ...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 handleToggleTodo = React.useCallback( + (id: string, completed: boolean) => { + const nextTodos = todos.map((todo) => (todo.id === id ? { ...todo, completed } : todo)); + 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 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( + (todoText: string) => { + if (!projectRef) { + return; + } + routeToChat(); + openNewSessionDraft({ + directoryOverride: projectRef.path, + initialPrompt: todoText, + }); + toast.success('Todo sent to new session'); + onActionComplete?.(); + }, + [onActionComplete, openNewSessionDraft, projectRef, routeToChat] + ); + + const handleSendToCurrentSession = React.useCallback( + (todoText: string) => { + if (!currentSessionId) { + toast.error('No active session selected'); + return; + } + routeToChat(); + setPendingInputText(todoText, 'append'); + toast.success('Todo sent to current session'); + onActionComplete?.(); + }, + [currentSessionId, onActionComplete, routeToChat, setPendingInputText] + ); + + const handleSendToNewWorktreeSession = React.useCallback( + async (todoId: string, todoText: string) => { + if (!projectRef) { + return; + } + if (!canCreateWorktree) { + toast.error('Worktree actions are only available for Git repositories'); + return; + } + setSendingTodoId(todoId); + try { + const newWorktreePath = await createWorktreeOnly(); + if (!newWorktreePath) { + return; + } + routeToChat(); + openNewSessionDraft({ + directoryOverride: newWorktreePath, + initialPrompt: todoText, + }); + toast.success('Todo sent to new worktree session'); + onActionComplete?.(); + } finally { + setSendingTodoId(null); + } + }, + [canCreateWorktree, onActionComplete, openNewSessionDraft, projectRef, routeToChat] + ); + + if (!projectRef) { + return ( +
+

Select a project to add notes and todos.

+
+ ); + } + + return ( +
+
+
+

Quick notes

+ {notes.length}/{OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH} +
+