feat: reply-to-message with quote preview in chat input

This commit is contained in:
Nicolas Varrot
2026-02-15 12:04:13 +00:00
parent 89abe3ef0d
commit f012336e30
4 changed files with 64 additions and 7 deletions

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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(); }}

View File

@@ -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',