diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index fec175d6..3e3c9a4d 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -1,11 +1,16 @@ import React from 'react'; import { createPortal } from 'react-dom'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; import { useInputStore } from '@/sync/input-store'; import { useUIStore } from '@/stores/useUIStore'; -import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { RiBookletLine, RiChatNewLine, RiAddLine, RiFileCopyLine, RiLoader4Line } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { copyTextToClipboard } from '@/lib/clipboard'; +import { toast } from '@/components/ui'; +import { getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig'; +import { summarizeText } from '@/lib/voice/summarize'; interface TextSelectionMenuProps { containerRef: React.RefObject; @@ -23,6 +28,52 @@ interface SelectionPayload { rect: DOMRect; } +const normalizeProjectPath = (value: string): string => { + const replaced = value.replace(/\\/g, '/').replace(/\/+$/g, ''); + return replaced || value; +}; + +const resolveProjectRefForDirectory = ( + directory: string, + projects: Array<{ id: string; path: string }>, + activeProjectId: string | null, +): { id: string; path: string } | null => { + const normalized = normalizeProjectPath(directory.trim()); + if (!normalized) { + return null; + } + + const activeProject = activeProjectId + ? projects.find((project) => project.id === activeProjectId) ?? null + : null; + + if (activeProject?.path) { + const activePath = normalizeProjectPath(activeProject.path); + if (normalized === activePath || normalized.startsWith(`${activePath}/`)) { + return { id: activeProject.id, path: activeProject.path }; + } + } + + const match = projects + .filter((project) => { + const projectPath = normalizeProjectPath(project.path); + return normalized === projectPath || normalized.startsWith(`${projectPath}/`); + }) + .sort((left, right) => normalizeProjectPath(right.path).length - normalizeProjectPath(left.path).length)[0]; + + return match ? { id: match.id, path: match.path } : null; +}; + +const appendDistilledInsightToNotes = (existingNotes: string, insight: string): string => { + const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, ''); + if (!trimmedInsight) { + return existingNotes; + } + + const trimmedNotes = existingNotes.trimEnd(); + return trimmedNotes ? `${trimmedNotes}\n${trimmedInsight}` : trimmedInsight; +}; + const DESKTOP_MENU_SIDE_MARGIN_PX = 8; const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280; const BLOCK_TAGS = new Set([ @@ -192,14 +243,19 @@ export const TextSelectionMenu: React.FC = ({ containerR const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState(''); const [isDragging, setIsDragging] = React.useState(false); const [isOpening, setIsOpening] = React.useState(false); + const [isAddingToNotes, setIsAddingToNotes] = React.useState(false); const menuRef = React.useRef(null); const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX); const pendingSelectionRef = React.useRef(null); const openRafRef = React.useRef(null); const isMenuVisibleRef = React.useRef(false); const createSession = useSessionUIStore((state) => state.createSession); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setPendingInputText = useInputStore((state) => state.setPendingInputText); const isMobile = useUIStore((state) => state.isMobile); + const projects = useProjectsStore((state) => state.projects); + const activeProjectId = useProjectsStore((state) => state.activeProjectId); + const sessions = useSessions(); React.useEffect(() => { isMenuVisibleRef.current = position.show; @@ -456,6 +512,57 @@ export const TextSelectionMenu: React.FC = ({ containerR window.getSelection()?.removeAllRanges(); }, [selectedText, hideMenu]); + const currentSession = React.useMemo(() => { + if (!currentSessionId) { + return null; + } + return sessions.find((session) => session.id === currentSessionId) ?? null; + }, [currentSessionId, sessions]); + + const currentProjectRef = React.useMemo(() => { + const directory = typeof currentSession?.directory === 'string' ? currentSession.directory : ''; + return resolveProjectRefForDirectory(directory, projects, activeProjectId); + }, [activeProjectId, currentSession?.directory, projects]); + + const handleAddToNotes = React.useCallback(async () => { + if (!selectedText || !currentProjectRef) { + if (!currentProjectRef) { + toast.error('No project found for this session'); + } + return; + } + + try { + setIsAddingToNotes(true); + const distilledInsight = await summarizeText(selectedText, { + threshold: 0, + maxLength: 100, + mode: 'note', + }); + const projectData = await getProjectNotesAndTodos(currentProjectRef); + const nextNotes = appendDistilledInsightToNotes(projectData.notes, distilledInsight); + const saved = await saveProjectNotesAndTodos(currentProjectRef, { + notes: nextNotes, + todos: projectData.todos, + }); + if (!saved) { + toast.error('Failed to add to notes'); + return; + } + window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', { + detail: { projectId: currentProjectRef.id }, + })); + toast.success('Added distilled insight to notes'); + hideMenu(); + window.getSelection()?.removeAllRanges(); + } catch (error) { + const description = error instanceof Error ? error.message : undefined; + toast.error('Failed to add to notes', description ? { description } : undefined); + } finally { + setIsAddingToNotes(false); + } + }, [currentProjectRef, hideMenu, selectedText]); + if (!position.show) return null; // Mobile: Show as a bar at the bottom of the screen, above the keyboard @@ -520,6 +627,22 @@ export const TextSelectionMenu: React.FC = ({ containerR Copy + + , document.body ); @@ -579,6 +702,25 @@ export const TextSelectionMenu: React.FC = ({ containerR New session + +
+ +
, document.body