diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index c255c5b..c295427 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useCallback, useState, useMemo } from 'react'; import { ChatMessageComponent } from './ChatMessage'; -import { ChatInput } from './ChatInput'; +import { ChatInput, type ComposerInsertRequest } from './ChatInput'; import { TypingIndicator } from './TypingIndicator'; import type { ChatMessage, ConnectionStatus } from '../types'; import { Bot, ArrowDown, Loader2, ChevronsDownUp, ChevronsUpDown, Sparkles, Bookmark, Download } from 'lucide-react'; @@ -81,9 +81,14 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session const [showScrollBtn, setShowScrollBtn] = useState(false); const [newMessageCount, setNewMessageCount] = useState(0); const [replyTo, setReplyTo] = useState<{ preview: string } | null>(null); + const [insertRequest, setInsertRequest] = useState(null); // Clear reply context on session switch - useEffect(() => { setReplyTo(null); }, [sessionKey]); // eslint-disable-line react-hooks/set-state-in-effect + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset transient composer UI when session changes + setReplyTo(null); + setInsertRequest(null); + }, [sessionKey]); const prevMessageCountRef = useRef(messages.length); const checkIfNearBottom = useCallback(() => { @@ -343,7 +348,19 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session )}
- { setReplyTo({ preview }); document.getElementById('chat-input')?.focus(); }} agentAvatarUrl={agentAvatarUrl} isFirstInGroup={isFirstInGroup} isBookmarked={isBookmarked(msg.id)} onToggleBookmark={sessionKey ? () => toggleBookmark(msg.id, sessionKey, (msg.content || '').slice(0, 120), msg.timestamp) : undefined} /> + { setReplyTo({ preview }); document.getElementById('chat-input')?.focus(); }} + onUseSelection={(text) => { + setInsertRequest({ id: `${msg.id}:${Date.now()}`, text }); + document.getElementById('chat-input')?.focus(); + }} + agentAvatarUrl={agentAvatarUrl} + isFirstInGroup={isFirstInGroup} + isBookmarked={isBookmarked(msg.id)} + onToggleBookmark={sessionKey ? () => toggleBookmark(msg.id, sessionKey, (msg.content || '').slice(0, 120), msg.timestamp) : undefined} + />
); @@ -426,7 +443,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session )} - setReplyTo(null)} /> + setReplyTo(null)} insertRequest={insertRequest} /> ); } diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 9a5b1ac..6ada887 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -18,6 +18,11 @@ export interface ReplyContext { preview: string; } +export interface ComposerInsertRequest { + id: string; + text: string; +} + interface Props { onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void; onNewSession?: () => Promise; @@ -27,6 +32,7 @@ interface Props { sessionKey?: string; replyTo?: ReplyContext | null; onCancelReply?: () => void; + insertRequest?: ComposerInsertRequest | null; } const MAX_BASE64_CHARS = 300 * 1024; // ~225KB real, well under 512KB WS limit (JSON overhead + base64 bloat) @@ -91,7 +97,15 @@ function formatSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; } -export function ChatInput({ onSend, onNewSession, onAbort, isGenerating, disabled, sessionKey, replyTo, onCancelReply }: Props) { +function toQuotedContext(text: string): string { + return text + .trim() + .split('\n') + .map(line => `> ${line}`) + .join('\n'); +} + +export function ChatInput({ onSend, onNewSession, onAbort, isGenerating, disabled, sessionKey, replyTo, onCancelReply, insertRequest }: Props) { const t = useT(); const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut(); const [text, setText] = useState(''); @@ -106,6 +120,7 @@ export function ChatInput({ onSend, onNewSession, onAbort, isGenerating, disable // Per-session draft storage const draftsRef = useRef>(new Map()); const prevSessionRef = useRef(sessionKey); + const lastInsertIdRef = useRef(null); // Save draft to previous session and restore draft for new session useEffect(() => { @@ -141,6 +156,30 @@ export function ChatInput({ onSend, onNewSession, onAbort, isGenerating, disable } }, [sessionKey, disabled]); + useEffect(() => { + if (!insertRequest?.id || lastInsertIdRef.current === insertRequest.id) return; + lastInsertIdRef.current = insertRequest.id; + + const quoted = toQuotedContext(insertRequest.text); + if (!quoted) return; + + setText(prev => { + const next = prev.trim() + ? `${prev.replace(/\s*$/, '')}\n\n${quoted}\n\n` + : `${quoted}\n\n`; + + requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.focus(); + const pos = next.length; + textarea.setSelectionRange(pos, pos); + }); + + return next; + }); + }, [insertRequest]); + const addFiles = useCallback(async (fileList: FileList | File[]) => { const newFiles: FileAttachment[] = []; for (const file of Array.from(fileList)) { diff --git a/src/components/ChatMessage.tsx b/src/components/ChatMessage.tsx index ede542b..8445560 100644 --- a/src/components/ChatMessage.tsx +++ b/src/components/ChatMessage.tsx @@ -313,6 +313,12 @@ function RawJsonPanel({ message }: { message: ChatMessageType }) { ); } +interface SelectionActionState { + text: string; + top: number; + left: number; +} + /** Extract plain text from message blocks for clipboard copy */ function getPlainText(message: ChatMessageType): string { if (message.blocks.length > 0) { @@ -353,11 +359,14 @@ function SystemEventMessage({ message }: { message: ChatMessageType }) { ); } -export const ChatMessageComponent = memo(function ChatMessageComponent({ message: rawMessage, onRetry, onReply, agentAvatarUrl, isFirstInGroup = true, isBookmarked = false, onToggleBookmark }: { message: ChatMessageType; onRetry?: (text: string) => void; onReply?: (preview: string) => void; agentAvatarUrl?: string; isFirstInGroup?: boolean; isBookmarked?: boolean; onToggleBookmark?: () => void }) { +export const ChatMessageComponent = memo(function ChatMessageComponent({ message: rawMessage, onRetry, onReply, onUseSelection, agentAvatarUrl, isFirstInGroup = true, isBookmarked = false, onToggleBookmark }: { message: ChatMessageType; onRetry?: (text: string) => void; onReply?: (preview: string) => void; onUseSelection?: (text: string) => void; agentAvatarUrl?: string; isFirstInGroup?: boolean; isBookmarked?: boolean; onToggleBookmark?: () => void }) { useLocale(); // re-render on locale change const { resolvedTheme } = useTheme(); const isLight = resolvedTheme === 'light'; const [showRawJson, setShowRawJson] = useState(false); + const [selectionAction, setSelectionAction] = useState(null); + const bubbleRef = useRef(null); + const selectionButtonRef = useRef(null); // Strip webhook/hook scaffolding and webchat envelope from user messages before rendering const message = useMemo(() => { @@ -402,6 +411,69 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message const isUser = message.role === 'user'; + const clearSelectionAction = useCallback(() => { + setSelectionAction(null); + }, []); + + const updateSelectionAction = useCallback(() => { + if (isUser || message.isStreaming || !onUseSelection) { + setSelectionAction(null); + return; + } + + const selection = window.getSelection(); + const bubble = bubbleRef.current; + if (!selection || !bubble || selection.rangeCount === 0 || selection.isCollapsed) { + setSelectionAction(null); + return; + } + + const text = selection.toString().trim(); + if (!text || text.length > 1500) { + setSelectionAction(null); + return; + } + + const range = selection.getRangeAt(0); + const common = range.commonAncestorContainer; + if (!bubble.contains(common.nodeType === Node.TEXT_NODE ? common.parentNode : common)) { + setSelectionAction(null); + return; + } + + const rect = range.getBoundingClientRect(); + if (!rect.width && !rect.height) { + setSelectionAction(null); + return; + } + + setSelectionAction({ + text, + top: Math.max(12, rect.top - 40), + left: rect.left + (rect.width / 2), + }); + }, [isUser, message.isStreaming, onUseSelection]); + + useEffect(() => { + if (!onUseSelection || isUser) return; + + const handleSelectionChange = () => { + requestAnimationFrame(updateSelectionAction); + }; + const handlePointerDown = (e: MouseEvent) => { + if (selectionButtonRef.current?.contains(e.target as Node)) return; + if (bubbleRef.current?.contains(e.target as Node)) return; + clearSelectionAction(); + }; + + document.addEventListener('selectionchange', handleSelectionChange); + document.addEventListener('mousedown', handlePointerDown); + return () => { + document.removeEventListener('selectionchange', handleSelectionChange); + document.removeEventListener('mousedown', handlePointerDown); + }; + }, [clearSelectionAction, isUser, onUseSelection, updateSelectionAction]); + // System events render as subtle inline notifications if (message.isSystemEvent) { return ; @@ -432,13 +504,18 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message {/* Bubble */}
-
+ }`} + > {/* User-visible text */} {!isUser ? ( @@ -530,6 +607,30 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message setShowRawJson(o => !o)} />
+ {!isUser && selectionAction && onUseSelection && createPortal( + , + document.body + )} {(message.timestamp || wasWebhookMessage || isBookmarked) && (
{isBookmarked && ( diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index b71c48a..6050056 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -105,6 +105,7 @@ const en = { 'message.metadata': 'Message details', 'message.rawJson': 'Raw JSON', 'message.hideRawJson': 'Hide raw JSON', + 'message.askInChat': 'Ask in Chat', // Timestamps 'time.yesterday': 'Yesterday', @@ -295,6 +296,7 @@ const fr: Record = { 'message.metadata': 'Détails du message', 'message.rawJson': 'JSON brut', 'message.hideRawJson': 'Masquer le JSON brut', + 'message.askInChat': 'Demander dans le chat', 'time.yesterday': 'Hier', 'time.today': "Aujourd'hui", @@ -477,6 +479,7 @@ const es: Record = { 'message.metadata': 'Detalles del mensaje', 'message.rawJson': 'JSON sin formato', 'message.hideRawJson': 'Ocultar JSON sin formato', + 'message.askInChat': 'Preguntar en el chat', 'time.yesterday': 'Ayer', 'time.today': 'Hoy', @@ -661,6 +664,7 @@ const de: Record = { 'message.metadata': 'Nachrichtendetails', 'message.rawJson': 'Roh-JSON', 'message.hideRawJson': 'Roh-JSON ausblenden', + 'message.askInChat': 'Im Chat fragen', 'time.yesterday': 'Gestern', 'time.today': 'Heute', @@ -843,6 +847,7 @@ const ja: Record = { 'message.metadata': 'メッセージの詳細', 'message.rawJson': '生JSON', 'message.hideRawJson': '生JSONを非表示', + 'message.askInChat': 'チャットで質問', 'time.yesterday': '昨日', 'time.today': '今日', @@ -1025,6 +1030,7 @@ const pt: Record = { 'message.metadata': 'Detalhes da mensagem', 'message.rawJson': 'JSON bruto', 'message.hideRawJson': 'Ocultar JSON bruto', + 'message.askInChat': 'Perguntar no chat', 'time.yesterday': 'Ontem', 'time.today': 'Hoje', @@ -1207,6 +1213,7 @@ const zh: Record = { 'message.metadata': '消息详情', 'message.rawJson': '原始 JSON', 'message.hideRawJson': '隐藏原始 JSON', + 'message.askInChat': '在聊天中提问', 'time.yesterday': '昨天', 'time.today': '今天', @@ -1389,6 +1396,7 @@ const it: Record = { 'message.metadata': 'Dettagli messaggio', 'message.rawJson': 'JSON grezzo', 'message.hideRawJson': 'Nascondi JSON grezzo', + 'message.askInChat': 'Chiedi in chat', 'time.yesterday': 'Ieri', 'time.today': 'Oggi',