feat: insert selected text into chat editor from previous responses (like chatgpt ui) (#23)
Nice contribution! Select text → Ask in Chat feature. Minor dark-mode and a11y tweaks can be done in follow-up.
This commit is contained in:
@@ -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<ComposerInsertRequest | null>(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
|
||||
</div>
|
||||
)}
|
||||
<div className={`${isActiveMatch ? 'ring-1 ring-pc-accent-light/40 rounded-lg' : ''} ${msg.isArchived ? 'opacity-60' : ''}`}>
|
||||
<ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} onReply={(preview) => { 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} />
|
||||
<ChatMessageComponent
|
||||
message={msg}
|
||||
onRetry={!isGenerating ? handleSend : undefined}
|
||||
onReply={(preview) => { 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -426,7 +443,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChatInput onSend={handleSend} onNewSession={onNewSession} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} sessionKey={sessionKey} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} />
|
||||
<ChatInput onSend={handleSend} onNewSession={onNewSession} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} sessionKey={sessionKey} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} insertRequest={insertRequest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -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<Map<string, string>>(new Map());
|
||||
const prevSessionRef = useRef<string | undefined>(sessionKey);
|
||||
const lastInsertIdRef = useRef<string | null>(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)) {
|
||||
|
||||
@@ -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<SelectionActionState | null>(null);
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
const selectionButtonRef = useRef<HTMLButtonElement>(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 <SystemEventMessage message={rawMessage} />;
|
||||
@@ -432,13 +504,18 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
|
||||
|
||||
{/* Bubble */}
|
||||
<div className={`min-w-0 max-w-[80%] ${isUser ? 'text-right' : ''}`}>
|
||||
<div className={`group relative inline-block text-left rounded-3xl px-4 py-3 text-sm leading-relaxed max-w-full overflow-hidden ${
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
onMouseUp={updateSelectionAction}
|
||||
onKeyUp={updateSelectionAction}
|
||||
className={`group relative inline-block text-left rounded-3xl px-4 py-3 text-sm leading-relaxed max-w-full overflow-hidden ${
|
||||
isUser
|
||||
? (isLight
|
||||
? 'bg-[rgba(var(--pc-accent-rgb),0.12)] text-pc-text border border-[rgba(var(--pc-accent-rgb),0.3)]'
|
||||
: 'bg-[rgba(var(--pc-accent-rgb),0.08)] text-pc-text border border-[rgba(var(--pc-accent-rgb),0.2)]')
|
||||
: 'bg-pc-elevated/40 text-pc-text border border-pc-border shadow-[0_0_0_1px_rgba(255,255,255,0.03)]'
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
{/* User-visible text */}
|
||||
{!isUser ? (
|
||||
<CollapsibleContent content={message.content || ''} isStreaming={message.isStreaming}>
|
||||
@@ -530,6 +607,30 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
|
||||
<RawJsonToggle isOpen={showRawJson} onToggle={() => setShowRawJson(o => !o)} />
|
||||
</div>
|
||||
</div>
|
||||
{!isUser && selectionAction && onUseSelection && createPortal(
|
||||
<button
|
||||
ref={selectionButtonRef}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onUseSelection(selectionAction.text);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
clearSelectionAction();
|
||||
}}
|
||||
className="fixed z-[9999] -translate-x-1/2 inline-flex items-center gap-2 rounded-2xl border border-white/8 bg-[rgba(26,26,29,0.96)] px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_12px_28px_rgba(0,0,0,0.38)] backdrop-blur-xl transition-all hover:bg-[rgba(36,36,40,0.98)]"
|
||||
style={{ top: selectionAction.top, left: selectionAction.left }}
|
||||
aria-label={t('message.askInChat')}
|
||||
title={t('message.askInChat')}
|
||||
>
|
||||
<span className="text-base leading-none text-white/90">❞</span>
|
||||
<span>{t('message.askInChat')}</span>
|
||||
</button>,
|
||||
document.body
|
||||
)}
|
||||
{(message.timestamp || wasWebhookMessage || isBookmarked) && (
|
||||
<div className={`mt-1 flex items-center gap-1.5 text-[11px] text-pc-text-muted ${isUser ? 'justify-end pr-2' : 'pl-2'}`}>
|
||||
{isBookmarked && (
|
||||
|
||||
@@ -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<keyof typeof en, string> = {
|
||||
'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<keyof typeof en, string> = {
|
||||
'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<keyof typeof en, string> = {
|
||||
'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<keyof typeof en, string> = {
|
||||
'message.metadata': 'メッセージの詳細',
|
||||
'message.rawJson': '生JSON',
|
||||
'message.hideRawJson': '生JSONを非表示',
|
||||
'message.askInChat': 'チャットで質問',
|
||||
|
||||
'time.yesterday': '昨日',
|
||||
'time.today': '今日',
|
||||
@@ -1025,6 +1030,7 @@ const pt: Record<keyof typeof en, string> = {
|
||||
'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<keyof typeof en, string> = {
|
||||
'message.metadata': '消息详情',
|
||||
'message.rawJson': '原始 JSON',
|
||||
'message.hideRawJson': '隐藏原始 JSON',
|
||||
'message.askInChat': '在聊天中提问',
|
||||
|
||||
'time.yesterday': '昨天',
|
||||
'time.today': '今天',
|
||||
@@ -1389,6 +1396,7 @@ const it: Record<keyof typeof en, string> = {
|
||||
'message.metadata': 'Dettagli messaggio',
|
||||
'message.rawJson': 'JSON grezzo',
|
||||
'message.hideRawJson': 'Nascondi JSON grezzo',
|
||||
'message.askInChat': 'Chiedi in chat',
|
||||
|
||||
'time.yesterday': 'Ieri',
|
||||
'time.today': 'Oggi',
|
||||
|
||||
Reference in New Issue
Block a user