2026-02-04 01:13:49 -08:00
|
|
|
import React from 'react';
|
|
|
|
|
import { createPortal } from 'react-dom';
|
2026-03-31 18:47:00 +03:00
|
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
2026-08-23 23:40:32 +03:00
|
|
|
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
2026-04-18 13:47:16 +03:00
|
|
|
import { useSessions } from '@/sync/sync-context';
|
2026-03-31 18:47:00 +03:00
|
|
|
import { useInputStore } from '@/sync/input-store';
|
2026-02-04 01:13:49 -08:00
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
2026-04-18 13:47:16 +03:00
|
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
2026-02-04 01:13:49 -08:00
|
|
|
import { cn } from '@/lib/utils';
|
2026-04-18 13:47:16 +03:00
|
|
|
import { toast } from '@/components/ui';
|
2026-05-13 13:26:15 +03:00
|
|
|
import { Icon } from "@/components/icon/Icon";
|
2026-08-18 02:59:04 +03:00
|
|
|
import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi';
|
|
|
|
|
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
2026-07-05 23:19:10 +03:00
|
|
|
import { summarizeSelectionForNotes } from '@/lib/smallModel';
|
2026-04-18 16:05:55 +03:00
|
|
|
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
|
|
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
2026-04-18 13:50:09 +03:00
|
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
2026-04-26 14:03:39 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-07-27 18:07:25 +08:00
|
|
|
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
|
2026-08-03 12:04:35 +00:00
|
|
|
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
2026-08-24 01:21:35 +03:00
|
|
|
import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects';
|
2026-02-04 01:13:49 -08:00
|
|
|
|
|
|
|
|
interface TextSelectionMenuProps {
|
|
|
|
|
containerRef: React.RefObject<HTMLElement | null>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MenuPosition {
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
show: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 13:18:54 +02:00
|
|
|
interface SelectionPayload {
|
|
|
|
|
plainText: string;
|
|
|
|
|
markdownText: string;
|
|
|
|
|
rect: DOMRect;
|
2026-08-23 23:40:32 +03:00
|
|
|
messageId: string | null;
|
|
|
|
|
range: Range;
|
2026-03-17 13:18:54 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-18 02:59:04 +03:00
|
|
|
const normalizeDistilledInsight = (insight: string): string => (
|
|
|
|
|
insight.trim().replace(/^[-*+]\s+/, '').slice(0, PROJECT_NOTE_BODY_MAX_LENGTH)
|
|
|
|
|
);
|
2026-04-18 13:47:16 +03:00
|
|
|
|
2026-03-04 01:41:01 +02:00
|
|
|
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
|
|
|
|
|
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
|
2026-02-04 01:13:49 -08:00
|
|
|
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
2026-02-04 01:13:49 -08:00
|
|
|
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
|
|
|
|
const [selectedText, setSelectedText] = React.useState('');
|
2026-03-17 13:18:54 +02:00
|
|
|
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
|
2026-08-23 23:40:32 +03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 01:21:35 +03:00
|
|
|
setCommentRects(collectSelectionOverlayRects(range));
|
2026-08-23 23:40:32 +03:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
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`;
|
|
|
|
|
}, []);
|
2026-04-26 16:24:07 +03:00
|
|
|
const isDraggingRef = React.useRef(false);
|
2026-02-09 03:13:34 +02:00
|
|
|
const [isOpening, setIsOpening] = React.useState(false);
|
2026-04-18 13:47:16 +03:00
|
|
|
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
|
2026-02-04 01:13:49 -08:00
|
|
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
2026-03-04 01:41:01 +02:00
|
|
|
const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX);
|
2026-03-17 13:18:54 +02:00
|
|
|
const pendingSelectionRef = React.useRef<SelectionPayload | null>(null);
|
2026-02-09 03:13:34 +02:00
|
|
|
const openRafRef = React.useRef<number | null>(null);
|
2026-05-08 09:01:13 -04:00
|
|
|
const mouseUpTimeoutRef = React.useRef<number | null>(null);
|
2026-03-04 01:41:01 +02:00
|
|
|
const isMenuVisibleRef = React.useRef(false);
|
2026-03-31 18:47:00 +03:00
|
|
|
const createSession = useSessionUIStore((state) => state.createSession);
|
2026-04-18 13:47:16 +03:00
|
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
2026-08-23 23:40:32 +03:00
|
|
|
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
|
|
|
|
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
2026-03-31 18:47:00 +03:00
|
|
|
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
2026-02-04 01:13:49 -08:00
|
|
|
const isMobile = useUIStore((state) => state.isMobile);
|
2026-04-18 13:47:16 +03:00
|
|
|
const projects = useProjectsStore((state) => state.projects);
|
2026-04-18 16:05:55 +03:00
|
|
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
|
|
|
|
const effectiveDirectory = useEffectiveDirectory();
|
2026-04-18 13:47:16 +03:00
|
|
|
const sessions = useSessions();
|
2026-02-04 01:13:49 -08:00
|
|
|
|
2026-08-23 23:40:32 +03:00
|
|
|
// 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]);
|
|
|
|
|
|
2026-03-04 01:41:01 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
isMenuVisibleRef.current = position.show;
|
|
|
|
|
}, [position.show]);
|
|
|
|
|
|
2026-02-05 03:14:26 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
return () => {
|
2026-02-09 03:13:34 +02:00
|
|
|
if (openRafRef.current !== null) {
|
|
|
|
|
window.cancelAnimationFrame(openRafRef.current);
|
|
|
|
|
openRafRef.current = null;
|
|
|
|
|
}
|
2026-05-08 09:01:13 -04:00
|
|
|
if (mouseUpTimeoutRef.current !== null) {
|
|
|
|
|
window.clearTimeout(mouseUpTimeoutRef.current);
|
|
|
|
|
mouseUpTimeoutRef.current = null;
|
|
|
|
|
}
|
2026-02-05 03:14:26 +02:00
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-04 01:13:49 -08:00
|
|
|
const hideMenu = React.useCallback(() => {
|
2026-03-04 01:41:01 +02:00
|
|
|
pendingSelectionRef.current = null;
|
2026-08-23 23:40:32 +03:00
|
|
|
setCommentRects(null);
|
2026-03-04 01:41:01 +02:00
|
|
|
|
|
|
|
|
if (!isMenuVisibleRef.current) {
|
|
|
|
|
return;
|
2026-02-05 03:14:26 +02:00
|
|
|
}
|
2026-03-04 01:41:01 +02:00
|
|
|
|
2026-02-09 03:13:34 +02:00
|
|
|
if (openRafRef.current !== null) {
|
|
|
|
|
window.cancelAnimationFrame(openRafRef.current);
|
|
|
|
|
openRafRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
setIsOpening(false);
|
2026-02-05 03:14:26 +02:00
|
|
|
|
2026-03-04 01:41:01 +02:00
|
|
|
setPosition((prev) => ({ ...prev, show: false }));
|
|
|
|
|
setSelectedText('');
|
2026-03-17 13:18:54 +02:00
|
|
|
setSelectedTextMarkdown('');
|
2026-08-23 23:40:32 +03:00
|
|
|
setSelectedMessageId(null);
|
|
|
|
|
setCommentMode(false);
|
|
|
|
|
commentModeRef.current = false;
|
|
|
|
|
setCommentText('');
|
2026-03-04 01:41:01 +02:00
|
|
|
isMenuVisibleRef.current = false;
|
2026-02-04 01:13:49 -08:00
|
|
|
}, []);
|
|
|
|
|
|
2026-03-04 01:41:01 +02:00
|
|
|
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;
|
2026-02-04 01:13:49 -08:00
|
|
|
|
2026-03-04 01:41:01 +02:00
|
|
|
if (minX > maxX) {
|
|
|
|
|
return viewportWidth / 2;
|
2026-02-05 03:14:26 +02:00
|
|
|
}
|
2026-03-04 01:41:01 +02:00
|
|
|
|
|
|
|
|
return Math.min(Math.max(anchorX, minX), maxX);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const showMenu = React.useCallback(() => {
|
|
|
|
|
if (!pendingSelectionRef.current) return;
|
2026-02-05 03:14:26 +02:00
|
|
|
|
2026-08-23 23:40:32 +03:00
|
|
|
const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current;
|
2026-02-09 03:13:34 +02:00
|
|
|
const shouldAnimateIn = !position.show;
|
2026-02-04 01:13:49 -08:00
|
|
|
|
|
|
|
|
// Position menu above the selection
|
2026-03-04 01:41:01 +02:00
|
|
|
const menuX = isMobile
|
|
|
|
|
? rect.left + rect.width / 2
|
|
|
|
|
: getDesktopClampedX(rect.left + rect.width / 2);
|
2026-02-04 01:13:49 -08:00
|
|
|
const menuY = rect.top - 10;
|
|
|
|
|
|
2026-03-17 13:18:54 +02:00
|
|
|
setSelectedText(plainText);
|
|
|
|
|
setSelectedTextMarkdown(markdownText);
|
2026-08-23 23:40:32 +03:00
|
|
|
setSelectedMessageId(messageId);
|
2026-02-04 01:13:49 -08:00
|
|
|
setPosition({
|
|
|
|
|
x: menuX,
|
|
|
|
|
y: menuY,
|
|
|
|
|
show: true,
|
|
|
|
|
});
|
2026-03-04 01:41:01 +02:00
|
|
|
isMenuVisibleRef.current = true;
|
2026-02-09 03:13:34 +02:00
|
|
|
|
|
|
|
|
if (shouldAnimateIn) {
|
|
|
|
|
setIsOpening(true);
|
|
|
|
|
if (openRafRef.current !== null) {
|
|
|
|
|
window.cancelAnimationFrame(openRafRef.current);
|
|
|
|
|
}
|
|
|
|
|
openRafRef.current = window.requestAnimationFrame(() => {
|
|
|
|
|
setIsOpening(false);
|
|
|
|
|
openRafRef.current = null;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-04 01:41:01 +02:00
|
|
|
}, [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]);
|
2026-02-04 01:13:49 -08:00
|
|
|
|
|
|
|
|
const handleSelectionChange = React.useCallback(() => {
|
2026-08-23 23:40:32 +03:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-02-04 01:13:49 -08:00
|
|
|
const selection = window.getSelection();
|
|
|
|
|
const container = containerRef.current;
|
|
|
|
|
|
|
|
|
|
if (!selection || !container) {
|
2026-04-26 16:24:07 +03:00
|
|
|
if (!isDraggingRef.current) {
|
2026-02-04 01:13:49 -08:00
|
|
|
hideMenu();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 13:18:54 +02:00
|
|
|
const text = trimSelectionValue(selection.toString());
|
2026-02-04 01:13:49 -08:00
|
|
|
|
|
|
|
|
// Only show if we have text and the selection is within our container
|
|
|
|
|
if (!text) {
|
2026-04-26 16:24:07 +03:00
|
|
|
if (!isDraggingRef.current) {
|
2026-02-04 01:13:49 -08:00
|
|
|
hideMenu();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if selection is within the container
|
|
|
|
|
const range = selection.getRangeAt(0);
|
|
|
|
|
|
|
|
|
|
if (!container.contains(range.commonAncestorContainer)) {
|
2026-04-26 16:24:07 +03:00
|
|
|
if (!isDraggingRef.current) {
|
2026-02-04 01:13:49 -08:00
|
|
|
hideMenu();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get selection coordinates
|
|
|
|
|
const rect = range.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
// Store the selection but don't show menu yet if dragging
|
2026-08-23 23:40:32 +03:00
|
|
|
const anchorElement = range.commonAncestorContainer instanceof Element
|
|
|
|
|
? range.commonAncestorContainer
|
|
|
|
|
: range.commonAncestorContainer.parentElement;
|
2026-03-17 13:18:54 +02:00
|
|
|
pendingSelectionRef.current = {
|
|
|
|
|
plainText: text,
|
|
|
|
|
markdownText: rangeToMarkdown(range, text),
|
|
|
|
|
rect,
|
2026-08-23 23:40:32 +03:00
|
|
|
messageId: anchorElement?.closest('[data-message-id]')?.getAttribute('data-message-id') ?? null,
|
|
|
|
|
range: range.cloneRange(),
|
2026-03-17 13:18:54 +02:00
|
|
|
};
|
2026-02-04 01:13:49 -08:00
|
|
|
|
|
|
|
|
// Only show menu if we're not currently dragging
|
2026-04-26 16:24:07 +03:00
|
|
|
if (!isDraggingRef.current) {
|
2026-02-04 01:13:49 -08:00
|
|
|
showMenu();
|
|
|
|
|
}
|
2026-04-26 16:24:07 +03:00
|
|
|
}, [containerRef, hideMenu, showMenu]);
|
2026-02-04 01:13:49 -08:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const container = containerRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
// Track when dragging starts
|
2026-08-23 23:40:32 +03:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-26 16:24:07 +03:00
|
|
|
isDraggingRef.current = true;
|
2026-02-04 01:13:49 -08:00
|
|
|
hideMenu();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Track when dragging stops
|
|
|
|
|
const handleMouseUp = () => {
|
2026-04-26 16:24:07 +03:00
|
|
|
isDraggingRef.current = false;
|
2026-02-04 01:13:49 -08:00
|
|
|
// Check if we have a pending selection to show
|
|
|
|
|
if (pendingSelectionRef.current) {
|
2026-05-08 09:01:13 -04:00
|
|
|
if (mouseUpTimeoutRef.current !== null) {
|
|
|
|
|
window.clearTimeout(mouseUpTimeoutRef.current);
|
|
|
|
|
}
|
2026-02-04 01:13:49 -08:00
|
|
|
// Small delay to ensure selection is finalized
|
2026-05-08 09:01:13 -04:00
|
|
|
mouseUpTimeoutRef.current = window.setTimeout(() => {
|
|
|
|
|
mouseUpTimeoutRef.current = null;
|
2026-08-23 23:40:32 +03:00
|
|
|
// The click that opened the comment input cleared the selection on
|
|
|
|
|
// purpose; the input must survive this deferred check.
|
|
|
|
|
if (commentModeRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-04 01:13:49 -08:00
|
|
|
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) &&
|
2026-08-23 23:40:32 +03:00
|
|
|
(commentModeRef.current || !window.getSelection()?.toString().trim())
|
2026-02-04 01:13:49 -08:00
|
|
|
) {
|
|
|
|
|
hideMenu();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
|
|
|
|
|
|
|
|
return () => {
|
2026-05-08 09:01:13 -04:00
|
|
|
if (mouseUpTimeoutRef.current !== null) {
|
|
|
|
|
window.clearTimeout(mouseUpTimeoutRef.current);
|
|
|
|
|
mouseUpTimeoutRef.current = null;
|
|
|
|
|
}
|
2026-02-04 01:13:49 -08:00
|
|
|
document.removeEventListener('selectionchange', handleSelectionChange);
|
|
|
|
|
container.removeEventListener('mousedown', handleMouseDown);
|
|
|
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
|
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
|
|
|
};
|
|
|
|
|
}, [containerRef, handleSelectionChange, hideMenu, showMenu]);
|
|
|
|
|
|
|
|
|
|
const handleAddToChat = React.useCallback(() => {
|
2026-03-17 13:18:54 +02:00
|
|
|
if (!selectedTextMarkdown) return;
|
2026-02-05 03:14:26 +02:00
|
|
|
|
2026-07-27 18:07:25 +08:00
|
|
|
const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown);
|
2026-03-17 13:18:54 +02:00
|
|
|
setPendingInputText(markdownBlock, 'append');
|
2026-02-04 01:13:49 -08:00
|
|
|
|
|
|
|
|
hideMenu();
|
|
|
|
|
|
|
|
|
|
// Clear selection
|
|
|
|
|
window.getSelection()?.removeAllRanges();
|
2026-08-03 12:04:35 +00:00
|
|
|
queueMicrotask(() => {
|
|
|
|
|
focusChatInput();
|
|
|
|
|
});
|
2026-03-17 13:18:54 +02:00
|
|
|
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
|
2026-02-04 01:13:49 -08:00
|
|
|
|
2026-08-23 23:40:32 +03:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
}, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]);
|
|
|
|
|
|
2026-02-04 01:13:49 -08:00
|
|
|
const handleCreateNewSession = React.useCallback(async () => {
|
|
|
|
|
if (!selectedText) return;
|
|
|
|
|
|
|
|
|
|
const session = await createSession(undefined, null, null);
|
|
|
|
|
if (session) {
|
2026-02-05 03:14:26 +02:00
|
|
|
setPendingInputText(selectedText, 'replace');
|
2026-02-04 01:13:49 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hideMenu();
|
|
|
|
|
window.getSelection()?.removeAllRanges();
|
|
|
|
|
}, [selectedText, createSession, setPendingInputText, hideMenu]);
|
|
|
|
|
|
2026-04-18 13:47:16 +03:00
|
|
|
const currentSession = React.useMemo(() => {
|
|
|
|
|
if (!currentSessionId) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return sessions.find((session) => session.id === currentSessionId) ?? null;
|
|
|
|
|
}, [currentSessionId, sessions]);
|
|
|
|
|
|
|
|
|
|
const currentProjectRef = React.useMemo(() => {
|
2026-04-18 16:05:55 +03:00
|
|
|
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]);
|
2026-04-18 13:47:16 +03:00
|
|
|
|
|
|
|
|
const handleAddToNotes = React.useCallback(async () => {
|
|
|
|
|
if (!selectedText || !currentProjectRef) {
|
|
|
|
|
if (!currentProjectRef) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('chat.textSelection.toast.noProject'));
|
2026-04-18 13:47:16 +03:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
setIsAddingToNotes(true);
|
2026-07-05 23:19:10 +03:00
|
|
|
// 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);
|
2026-08-18 02:59:04 +03:00
|
|
|
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 } } : {}),
|
2026-04-18 13:47:16 +03:00
|
|
|
});
|
|
|
|
|
if (!saved) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
|
2026-04-18 13:47:16 +03:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-19 02:07:07 +03:00
|
|
|
toast.success(t('chat.textSelection.toast.addToNotesSuccess'));
|
2026-04-18 13:47:16 +03:00
|
|
|
hideMenu();
|
|
|
|
|
window.getSelection()?.removeAllRanges();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const description = error instanceof Error ? error.message : undefined;
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('chat.textSelection.toast.addToNotesFailed'), description ? { description } : undefined);
|
2026-04-18 13:47:16 +03:00
|
|
|
} finally {
|
|
|
|
|
setIsAddingToNotes(false);
|
|
|
|
|
}
|
2026-07-06 00:39:49 +03:00
|
|
|
}, [currentProjectRef, currentSessionId, hideMenu, selectedText, selectedTextMarkdown, t]);
|
2026-04-18 13:47:16 +03:00
|
|
|
|
2026-02-04 01:13:49 -08:00
|
|
|
if (!position.show) return null;
|
|
|
|
|
|
2026-08-23 23:40:32 +03:00
|
|
|
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(
|
|
|
|
|
'flex items-end gap-2 rounded-3xl border border-[var(--interactive-border)]',
|
|
|
|
|
'bg-[var(--surface-elevated)] 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(
|
2026-08-24 01:02:33 +03:00
|
|
|
'flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60',
|
2026-08-23 23:40:32 +03:00
|
|
|
// 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>
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-04 01:13:49 -08:00
|
|
|
// Mobile: Show as a bar at the bottom of the screen, above the keyboard
|
|
|
|
|
if (isMobile) {
|
2026-08-23 23:40:32 +03:00
|
|
|
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)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-04 01:13:49 -08:00
|
|
|
return createPortal(
|
|
|
|
|
<div
|
|
|
|
|
ref={menuRef}
|
|
|
|
|
className={cn(
|
2026-04-26 14:37:34 +03:00
|
|
|
'fixed left-3 right-3 bottom-0 z-50 mx-auto max-w-[420px]',
|
|
|
|
|
'rounded-2xl border border-[var(--interactive-border)]',
|
2026-08-23 23:40:32 +03:00
|
|
|
'bg-[var(--surface-elevated)] p-2 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
|
2026-02-04 01:13:49 -08:00
|
|
|
'safe-area-bottom',
|
2026-02-09 03:13:34 +02:00
|
|
|
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
2026-03-04 01:41:01 +02:00
|
|
|
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
2026-02-04 01:13:49 -08:00
|
|
|
)}
|
|
|
|
|
style={{
|
2026-04-26 14:37:34 +03:00
|
|
|
bottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
|
2026-02-04 01:13:49 -08:00
|
|
|
}}
|
|
|
|
|
>
|
2026-04-26 14:37:34 +03:00
|
|
|
<div className="grid grid-cols-2 gap-2">
|
2026-04-18 13:50:09 +03:00
|
|
|
<button
|
2026-08-23 23:40:32 +03:00
|
|
|
onClick={handleOpenComment}
|
2026-04-18 13:50:09 +03:00
|
|
|
className={cn(
|
2026-04-26 14:37:34 +03:00
|
|
|
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
|
|
|
|
|
'text-sm font-medium leading-tight',
|
2026-08-23 23:40:32 +03:00
|
|
|
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
|
2026-04-26 14:37:34 +03:00
|
|
|
'active:opacity-80',
|
|
|
|
|
'transition-opacity duration-150'
|
|
|
|
|
)}
|
2026-08-23 23:40:32 +03:00
|
|
|
title={t('chat.textSelection.title.commentOnSelection')}
|
2026-04-26 14:37:34 +03:00
|
|
|
type="button"
|
|
|
|
|
>
|
2026-08-23 23:40:32 +03:00
|
|
|
<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>
|
2026-04-26 14:37:34 +03:00
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
2026-08-23 23:40:32 +03:00
|
|
|
onClick={handleAddToChat}
|
2026-04-26 14:37:34 +03:00
|
|
|
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',
|
2026-08-23 23:40:32 +03:00
|
|
|
'bg-[var(--primary-base)] text-[var(--primary-foreground)]',
|
2026-04-26 14:37:34 +03:00
|
|
|
'active:opacity-80',
|
|
|
|
|
'transition-opacity duration-150'
|
|
|
|
|
)}
|
2026-08-23 23:40:32 +03:00
|
|
|
title={t('chat.textSelection.title.addToCurrentChat')}
|
2026-04-26 14:37:34 +03:00
|
|
|
type="button"
|
|
|
|
|
>
|
2026-08-23 23:40:32 +03:00
|
|
|
<Icon name="add" className="h-5 w-5 flex-shrink-0" />
|
|
|
|
|
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToInput')}</span>
|
2026-04-26 14:37:34 +03:00
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
2026-08-23 23:40:32 +03:00
|
|
|
onClick={handleCreateNewSession}
|
2026-04-26 14:37:34 +03:00
|
|
|
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',
|
2026-08-23 23:40:32 +03:00
|
|
|
'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]',
|
2026-04-26 14:37:34 +03:00
|
|
|
'active:opacity-80',
|
2026-04-18 13:50:09 +03:00
|
|
|
'transition-opacity duration-150'
|
|
|
|
|
)}
|
2026-08-23 23:40:32 +03:00
|
|
|
title={t('chat.textSelection.title.newSessionWithSelection')}
|
2026-04-18 13:50:09 +03:00
|
|
|
type="button"
|
|
|
|
|
>
|
2026-08-23 23:40:32 +03:00
|
|
|
<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>
|
2026-04-18 13:50:09 +03:00
|
|
|
</button>
|
2026-04-26 14:37:34 +03:00
|
|
|
|
|
|
|
|
{!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"
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
{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" />}
|
2026-04-26 14:37:34 +03:00
|
|
|
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToNotes')}</span>
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
2026-02-04 01:13:49 -08:00
|
|
|
</div>,
|
|
|
|
|
document.body
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Desktop: Show as a popup above the selection
|
|
|
|
|
return createPortal(
|
|
|
|
|
<div
|
|
|
|
|
ref={menuRef}
|
2026-02-09 03:13:34 +02:00
|
|
|
className="fixed z-50"
|
2026-02-04 01:13:49 -08:00
|
|
|
style={{
|
|
|
|
|
left: position.x,
|
|
|
|
|
top: position.y,
|
|
|
|
|
transform: 'translate(-50%, -100%)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-08-23 23:40:32 +03:00
|
|
|
{commentMode ? (<>{commentHighlightOverlay}{commentInput}</>) : (
|
|
|
|
|
<div
|
2026-02-09 03:13:34 +02:00
|
|
|
className={cn(
|
2026-08-23 23:40:32 +03:00
|
|
|
'flex items-center whitespace-nowrap',
|
|
|
|
|
'rounded-full border border-[var(--interactive-border)]',
|
|
|
|
|
'bg-[var(--surface-elevated)] 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'
|
2026-02-09 03:13:34 +02:00
|
|
|
)}
|
|
|
|
|
>
|
2026-08-23 23:40:32 +03:00
|
|
|
<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>
|
2026-04-18 13:47:16 +03:00
|
|
|
|
2026-08-23 23:40:32 +03:00
|
|
|
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
2026-04-18 13:50:09 +03:00
|
|
|
|
2026-08-23 23:40:32 +03:00
|
|
|
<button
|
|
|
|
|
onClick={handleAddToChat}
|
|
|
|
|
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.addToCurrentChat')}
|
|
|
|
|
type="button"
|
|
|
|
|
>
|
|
|
|
|
{t('chat.textSelection.actions.addToInput')}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleCreateNewSession}
|
|
|
|
|
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.newSessionWithSelection')}
|
|
|
|
|
type="button"
|
|
|
|
|
>
|
|
|
|
|
{t('chat.textSelection.actions.newSession')}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{!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>
|
|
|
|
|
)}
|
2026-02-04 01:13:49 -08:00
|
|
|
</div>,
|
|
|
|
|
document.body
|
|
|
|
|
);
|
|
|
|
|
};
|