Adds a server-side "small model" capability: direct, cheap LLM calls that reuse the user's existing OpenCode provider logins — the mechanism OpenCode uses internally for titles and summaries but does not expose through the SDK or plugins. Zero new dependencies; plain fetch with per-provider wire formats, credentials never leave the server. Core (packages/web/server/lib/small-model): - Resolution mirrors OpenCode's session scoping: explicit settings override → small_model from the OpenCode config → family scan within the session's provider → the session's own model. The global provider scan only serves callers without a session context, and background callers forbid it entirely (restrictToPreferredProvider), so conversation content never reaches a provider the user didn't pick — explicit choices excepted. - Per-provider auth replicating OpenCode's plugin loaders: GitHub Copilot (device token as bearer, no exchange), ChatGPT plan via the codex Responses API (single-flight OAuth refresh written back to auth.json), Anthropic messages, Google generateContent, generic OpenAI-compatible. - OpenCode's free models (opencode/big-pickle, *-free) are never called directly; unauthenticated providers are skipped by design. - Prompt clamping to the model's catalog context limit; thinking disabled where a wire switch exists (Z.AI/GLM, MiniMax-M3, Gemini Flash); robust content parsing with a clear error when a thinking model spends its whol budget on reasoning. - Settings → Sessions gains a Small Model group: use-default checkbox plus an override picker limited to authenticated providers, persisted with web/desktop/VS Code sanitization parity. Consumers: - Session assist: a server-side watcher on the global SSE hub generates a short recap and one suggested follow-up after a session idles quietly fo a minute, stored on session metadata (openchamber.assist). Freshness is keyed to the last assistant message id, so new activity invalidates the payload everywhere with no extra writes. The chat shows the recap under the last message after five quiet minutes and the suggestion as a dismissible chip above the composer (tap fills the input, never sends). Gated by a new Chat setting (default on) that is a hard generation switch. Language is anchored to the conversation itself, with a script-mismatch guard against model/backend language hallucination. - TTS: a third input mode, summarized — long replies are condensed to spoken prose before playback on any TTS engine. - Git: commit-message and PR generation moved off the active chat session onto the small model fed with real diffs and the commit list (bodies included), with a session-transport fallback for free-model-only setups. - Notes: Add to notes distills long selections into 1-3 dense sentences preserving exact identifiers, with verbatim fallback on failure. Fixes along the way: - The global event watcher now starts unconditionally; it was gated behind the desktop-notify env, leaving the server-side event hub dead in packaged apps. - OpenCode re-emits message.updated for old user messages after idle; the watcher no longer mistakes those for new activity. - Session metadata merges from a fresh read right before the PATCH, so writes made during the generation window (suggestion dismissals, review links) are preserved; the assist runtime stops during graceful shutdown.
723 lines
24 KiB
TypeScript
723 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 { summarizeSelectionForNotes } from '@/lib/smallModel';
|
|
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);
|
|
// 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, 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
|
|
);
|
|
};
|