feat: reply-to-message with quote preview in chat input
This commit is contained in:
@@ -78,6 +78,10 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
const userSentRef = useRef(false);
|
||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||
const [newMessageCount, setNewMessageCount] = useState(0);
|
||||
const [replyTo, setReplyTo] = useState<{ preview: string } | null>(null);
|
||||
|
||||
// Clear reply context on session switch
|
||||
useEffect(() => { setReplyTo(null); }, [sessionKey]); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
const prevMessageCountRef = useRef(messages.length);
|
||||
|
||||
const checkIfNearBottom = useCallback(() => {
|
||||
@@ -335,7 +339,7 @@ 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} 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(); }} agentAvatarUrl={agentAvatarUrl} isFirstInGroup={isFirstInGroup} isBookmarked={isBookmarked(msg.id)} onToggleBookmark={sessionKey ? () => toggleBookmark(msg.id, sessionKey, (msg.content || '').slice(0, 120), msg.timestamp) : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -418,7 +422,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChatInput onSend={handleSend} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} sessionKey={sessionKey} />
|
||||
<ChatInput onSend={handleSend} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} sessionKey={sessionKey} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect, useCallback, lazy, Suspense } from 'react';
|
||||
import { Send, Square, Paperclip, X, FileText, Eye, EyeOff } from 'lucide-react';
|
||||
import { Send, Square, Paperclip, X, FileText, Eye, EyeOff, Reply } from 'lucide-react';
|
||||
import { useT } from '../hooks/useLocale';
|
||||
import { useSendShortcut } from '../hooks/useSendShortcut';
|
||||
import { SlashCommandMenu } from './SlashCommands';
|
||||
@@ -18,12 +18,18 @@ interface FileAttachment {
|
||||
preview?: string; // data url thumbnail for images
|
||||
}
|
||||
|
||||
export interface ReplyContext {
|
||||
preview: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
|
||||
onAbort: () => void;
|
||||
isGenerating: boolean;
|
||||
disabled: boolean;
|
||||
sessionKey?: string;
|
||||
replyTo?: ReplyContext | null;
|
||||
onCancelReply?: () => void;
|
||||
}
|
||||
|
||||
const MAX_BASE64_CHARS = 300 * 1024; // ~225KB real, well under 512KB WS limit (JSON overhead + base64 bloat)
|
||||
@@ -88,7 +94,7 @@ function formatSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey }: Props) {
|
||||
export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey, replyTo, onCancelReply }: Props) {
|
||||
const t = useT();
|
||||
const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut();
|
||||
const [text, setText] = useState('');
|
||||
@@ -176,10 +182,17 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
|
||||
fileName: f.file.name,
|
||||
content: f.base64,
|
||||
})) : undefined;
|
||||
onSend(trimmed || ' ', attachments);
|
||||
// Prepend quote if replying
|
||||
let finalText = trimmed || ' ';
|
||||
if (replyTo?.preview) {
|
||||
const quoteLine = replyTo.preview.split('\n')[0].slice(0, 80);
|
||||
finalText = `> ${quoteLine}\n\n${finalText}`;
|
||||
}
|
||||
onSend(finalText, attachments);
|
||||
setText('');
|
||||
setFiles([]);
|
||||
setShowSlash(false);
|
||||
onCancelReply?.();
|
||||
// Clear draft for this session after sending
|
||||
if (sessionKey) draftsRef.current.delete(sessionKey);
|
||||
};
|
||||
@@ -252,6 +265,20 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
|
||||
onSelect={(cmd) => { setText(cmd); setShowSlash(shouldShowSlashMenu(cmd)); textareaRef.current?.focus(); }}
|
||||
onClose={() => setShowSlash(false)}
|
||||
/>
|
||||
{/* Reply context banner */}
|
||||
{replyTo && (
|
||||
<div className="flex items-center gap-2 mb-2 px-1 py-2 rounded-xl border-l-2 border-[var(--pc-accent)] bg-[rgba(var(--pc-accent-rgb),0.06)]">
|
||||
<Reply size={14} className="shrink-0 text-pc-accent-light ml-2" />
|
||||
<span className="text-xs text-pc-text-secondary truncate flex-1">{replyTo.preview || '…'}</span>
|
||||
<button
|
||||
onClick={onCancelReply}
|
||||
className="shrink-0 h-5 w-5 rounded-md flex items-center justify-center text-pc-text-muted hover:text-pc-text-secondary transition-colors mr-1"
|
||||
aria-label="Cancel reply"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* File previews */}
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3 px-1">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { CodeBlock } from './CodeBlock';
|
||||
import { ToolCall } from './ToolCall';
|
||||
import { ImageBlock } from './ImageBlock';
|
||||
import { buildImageSrc } from '../lib/image';
|
||||
import { Bot, User, Wrench, Copy, Check, CheckCheck, RefreshCw, Zap, Info, Webhook, Braces, Clock, AlertCircle, Bookmark, ChevronDown } from 'lucide-react';
|
||||
import { Bot, User, Wrench, Copy, Check, CheckCheck, RefreshCw, Zap, Info, Webhook, Braces, Clock, AlertCircle, Bookmark, ChevronDown, Reply } from 'lucide-react';
|
||||
import { t, getLocale } from '../lib/i18n';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { stripWebhookScaffolding, hasWebhookScaffolding } from '../lib/systemEvent';
|
||||
@@ -455,7 +455,7 @@ function SystemEventMessage({ message }: { message: ChatMessageType }) {
|
||||
);
|
||||
}
|
||||
|
||||
export const ChatMessageComponent = memo(function ChatMessageComponent({ message: rawMessage, onRetry, agentAvatarUrl, isFirstInGroup = true, isBookmarked = false, onToggleBookmark }: { message: ChatMessageType; onRetry?: (text: string) => void; agentAvatarUrl?: string; isFirstInGroup?: boolean; isBookmarked?: boolean; onToggleBookmark?: () => void }) {
|
||||
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 }) {
|
||||
useLocale(); // re-render on locale change
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isLight = resolvedTheme === 'light';
|
||||
@@ -534,6 +534,16 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
|
||||
<CopyButton text={getPlainText(message)} />
|
||||
)}
|
||||
<div className={`absolute -top-3 ${isUser ? 'left-2' : 'right-10'} flex gap-1 opacity-0 group-hover:opacity-100 transition-all z-10`}>
|
||||
{onReply && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onReply(getPlainText(message).slice(0, 120)); }}
|
||||
className="h-7 w-7 rounded-lg border border-pc-border bg-pc-elevated/80 backdrop-blur-sm flex items-center justify-center text-pc-text-secondary hover:text-pc-accent-light hover:border-[var(--pc-accent-dim)] transition-all"
|
||||
title={t('message.reply')}
|
||||
aria-label={t('message.reply')}
|
||||
>
|
||||
<Reply size={13} />
|
||||
</button>
|
||||
)}
|
||||
{onToggleBookmark && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleBookmark(); }}
|
||||
|
||||
@@ -168,6 +168,8 @@ const en = {
|
||||
// Bookmarks
|
||||
'message.bookmark': 'Bookmark message',
|
||||
'message.removeBookmark': 'Remove bookmark',
|
||||
'message.reply': 'Reply',
|
||||
'chat.replyingTo': 'Replying to',
|
||||
'chat.bookmarks': 'Bookmarks',
|
||||
'chat.export': 'Export conversation',
|
||||
'chat.contextCompacted': 'Context compacted — older messages cached locally',
|
||||
@@ -326,6 +328,8 @@ const fr: Record<keyof typeof en, string> = {
|
||||
|
||||
'message.bookmark': 'Marquer le message',
|
||||
'message.removeBookmark': 'Retirer le marque-page',
|
||||
'message.reply': 'Répondre',
|
||||
'chat.replyingTo': 'En réponse à',
|
||||
'chat.bookmarks': 'Marque-pages',
|
||||
'chat.export': 'Exporter la conversation',
|
||||
'chat.contextCompacted': 'Contexte compacté — anciens messages en cache local',
|
||||
@@ -484,6 +488,8 @@ const es: Record<keyof typeof en, string> = {
|
||||
|
||||
'message.bookmark': 'Marcar mensaje',
|
||||
'message.removeBookmark': 'Quitar marcador',
|
||||
'message.reply': 'Responder',
|
||||
'chat.replyingTo': 'Respondiendo a',
|
||||
'chat.bookmarks': 'Marcadores',
|
||||
'chat.export': 'Exportar conversación',
|
||||
'chat.contextCompacted': 'Contexto compactado — mensajes anteriores en caché local',
|
||||
@@ -643,6 +649,8 @@ const de: Record<keyof typeof en, string> = {
|
||||
'settings.sendCtrlEnter': 'Strg+Enter',
|
||||
|
||||
'message.bookmark': 'Nachricht markieren',
|
||||
'message.reply': 'Antworten',
|
||||
'chat.replyingTo': 'Antwort auf',
|
||||
'message.removeBookmark': 'Lesezeichen entfernen',
|
||||
'chat.bookmarks': 'Lesezeichen',
|
||||
'chat.export': 'Unterhaltung exportieren',
|
||||
@@ -801,6 +809,8 @@ const ja: Record<keyof typeof en, string> = {
|
||||
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
||||
|
||||
'message.bookmark': 'メッセージをブックマーク',
|
||||
'message.reply': '返信',
|
||||
'chat.replyingTo': '返信先',
|
||||
'message.removeBookmark': 'ブックマークを削除',
|
||||
'chat.bookmarks': 'ブックマーク',
|
||||
'chat.export': '会話をエクスポート',
|
||||
@@ -959,6 +969,8 @@ const pt: Record<keyof typeof en, string> = {
|
||||
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
||||
|
||||
'message.bookmark': 'Marcar mensagem',
|
||||
'message.reply': 'Responder',
|
||||
'chat.replyingTo': 'Respondendo a',
|
||||
'message.removeBookmark': 'Remover marcador',
|
||||
'chat.bookmarks': 'Marcadores',
|
||||
'chat.export': 'Exportar conversa',
|
||||
@@ -1117,6 +1129,8 @@ const zh: Record<keyof typeof en, string> = {
|
||||
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
||||
|
||||
'message.bookmark': '收藏消息',
|
||||
'message.reply': '回复',
|
||||
'chat.replyingTo': '回复',
|
||||
'message.removeBookmark': '取消收藏',
|
||||
'chat.bookmarks': '收藏',
|
||||
'chat.export': '导出会话',
|
||||
@@ -1275,6 +1289,8 @@ const it: Record<keyof typeof en, string> = {
|
||||
'settings.sendCtrlEnter': 'Ctrl+Invio',
|
||||
|
||||
'message.bookmark': 'Aggiungi ai segnalibri',
|
||||
'message.reply': 'Rispondi',
|
||||
'chat.replyingTo': 'In risposta a',
|
||||
'message.removeBookmark': 'Rimuovi segnalibro',
|
||||
'chat.bookmarks': 'Segnalibri',
|
||||
'chat.export': 'Esporta conversazione',
|
||||
|
||||
Reference in New Issue
Block a user