diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index f5be27d..4b71409 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -88,7 +88,10 @@ export const ChatInput = memo(function ChatInput({ const isConnecting = status === "connecting" return ( -
+
event.stopPropagation()} + > {queue && queue.length > 0 && onQueueReorder && diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 7a50e01..87e4af2 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -1,7 +1,16 @@ "use client" -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react" +import { + Copy, Download, FileCode, FileImage, @@ -1221,6 +1230,72 @@ export function ConversationDetailPanel() { })) }, [activeConversationTab]) + const [contextMenuSelectedText, setContextMenuSelectedText] = useState("") + const savedSelectionRangeRef = useRef(null) + const isContextMenuOpenRef = useRef(false) + + const handleContextMenuOpenChange = useCallback((open: boolean) => { + isContextMenuOpenRef.current = open + if (!open) { + savedSelectionRangeRef.current = null + return + } + const selection = window.getSelection() + const text = selection?.toString() ?? "" + setContextMenuSelectedText(text) + savedSelectionRangeRef.current = + selection && selection.rangeCount > 0 && !selection.isCollapsed + ? selection.getRangeAt(0).cloneRange() + : null + }, []) + + const handleContextMenuTriggerPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 2) return + const selection = window.getSelection() + if (selection && !selection.isCollapsed) { + event.preventDefault() + } + }, + [] + ) + + useEffect(() => { + const handler = () => { + if (!isContextMenuOpenRef.current) return + const range = savedSelectionRangeRef.current + if (!range) return + if ( + !document.contains(range.startContainer) || + !document.contains(range.endContainer) + ) { + savedSelectionRangeRef.current = null + return + } + const selection = window.getSelection() + if (!selection) return + if (selection.toString().length > 0) return + selection.removeAllRanges() + selection.addRange(range) + } + document.addEventListener("selectionchange", handler) + return () => document.removeEventListener("selectionchange", handler) + }, []) + + const handleCopySelectedText = useCallback(async () => { + if (!contextMenuSelectedText) return + if (!navigator.clipboard?.writeText) { + toast.error(t("copyTextFailed")) + return + } + try { + await navigator.clipboard.writeText(contextMenuSelectedText) + toast.success(t("copyTextSuccess")) + } catch { + toast.error(t("copyTextFailed")) + } + }, [contextMenuSelectedText, t]) + const handleNewConversation = useCallback(() => { if (!folder) return openNewConversationTab(folder.path) @@ -1309,13 +1384,14 @@ export function ConversationDetailPanel() { } return ( - +
{tabs.map((tab, index) => { const active = tab.id === activeTabId @@ -1352,6 +1428,14 @@ export function ConversationDetailPanel() {
+ + + {t("copyText")} + +