Files
openchamber/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
T
ChangeHow 8801d69c66 fix(ui): preserve Markdown code selections (#2449)
* fix(ui): preserve markdown code selections

* test(ui): clarify markdown selection fixtures

* fix(ui): preserve highlighted code languages

* fix(ui): preserve markdown block boundaries
2026-07-27 13:07:25 +03:00

563 lines
20 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 { summarizeSelectionForNotes } from '@/lib/smallModel';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
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;
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 = wrapMarkdownSelectionForChat(selectedTextMarkdown);
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);
// Long selections are distilled into a compact note by the small model;
// short ones (and any generation failure) go in verbatim.
const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId);
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, currentSessionId, 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
);
};