fix(chat): clamp text selection menu Y position to the viewport

Selecting text that crosses the top scroll edge could push the
floating selection menu above the viewport because only the X
position was clamped, not Y. Add getDesktopClampedY alongside the
existing X clamp in selectionMenuPosition.ts, measure the menu's
height the same way its width is measured, and re-clamp Y in
showMenu, the layout effect, and the resize handler.

Ported by hand: TextSelectionMenu.tsx changed significantly on main
(comment mode, glass surfaces, header drag-zone push-down) since this
fix was written, so main's file was kept and the fix re-applied on
top of it.

Closes #2257
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 23:46:12 +03:00
1264 changed files with 107942 additions and 30321 deletions
@@ -1,28 +1,31 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
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 { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
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';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat';
import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects';
import {
DESKTOP_MENU_FALLBACK_HEIGHT_PX,
DESKTOP_MENU_FALLBACK_WIDTH_PX,
getDesktopClampedX,
getDesktopClampedY,
} from './selectionMenuPosition';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
interface TextSelectionMenuProps {
containerRef: React.RefObject<HTMLElement | null>;
@@ -38,23 +41,67 @@ interface SelectionPayload {
plainText: string;
markdownText: string;
rect: DOMRect;
messageId: string | null;
range: Range;
}
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 normalizeDistilledInsight = (insight: string): string => (
insight.trim().replace(/^[-*+]\s+/, '').slice(0, PROJECT_NOTE_BODY_MAX_LENGTH)
);
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 [selectedMessageId, setSelectedMessageId] = React.useState<string | null>(null);
const [commentMode, setCommentMode] = React.useState(false);
const commentModeRef = React.useRef(false);
const [commentText, setCommentText] = React.useState('');
const commentInputRef = React.useRef<HTMLTextAreaElement>(null);
// While the comment input owns focus the native selection is gone, so the
// quoted fragment is repainted with our own overlay rectangles. Raw
// Range.getClientRects() mixes block-container boxes with text boxes and
// the translucent overlaps paint double-dark bands, so the rects are taken
// from the text nodes only and merged into one strip per visual line.
const [commentRects, setCommentRects] = React.useState<DOMRect[] | null>(null);
const updateCommentRects = React.useCallback(() => {
const range = pendingSelectionRef.current?.range;
if (!range) {
setCommentRects(null);
return;
}
setCommentRects(collectSelectionOverlayRects(range));
}, []);
React.useEffect(() => {
if (!commentMode) return;
let frame: number | null = null;
const scheduleUpdate = () => {
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
updateCommentRects();
});
};
document.addEventListener('scroll', scheduleUpdate, { capture: true, passive: true });
window.addEventListener('resize', scheduleUpdate);
return () => {
if (frame !== null) window.cancelAnimationFrame(frame);
document.removeEventListener('scroll', scheduleUpdate, { capture: true });
window.removeEventListener('resize', scheduleUpdate);
};
}, [commentMode, updateCommentRects]);
// Grow the comment box with its content, up to five lines.
const resizeCommentInput = React.useCallback(() => {
const element = commentInputRef.current;
if (!element) return;
element.style.height = 'auto';
element.style.height = `${Math.min(element.scrollHeight, 120)}px`;
}, []);
const isDraggingRef = React.useRef(false);
const [isOpening, setIsOpening] = React.useState(false);
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
@@ -65,8 +112,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
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 activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const isMobile = useUIStore((state) => state.isMobile);
const projects = useProjectsStore((state) => state.projects);
@@ -74,12 +123,47 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const effectiveDirectory = useEffectiveDirectory();
const sessions = useSessions();
// Mobile: the comment bar is rendered inside the composer form (its
// positioning context), so it inherits the runtime's own keyboard handling
// — browser viewport resizing and Capacitor choreography alike. This effect
// only centers it on the composer pill in the form's local coordinates; no
// viewport math, which Safari's keyboard handling reliably breaks for
// fixed elements.
React.useEffect(() => {
if (!commentMode || !isMobile) return;
const update = () => {
const element = menuRef.current;
const host = element?.offsetParent;
if (!element || !host) return;
const pill = document.querySelector('[data-mobile-composer-pill="true"]')
?? document.querySelector('[data-chat-input="true"]');
const pillRect = pill?.getBoundingClientRect();
if (!pillRect || pillRect.height <= 0) return;
const hostRect = host.getBoundingClientRect();
element.style.top = `${pillRect.top - hostRect.top + (pillRect.height - element.offsetHeight) / 2}px`;
element.style.left = `${pillRect.left - hostRect.left}px`;
element.style.width = `${pillRect.width}px`;
element.style.bottom = 'auto';
};
update();
const raf = window.requestAnimationFrame(update);
// The composer relayouts with its own transitions and timeouts that emit
// no event; a light poll keeps the overlay glued to the pill.
const poll = window.setInterval(update, 200);
return () => {
window.cancelAnimationFrame(raf);
window.clearInterval(poll);
};
}, [commentMode, isMobile]);
React.useEffect(() => {
isMenuVisibleRef.current = position.show;
}, [position.show]);
React.useEffect(() => {
return () => {
activeAddToChatCleanupRef.current?.();
activeAddToChatCleanupRef.current = null;
if (openRafRef.current !== null) {
window.cancelAnimationFrame(openRafRef.current);
openRafRef.current = null;
@@ -93,6 +177,9 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const hideMenu = React.useCallback(() => {
pendingSelectionRef.current = null;
activeAddToChatCleanupRef.current?.();
activeAddToChatCleanupRef.current = null;
setCommentRects(null);
if (!isMenuVisibleRef.current) {
return;
@@ -107,41 +194,56 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
setPosition((prev) => ({ ...prev, show: false }));
setSelectedText('');
setSelectedTextMarkdown('');
setSelectedMessageId(null);
setCommentMode(false);
commentModeRef.current = false;
setCommentText('');
isMenuVisibleRef.current = false;
}, []);
const clampDesktopX = React.useCallback((anchorX: number) => {
if (typeof window === 'undefined') {
return anchorX;
}
const getClampedX = React.useCallback((anchorX: number) => (
getDesktopClampedX(anchorX, window.innerWidth, menuWidthRef.current)
), []);
return getDesktopClampedX(anchorX, window.innerWidth, menuWidthRef.current);
}, []);
const getClampedY = React.useCallback((anchorY: number) => (
getDesktopClampedY(anchorY, window.innerHeight, menuHeightRef.current)
), []);
const clampDesktopY = React.useCallback((anchorY: number) => {
if (typeof window === 'undefined') {
return anchorY;
}
const addMarkdownToChat = React.useCallback((markdownText: string) => {
const markdownBlock = wrapMarkdownSelectionForChat(markdownText);
setPendingInputText(markdownBlock, 'append');
return getDesktopClampedY(anchorY, window.innerHeight, menuHeightRef.current);
}, []);
hideMenu();
window.getSelection()?.removeAllRanges();
queueMicrotask(() => {
focusChatInput();
});
}, [hideMenu, setPendingInputText]);
const showMenu = React.useCallback(() => {
if (!pendingSelectionRef.current) return;
const { plainText, markdownText, rect } = pendingSelectionRef.current;
const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current;
const shouldAnimateIn = !position.show;
activeAddToChatCleanupRef.current?.();
activeAddToChatCleanupRef.current = registerActiveSelectionToolbar({
addToChat: () => addMarkdownToChat(markdownText),
dismiss: hideMenu,
});
// Position menu above the selection
const menuX = isMobile
? rect.left + rect.width / 2
: clampDesktopX(rect.left + rect.width / 2);
: getClampedX(rect.left + rect.width / 2);
const menuY = isMobile
? rect.top - 10
: clampDesktopY(rect.top - 10);
: getClampedY(rect.top - 10);
setSelectedText(plainText);
setSelectedTextMarkdown(markdownText);
setSelectedMessageId(messageId);
setPosition({
x: menuX,
y: menuY,
@@ -159,7 +261,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
openRafRef.current = null;
});
}
}, [clampDesktopX, clampDesktopY, isMobile, position.show]);
}, [addMarkdownToChat, getClampedX, getClampedY, hideMenu, isMobile, position.show]);
React.useLayoutEffect(() => {
if (!position.show || isMobile || !menuRef.current) {
@@ -182,10 +284,29 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
}
setPosition((prev) => ({
...prev,
x: clampDesktopX(prev.x),
y: clampDesktopY(prev.y),
x: getClampedX(prev.x),
y: getClampedY(prev.y),
}));
}, [clampDesktopX, clampDesktopY, isMobile, position.show]);
}, [getClampedX, getClampedY, isMobile, position.show]);
// The desktop popup hangs above its anchor, so a tall comment box near the
// top of the chat can climb over the app header. On the desktop shell the
// header is a window drag zone, which makes the overlapped part of the
// textarea untouchable, so the popup is pushed down until its top edge stays
// inside the chat container.
React.useLayoutEffect(() => {
if (!position.show || isMobile || !menuRef.current) {
return;
}
const container = containerRef.current;
const minTop = (container ? container.getBoundingClientRect().top : 0) + 4;
const menuTop = menuRef.current.getBoundingClientRect().top;
if (menuTop < minTop) {
const delta = minTop - menuTop;
setPosition((prev) => ({ ...prev, y: prev.y + delta }));
}
}, [containerRef, isMobile, position.show, position.y, commentMode, commentText]);
React.useEffect(() => {
if (!position.show || isMobile) {
@@ -195,8 +316,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const handleViewportResize = () => {
setPosition((prev) => ({
...prev,
x: clampDesktopX(prev.x),
y: clampDesktopY(prev.y),
x: getClampedX(prev.x),
y: getClampedY(prev.y),
}));
};
@@ -204,9 +325,14 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
return () => {
window.removeEventListener('resize', handleViewportResize);
};
}, [clampDesktopX, clampDesktopY, isMobile, position.show]);
}, [getClampedX, getClampedY, isMobile, position.show]);
const handleSelectionChange = React.useCallback(() => {
// While the comment input is open, clicking or typing in it collapses the
// text selection; the captured quote must survive that.
if (commentModeRef.current) {
return;
}
const selection = window.getSelection();
const container = containerRef.current;
@@ -241,10 +367,15 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const rect = range.getBoundingClientRect();
// Store the selection but don't show menu yet if dragging
const anchorElement = range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
pendingSelectionRef.current = {
plainText: text,
markdownText: rangeToMarkdown(range, text),
rect,
messageId: anchorElement?.closest('[data-message-id]')?.getAttribute('data-message-id') ?? null,
range: range.cloneRange(),
};
// Only show menu if we're not currently dragging
@@ -258,7 +389,12 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
if (!container) return;
// Track when dragging starts
const handleMouseDown = () => {
const handleMouseDown = (event: MouseEvent) => {
// SAFETY: a MouseEvent target inside the document is always a Node;
// `contains` only needs that.
if (commentModeRef.current && menuRef.current?.contains(event.target as Node)) {
return;
}
isDraggingRef.current = true;
hideMenu();
};
@@ -274,6 +410,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
// Small delay to ensure selection is finalized
mouseUpTimeoutRef.current = window.setTimeout(() => {
mouseUpTimeoutRef.current = null;
// The click that opened the comment input cleared the selection on
// purpose; the input must survive this deferred check.
if (commentModeRef.current) {
return;
}
const selection = window.getSelection();
if (selection && selection.toString().trim()) {
showMenu();
@@ -295,7 +436,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
if (
menuRef.current &&
!menuRef.current.contains(e.target as Node) &&
!window.getSelection()?.toString().trim()
(commentModeRef.current || !window.getSelection()?.toString().trim())
) {
hideMenu();
}
@@ -317,42 +458,40 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const handleAddToChat = React.useCallback(() => {
if (!selectedTextMarkdown) return;
addMarkdownToChat(selectedTextMarkdown);
}, [addMarkdownToChat, selectedTextMarkdown]);
const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown);
setPendingInputText(markdownBlock, 'append');
hideMenu();
// Clear selection
const handleOpenComment = React.useCallback(() => {
if (!selectedTextMarkdown) return;
setCommentMode(true);
commentModeRef.current = true;
updateCommentRects();
window.getSelection()?.removeAllRanges();
queueMicrotask(() => {
commentInputRef.current?.focus();
});
}, [selectedTextMarkdown, updateCommentRects]);
const handleAttachComment = React.useCallback(() => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!selectedTextMarkdown || !sessionKey || !effectiveDirectory) {
hideMenu();
return;
}
addContextDraft({ directory: effectiveDirectory, sessionKey }, {
source: 'chat-quote',
fileLabel: selectedMessageId ?? '',
startLine: 1,
endLine: 1,
code: selectedTextMarkdown,
language: '',
text: commentText.trim(),
});
hideMenu();
queueMicrotask(() => {
focusChatInput();
});
}, [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]);
}, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]);
const currentSession = React.useMemo(() => {
if (!currentSessionId) {
@@ -381,19 +520,22 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
// 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,
const insight = normalizeDistilledInsight(noteText);
if (!insight) {
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
return;
}
// Recorded as its own note with provenance, so the distilled insight can
// later be traced back to the conversation it came from.
const saved = await useProjectContextStore.getState().createNote(currentProjectRef, {
body: insight,
source: 'selection',
...(currentSessionId ? { origin: { sessionId: currentSessionId } } : {}),
});
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();
@@ -407,15 +549,110 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
if (!position.show) return null;
const commentHighlightOverlay = commentMode && commentRects && commentRects.length > 0
? createPortal(
<div className="pointer-events-none fixed inset-0 z-[5]">
{commentRects.map((rect, index) => (
<div
key={index}
className="oc-chat-comment-rect absolute"
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
/>
))}
</div>,
document.body,
)
: null;
const commentInput = (
<div
className={cn(
'oc-glass-popover flex items-end gap-2 rounded-3xl border border-[var(--interactive-border)]',
'pl-4 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
'py-1 pr-1',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
>
<textarea
ref={commentInputRef}
rows={1}
value={commentText}
onChange={(event) => {
setCommentText(event.target.value);
resizeCommentInput();
}}
onKeyDown={(event) => {
// Desktop: Enter attaches, Shift+Enter breaks the line. Mobile
// keyboards use Enter for line breaks; attaching is the button's job.
if (event.key === 'Enter' && !event.shiftKey && !isMobile) {
event.preventDefault();
handleAttachComment();
} else if (event.key === 'Escape') {
event.preventDefault();
hideMenu();
}
}}
placeholder={t('chat.textSelection.comment.placeholder')}
className={cn(
'flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60',
// The width cap sizes the floating desktop pill; on mobile the pill
// spans the bottom bar and the cap would strand slack space to the
// right of the attach button.
isMobile ? 'w-full min-w-0 py-1.5 text-base leading-6' : 'w-64 max-w-[70vw] py-1.5'
)}
style={{ minHeight: 0, height: 'auto' }}
/>
<button
type="button"
onClick={handleAttachComment}
className={cn(
'mb-0.5 flex shrink-0 items-center justify-center rounded-full bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity duration-150',
isMobile ? 'h-9 w-9' : 'h-8 w-8'
)}
aria-label={t('chat.textSelection.comment.attach')}
title={t('chat.textSelection.comment.attach')}
>
<Icon name="attachment-2" className="h-4 w-4" />
</button>
</div>
);
// Mobile: Show as a bar at the bottom of the screen, above the keyboard
if (isMobile) {
if (commentMode) {
// Overlay the comment input onto the composer pill: rendering into the
// composer form (position: relative) inherits the runtime's keyboard
// handling in both browser and Capacitor; the centering effect above
// glues it to the pill in the form's local coordinates.
const composerHost = document.querySelector('form.oc-mobile-composer');
const bar = (
<div
ref={menuRef}
className={cn(
'z-50',
composerHost
? 'absolute inset-x-0 bottom-[var(--oc-safe-area-bottom-visual,0.5rem)]'
: 'oc-chat-comment-bar fixed left-3 right-3 mx-auto max-w-[420px]',
)}
>
{commentInput}
</div>
);
return (
<>
{commentHighlightOverlay}
{createPortal(bar, composerHost ?? document.body)}
</>
);
}
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',
'oc-glass-popover rounded-2xl border border-[var(--interactive-border)]',
'p-2 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
'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'
@@ -425,6 +662,22 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
}}
>
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleOpenComment}
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.title.commentOnSelection')}
type="button"
>
<Icon name="chat-1" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.comment')}</span>
</button>
<button
onClick={handleAddToChat}
className={cn(
@@ -438,39 +691,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
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>
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToInput')}</span>
</button>
{!isVSCodeRuntime() ? (
@@ -501,80 +722,64 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
return createPortal(
<div
ref={menuRef}
className="fixed z-50"
className="app-region-no-drag 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}
{commentMode ? (<>{commentHighlightOverlay}{commentInput}</>) : (
<div
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'
'flex items-center whitespace-nowrap',
'oc-glass-popover rounded-full border border-[var(--interactive-border)]',
'shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
'p-1',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
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>
<button
onClick={handleOpenComment}
className={cn(
'px-3.5 py-1.5 rounded-full',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.commentOnSelection')}
type="button"
>
{t('chat.textSelection.actions.comment')}
</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>
{!isVSCodeRuntime() ? (
<>
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
<button
onClick={handleAddToNotes}
disabled={isAddingToNotes}
className={cn(
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-full',
'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" /> : null}
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
</button>
</>
) : null}
</div>
)}
</div>,
document.body
);