From 8d43678fea96ae371e8275fe0f2c9325ea64707c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 18 Apr 2026 13:47:16 +0300 Subject: [PATCH] feat(notes): add selected chat text to project notes with AI-distilled insight Extend the text selection popover with an Add to notes action that runs the selection through the shared text summarizer in note mode and appends the distilled insight to the active project's notes. --- .../chat/message/TextSelectionMenu.tsx | 144 +++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) 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