Files
openchamber/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
T
Bohdan Triapitsyn f40110698f fix: preserve markdown when saving notes
Change the chat text-selection Add to notes action to use the existing markdown selection payload, matching Add to chat formatting behavior while still falling back to plain text when markdown extraction is unavailable.

Stop distilling or summarizing selected text before saving it to project notes. The action now appends the selected content directly so code, lists, emphasis, and other markdown survive.

Increase the project notes text limit from 1000 to 3000 characters to account for saving raw selected text instead of a short generated summary.

Update user-facing copy across locales from distilled insight language to selected text language.
2026-05-19 02:07:07 +03:00

722 lines
24 KiB
TypeScript

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 { useProjectsStore } from '@/stores/useProjectsStore';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
interface TextSelectionMenuProps {
containerRef: React.RefObject<HTMLElement | null>;
}
interface MenuPosition {
x: number;
y: number;
show: boolean;
}
interface SelectionPayload {
plainText: string;
markdownText: string;
rect: DOMRect;
}
const appendDistilledInsightToNotes = (existingNotes: string, insight: string): string => {
const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, '').slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH);
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([
'address', 'article', 'aside', 'blockquote', 'dd', 'div', 'dl', 'dt',
'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre',
'section', 'table', 'ul',
]);
const normalizeLineBreaks = (value: string): string => value.replace(/\r\n?/g, '\n');
const trimSelectionValue = (value: string): string => normalizeLineBreaks(value).trim();
const textToMarkdownInline = (value: string): string => value.replace(/\s+/g, ' ').trim();
const renderInlineMarkdownNode = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) {
return textToMarkdownInline(node.textContent || '');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
const element = node as HTMLElement;
const tag = element.tagName.toLowerCase();
const childText = Array.from(element.childNodes)
.map((child) => renderInlineMarkdownNode(child))
.join('')
.replace(/\s+/g, ' ')
.trim();
if (!childText && tag !== 'br') {
return '';
}
if (tag === 'br') return '\n';
if (tag === 'strong' || tag === 'b') return `**${childText}**`;
if (tag === 'em' || tag === 'i') return `*${childText}*`;
if (tag === 'code') return `\`${childText.replace(/`/g, '\\`')}\``;
if (tag === 'a') {
const href = element.getAttribute('href');
return href ? `[${childText}](${href})` : childText;
}
return childText;
};
const renderListMarkdown = (list: HTMLElement, ordered: boolean): string => {
const items = Array.from(list.children).filter(
(child): child is HTMLElement => child instanceof HTMLElement && child.tagName.toLowerCase() === 'li'
);
return items
.map((item, index) => {
const prefix = ordered ? `${index + 1}. ` : '- ';
const body = Array.from(item.childNodes)
.map((child) => renderInlineMarkdownNode(child))
.join('')
.replace(/\s+/g, ' ')
.trim();
return body ? `${prefix}${body}` : '';
})
.filter(Boolean)
.join('\n');
};
const renderBlockMarkdownNode = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) {
return trimSelectionValue(node.textContent || '');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
const element = node as HTMLElement;
const tag = element.tagName.toLowerCase();
if (tag === 'pre') {
const codeElement = element.querySelector('code');
const languageClass = codeElement?.className || '';
const language = (languageClass.match(/language-([\w-]+)/)?.[1] || '').trim();
const code = normalizeLineBreaks(codeElement?.textContent || element.textContent || '').replace(/\n$/, '');
return `\`\`\`${language}\n${code}\n\`\`\``;
}
if (tag === 'code') {
const code = normalizeLineBreaks(element.textContent || '').trim();
return code ? `\`${code.replace(/`/g, '\\`')}\`` : '';
}
if (tag === 'ul') return renderListMarkdown(element, false);
if (tag === 'ol') return renderListMarkdown(element, true);
if (tag === 'blockquote') {
const content = trimSelectionValue(
Array.from(element.childNodes).map((child) => renderBlockMarkdownNode(child)).join('\n')
);
return content
.split('\n')
.filter((line) => line.length > 0)
.map((line) => `> ${line}`)
.join('\n');
}
if (/^h[1-6]$/.test(tag)) {
const level = Number.parseInt(tag[1], 10);
const text = trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
return text ? `${'#'.repeat(level)} ${text}` : '';
}
if (tag === 'p' || tag === 'div' || tag === 'li') {
return trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
}
const blockChildren = Array.from(element.childNodes)
.map((child) => renderBlockMarkdownNode(child))
.filter((child) => child.length > 0);
if (blockChildren.length > 0) {
return blockChildren.join('\n\n');
}
return trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
};
const isInlineSelectionFragment = (fragment: DocumentFragment): boolean => {
return Array.from(fragment.childNodes).every((node) => {
if (node.nodeType === Node.TEXT_NODE) {
return true;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return true;
}
const element = node as HTMLElement;
return !BLOCK_TAGS.has(element.tagName.toLowerCase());
});
};
const rangeToMarkdown = (range: Range, plainText: string): string => {
const fragment = range.cloneContents();
if (isInlineSelectionFragment(fragment)) {
const inlineMarkdown = trimSelectionValue(
Array.from(fragment.childNodes)
.map((node) => renderInlineMarkdownNode(node))
.join('')
);
if (inlineMarkdown) {
return inlineMarkdown;
}
}
const markdown = Array.from(fragment.childNodes)
.map((node) => renderBlockMarkdownNode(node))
.filter((value) => value.length > 0)
.join('\n\n')
.trim();
return markdown || trimSelectionValue(plainText);
};
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
const { t } = useI18n();
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
const [selectedText, setSelectedText] = React.useState('');
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
const isDraggingRef = React.useRef(false);
const [isOpening, setIsOpening] = React.useState(false);
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX);
const pendingSelectionRef = React.useRef<SelectionPayload | null>(null);
const openRafRef = React.useRef<number | null>(null);
const mouseUpTimeoutRef = React.useRef<number | null>(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 availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const effectiveDirectory = useEffectiveDirectory();
const sessions = useSessions();
React.useEffect(() => {
isMenuVisibleRef.current = position.show;
}, [position.show]);
React.useEffect(() => {
return () => {
if (openRafRef.current !== null) {
window.cancelAnimationFrame(openRafRef.current);
openRafRef.current = null;
}
if (mouseUpTimeoutRef.current !== null) {
window.clearTimeout(mouseUpTimeoutRef.current);
mouseUpTimeoutRef.current = null;
}
};
}, []);
const hideMenu = React.useCallback(() => {
pendingSelectionRef.current = null;
if (!isMenuVisibleRef.current) {
return;
}
if (openRafRef.current !== null) {
window.cancelAnimationFrame(openRafRef.current);
openRafRef.current = null;
}
setIsOpening(false);
setPosition((prev) => ({ ...prev, show: false }));
setSelectedText('');
setSelectedTextMarkdown('');
isMenuVisibleRef.current = false;
}, []);
const getDesktopClampedX = React.useCallback((anchorX: number) => {
if (typeof window === 'undefined') {
return anchorX;
}
const viewportWidth = window.innerWidth;
const menuWidth = menuWidthRef.current;
const halfWidth = menuWidth / 2;
const minX = DESKTOP_MENU_SIDE_MARGIN_PX + halfWidth;
const maxX = viewportWidth - DESKTOP_MENU_SIDE_MARGIN_PX - halfWidth;
if (minX > maxX) {
return viewportWidth / 2;
}
return Math.min(Math.max(anchorX, minX), maxX);
}, []);
const showMenu = React.useCallback(() => {
if (!pendingSelectionRef.current) return;
const { plainText, markdownText, rect } = pendingSelectionRef.current;
const shouldAnimateIn = !position.show;
// Position menu above the selection
const menuX = isMobile
? rect.left + rect.width / 2
: getDesktopClampedX(rect.left + rect.width / 2);
const menuY = rect.top - 10;
setSelectedText(plainText);
setSelectedTextMarkdown(markdownText);
setPosition({
x: menuX,
y: menuY,
show: true,
});
isMenuVisibleRef.current = true;
if (shouldAnimateIn) {
setIsOpening(true);
if (openRafRef.current !== null) {
window.cancelAnimationFrame(openRafRef.current);
}
openRafRef.current = window.requestAnimationFrame(() => {
setIsOpening(false);
openRafRef.current = null;
});
}
}, [getDesktopClampedX, isMobile, position.show]);
React.useLayoutEffect(() => {
if (!position.show || isMobile || !menuRef.current) {
return;
}
const measuredWidth = menuRef.current.offsetWidth;
if (!Number.isFinite(measuredWidth) || measuredWidth <= 0 || measuredWidth === menuWidthRef.current) {
return;
}
menuWidthRef.current = measuredWidth;
setPosition((prev) => ({
...prev,
x: getDesktopClampedX(prev.x),
}));
}, [getDesktopClampedX, isMobile, position.show]);
React.useEffect(() => {
if (!position.show || isMobile) {
return;
}
const handleViewportResize = () => {
setPosition((prev) => ({
...prev,
x: getDesktopClampedX(prev.x),
}));
};
window.addEventListener('resize', handleViewportResize);
return () => {
window.removeEventListener('resize', handleViewportResize);
};
}, [getDesktopClampedX, isMobile, position.show]);
const handleSelectionChange = React.useCallback(() => {
const selection = window.getSelection();
const container = containerRef.current;
if (!selection || !container) {
if (!isDraggingRef.current) {
hideMenu();
}
return;
}
const text = trimSelectionValue(selection.toString());
// Only show if we have text and the selection is within our container
if (!text) {
if (!isDraggingRef.current) {
hideMenu();
}
return;
}
// Check if selection is within the container
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
if (!isDraggingRef.current) {
hideMenu();
}
return;
}
// Get selection coordinates
const rect = range.getBoundingClientRect();
// Store the selection but don't show menu yet if dragging
pendingSelectionRef.current = {
plainText: text,
markdownText: rangeToMarkdown(range, text),
rect,
};
// Only show menu if we're not currently dragging
if (!isDraggingRef.current) {
showMenu();
}
}, [containerRef, hideMenu, showMenu]);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
// Track when dragging starts
const handleMouseDown = () => {
isDraggingRef.current = true;
hideMenu();
};
// Track when dragging stops
const handleMouseUp = () => {
isDraggingRef.current = false;
// Check if we have a pending selection to show
if (pendingSelectionRef.current) {
if (mouseUpTimeoutRef.current !== null) {
window.clearTimeout(mouseUpTimeoutRef.current);
}
// Small delay to ensure selection is finalized
mouseUpTimeoutRef.current = window.setTimeout(() => {
mouseUpTimeoutRef.current = null;
const selection = window.getSelection();
if (selection && selection.toString().trim()) {
showMenu();
} else {
hideMenu();
}
}, 10);
}
};
// Listen for selection changes during drag
document.addEventListener('selectionchange', handleSelectionChange);
container.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mouseup', handleMouseUp);
// Hide menu when clicking outside
const handleClickOutside = (e: MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(e.target as Node) &&
!window.getSelection()?.toString().trim()
) {
hideMenu();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
if (mouseUpTimeoutRef.current !== null) {
window.clearTimeout(mouseUpTimeoutRef.current);
mouseUpTimeoutRef.current = null;
}
document.removeEventListener('selectionchange', handleSelectionChange);
container.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [containerRef, handleSelectionChange, hideMenu, showMenu]);
const handleAddToChat = React.useCallback(() => {
if (!selectedTextMarkdown) return;
const markdownBlock = `\`\`\`md\n${selectedTextMarkdown}\n\`\`\``;
setPendingInputText(markdownBlock, 'append');
hideMenu();
// Clear selection
window.getSelection()?.removeAllRanges();
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
const handleCreateNewSession = React.useCallback(async () => {
if (!selectedText) return;
const session = await createSession(undefined, null, null);
if (session) {
setPendingInputText(selectedText, 'replace');
}
hideMenu();
window.getSelection()?.removeAllRanges();
}, [selectedText, createSession, setPendingInputText, hideMenu]);
const handleCopy = React.useCallback(async () => {
if (!selectedText) return;
const result = await copyTextToClipboard(selectedText);
if (!result.ok) {
console.error('Failed to copy:', result.error);
}
hideMenu();
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 = effectiveDirectory
?? (typeof currentSession?.directory === 'string' ? currentSession.directory : '');
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory);
return resolved ? { id: resolved.id, path: resolved.path } : null;
}, [availableWorktreesByProject, currentSession?.directory, effectiveDirectory, projects]);
const handleAddToNotes = React.useCallback(async () => {
if (!selectedText || !currentProjectRef) {
if (!currentProjectRef) {
toast.error(t('chat.textSelection.toast.noProject'));
}
return;
}
try {
setIsAddingToNotes(true);
const noteText = selectedTextMarkdown || selectedText;
const projectData = await getProjectNotesAndTodos(currentProjectRef);
const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText);
const saved = await saveProjectNotesAndTodos(currentProjectRef, {
notes: nextNotes,
todos: projectData.todos,
});
if (!saved) {
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', {
detail: { projectId: currentProjectRef.id },
}));
toast.success(t('chat.textSelection.toast.addToNotesSuccess'));
hideMenu();
window.getSelection()?.removeAllRanges();
} catch (error) {
const description = error instanceof Error ? error.message : undefined;
toast.error(t('chat.textSelection.toast.addToNotesFailed'), description ? { description } : undefined);
} finally {
setIsAddingToNotes(false);
}
}, [currentProjectRef, hideMenu, selectedText, selectedTextMarkdown, t]);
if (!position.show) return null;
// Mobile: Show as a bar at the bottom of the screen, above the keyboard
if (isMobile) {
return createPortal(
<div
ref={menuRef}
className={cn(
'fixed left-3 right-3 bottom-0 z-50 mx-auto max-w-[420px]',
'rounded-2xl border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] p-2 shadow-lg',
'safe-area-bottom',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
style={{
bottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
}}
>
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleAddToChat}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--primary-base)] text-[var(--primary-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.title.addToCurrentChat')}
type="button"
>
<Icon name="add" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToChat')}</span>
</button>
<button
onClick={handleCreateNewSession}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.title.newSessionWithSelection')}
type="button"
>
<Icon name="chat-new" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.newSession')}</span>
</button>
<button
onClick={handleCopy}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.actions.copy')}
type="button"
>
<Icon name="file-copy" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.copy')}</span>
</button>
{!isVSCodeRuntime() ? (
<button
onClick={handleAddToNotes}
disabled={isAddingToNotes}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
'active:opacity-80 disabled:opacity-60 disabled:cursor-not-allowed',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.title.saveInsightToNotes')}
type="button"
>
{isAddingToNotes ? <Icon name="loader-4" className="h-5 w-5 flex-shrink-0 animate-spin" /> : <Icon name="booklet" className="h-5 w-5 flex-shrink-0" />}
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToNotes')}</span>
</button>
) : null}
</div>
</div>,
document.body
);
}
// Desktop: Show as a popup above the selection
return createPortal(
<div
ref={menuRef}
className="fixed z-50"
style={{
left: position.x,
top: position.y,
transform: 'translate(-50%, -100%)',
}}
>
<div
className={cn(
'flex items-center gap-1 whitespace-nowrap',
'rounded-lg border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] shadow-none',
'px-1.5 py-1',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
>
<button
onClick={handleAddToChat}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.addToCurrentChat')}
type="button"
>
<Icon name="add" className="h-4 w-4" />
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToChat')}</span>
</button>
<div className="w-px h-4 bg-[var(--interactive-border)]" />
<button
onClick={handleCreateNewSession}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.newSessionWithSelection')}
type="button"
>
<Icon name="chat-new" className="h-4 w-4" />
<span className="whitespace-nowrap">{t('chat.textSelection.actions.newSession')}</span>
</button>
{!isVSCodeRuntime() ? (
<>
<div className="w-px h-4 bg-[var(--interactive-border)]" />
<button
onClick={handleAddToNotes}
disabled={isAddingToNotes}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.saveInsightToNotes')}
type="button"
>
{isAddingToNotes ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : <Icon name="booklet" className="h-4 w-4" />}
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
</button>
</>
) : null}
</div>
</div>,
document.body
);
};
export default TextSelectionMenu;