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 userSentRef = useRef(false);
|
||||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||||
const [newMessageCount, setNewMessageCount] = useState(0);
|
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 prevMessageCountRef = useRef(messages.length);
|
||||||
|
|
||||||
const checkIfNearBottom = useCallback(() => {
|
const checkIfNearBottom = useCallback(() => {
|
||||||
@@ -335,7 +339,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`${isActiveMatch ? 'ring-1 ring-pc-accent-light/40 rounded-lg' : ''} ${msg.isArchived ? 'opacity-60' : ''}`}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -418,7 +422,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, lazy, Suspense } from 'react';
|
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 { useT } from '../hooks/useLocale';
|
||||||
import { useSendShortcut } from '../hooks/useSendShortcut';
|
import { useSendShortcut } from '../hooks/useSendShortcut';
|
||||||
import { SlashCommandMenu } from './SlashCommands';
|
import { SlashCommandMenu } from './SlashCommands';
|
||||||
@@ -18,12 +18,18 @@ interface FileAttachment {
|
|||||||
preview?: string; // data url thumbnail for images
|
preview?: string; // data url thumbnail for images
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReplyContext {
|
||||||
|
preview: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
|
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
|
||||||
onAbort: () => void;
|
onAbort: () => void;
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
|
replyTo?: ReplyContext | null;
|
||||||
|
onCancelReply?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_BASE64_CHARS = 300 * 1024; // ~225KB real, well under 512KB WS limit (JSON overhead + base64 bloat)
|
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`;
|
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 t = useT();
|
||||||
const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut();
|
const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut();
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
@@ -176,10 +182,17 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
|
|||||||
fileName: f.file.name,
|
fileName: f.file.name,
|
||||||
content: f.base64,
|
content: f.base64,
|
||||||
})) : undefined;
|
})) : 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('');
|
setText('');
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setShowSlash(false);
|
setShowSlash(false);
|
||||||
|
onCancelReply?.();
|
||||||
// Clear draft for this session after sending
|
// Clear draft for this session after sending
|
||||||
if (sessionKey) draftsRef.current.delete(sessionKey);
|
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(); }}
|
onSelect={(cmd) => { setText(cmd); setShowSlash(shouldShowSlashMenu(cmd)); textareaRef.current?.focus(); }}
|
||||||
onClose={() => setShowSlash(false)}
|
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 */}
|
{/* File previews */}
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mb-3 px-1">
|
<div className="flex flex-wrap gap-2 mb-3 px-1">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { CodeBlock } from './CodeBlock';
|
|||||||
import { ToolCall } from './ToolCall';
|
import { ToolCall } from './ToolCall';
|
||||||
import { ImageBlock } from './ImageBlock';
|
import { ImageBlock } from './ImageBlock';
|
||||||
import { buildImageSrc } from '../lib/image';
|
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 { t, getLocale } from '../lib/i18n';
|
||||||
import { useLocale } from '../hooks/useLocale';
|
import { useLocale } from '../hooks/useLocale';
|
||||||
import { stripWebhookScaffolding, hasWebhookScaffolding } from '../lib/systemEvent';
|
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
|
useLocale(); // re-render on locale change
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const isLight = resolvedTheme === 'light';
|
const isLight = resolvedTheme === 'light';
|
||||||
@@ -534,6 +534,16 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
|
|||||||
<CopyButton text={getPlainText(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`}>
|
<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 && (
|
{onToggleBookmark && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onToggleBookmark(); }}
|
onClick={(e) => { e.stopPropagation(); onToggleBookmark(); }}
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ const en = {
|
|||||||
// Bookmarks
|
// Bookmarks
|
||||||
'message.bookmark': 'Bookmark message',
|
'message.bookmark': 'Bookmark message',
|
||||||
'message.removeBookmark': 'Remove bookmark',
|
'message.removeBookmark': 'Remove bookmark',
|
||||||
|
'message.reply': 'Reply',
|
||||||
|
'chat.replyingTo': 'Replying to',
|
||||||
'chat.bookmarks': 'Bookmarks',
|
'chat.bookmarks': 'Bookmarks',
|
||||||
'chat.export': 'Export conversation',
|
'chat.export': 'Export conversation',
|
||||||
'chat.contextCompacted': 'Context compacted — older messages cached locally',
|
'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.bookmark': 'Marquer le message',
|
||||||
'message.removeBookmark': 'Retirer le marque-page',
|
'message.removeBookmark': 'Retirer le marque-page',
|
||||||
|
'message.reply': 'Répondre',
|
||||||
|
'chat.replyingTo': 'En réponse à',
|
||||||
'chat.bookmarks': 'Marque-pages',
|
'chat.bookmarks': 'Marque-pages',
|
||||||
'chat.export': 'Exporter la conversation',
|
'chat.export': 'Exporter la conversation',
|
||||||
'chat.contextCompacted': 'Contexte compacté — anciens messages en cache local',
|
'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.bookmark': 'Marcar mensaje',
|
||||||
'message.removeBookmark': 'Quitar marcador',
|
'message.removeBookmark': 'Quitar marcador',
|
||||||
|
'message.reply': 'Responder',
|
||||||
|
'chat.replyingTo': 'Respondiendo a',
|
||||||
'chat.bookmarks': 'Marcadores',
|
'chat.bookmarks': 'Marcadores',
|
||||||
'chat.export': 'Exportar conversación',
|
'chat.export': 'Exportar conversación',
|
||||||
'chat.contextCompacted': 'Contexto compactado — mensajes anteriores en caché local',
|
'chat.contextCompacted': 'Contexto compactado — mensajes anteriores en caché local',
|
||||||
@@ -643,6 +649,8 @@ const de: Record<keyof typeof en, string> = {
|
|||||||
'settings.sendCtrlEnter': 'Strg+Enter',
|
'settings.sendCtrlEnter': 'Strg+Enter',
|
||||||
|
|
||||||
'message.bookmark': 'Nachricht markieren',
|
'message.bookmark': 'Nachricht markieren',
|
||||||
|
'message.reply': 'Antworten',
|
||||||
|
'chat.replyingTo': 'Antwort auf',
|
||||||
'message.removeBookmark': 'Lesezeichen entfernen',
|
'message.removeBookmark': 'Lesezeichen entfernen',
|
||||||
'chat.bookmarks': 'Lesezeichen',
|
'chat.bookmarks': 'Lesezeichen',
|
||||||
'chat.export': 'Unterhaltung exportieren',
|
'chat.export': 'Unterhaltung exportieren',
|
||||||
@@ -801,6 +809,8 @@ const ja: Record<keyof typeof en, string> = {
|
|||||||
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
||||||
|
|
||||||
'message.bookmark': 'メッセージをブックマーク',
|
'message.bookmark': 'メッセージをブックマーク',
|
||||||
|
'message.reply': '返信',
|
||||||
|
'chat.replyingTo': '返信先',
|
||||||
'message.removeBookmark': 'ブックマークを削除',
|
'message.removeBookmark': 'ブックマークを削除',
|
||||||
'chat.bookmarks': 'ブックマーク',
|
'chat.bookmarks': 'ブックマーク',
|
||||||
'chat.export': '会話をエクスポート',
|
'chat.export': '会話をエクスポート',
|
||||||
@@ -959,6 +969,8 @@ const pt: Record<keyof typeof en, string> = {
|
|||||||
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
||||||
|
|
||||||
'message.bookmark': 'Marcar mensagem',
|
'message.bookmark': 'Marcar mensagem',
|
||||||
|
'message.reply': 'Responder',
|
||||||
|
'chat.replyingTo': 'Respondendo a',
|
||||||
'message.removeBookmark': 'Remover marcador',
|
'message.removeBookmark': 'Remover marcador',
|
||||||
'chat.bookmarks': 'Marcadores',
|
'chat.bookmarks': 'Marcadores',
|
||||||
'chat.export': 'Exportar conversa',
|
'chat.export': 'Exportar conversa',
|
||||||
@@ -1117,6 +1129,8 @@ const zh: Record<keyof typeof en, string> = {
|
|||||||
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
'settings.sendCtrlEnter': 'Ctrl+Enter',
|
||||||
|
|
||||||
'message.bookmark': '收藏消息',
|
'message.bookmark': '收藏消息',
|
||||||
|
'message.reply': '回复',
|
||||||
|
'chat.replyingTo': '回复',
|
||||||
'message.removeBookmark': '取消收藏',
|
'message.removeBookmark': '取消收藏',
|
||||||
'chat.bookmarks': '收藏',
|
'chat.bookmarks': '收藏',
|
||||||
'chat.export': '导出会话',
|
'chat.export': '导出会话',
|
||||||
@@ -1275,6 +1289,8 @@ const it: Record<keyof typeof en, string> = {
|
|||||||
'settings.sendCtrlEnter': 'Ctrl+Invio',
|
'settings.sendCtrlEnter': 'Ctrl+Invio',
|
||||||
|
|
||||||
'message.bookmark': 'Aggiungi ai segnalibri',
|
'message.bookmark': 'Aggiungi ai segnalibri',
|
||||||
|
'message.reply': 'Rispondi',
|
||||||
|
'chat.replyingTo': 'In risposta a',
|
||||||
'message.removeBookmark': 'Rimuovi segnalibro',
|
'message.removeBookmark': 'Rimuovi segnalibro',
|
||||||
'chat.bookmarks': 'Segnalibri',
|
'chat.bookmarks': 'Segnalibri',
|
||||||
'chat.export': 'Esporta conversazione',
|
'chat.export': 'Esporta conversazione',
|
||||||
|
|||||||
Reference in New Issue
Block a user