feat: message bookmarks — star important messages and jump back to them
- Add bookmark button on message hover (amber star icon) - Bookmarked messages show a small star indicator in the timestamp row - Floating bookmarks panel to list and jump to bookmarked messages - Bookmarks persisted in localStorage per session - i18n support (EN/FR)
This commit is contained in:
@@ -3,11 +3,12 @@ import { ChatMessageComponent } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import type { ChatMessage, ConnectionStatus } from '../types';
|
||||
import { Bot, ArrowDown, Loader2, ChevronsDownUp, ChevronsUpDown, Sparkles } from 'lucide-react';
|
||||
import { Bot, ArrowDown, Loader2, ChevronsDownUp, ChevronsUpDown, Sparkles, Bookmark } from 'lucide-react';
|
||||
import { MessageSearch } from './MessageSearch';
|
||||
import { useT } from '../hooks/useLocale';
|
||||
import { getLocale, type TranslationKey } from '../lib/i18n';
|
||||
import { useToolCollapse } from '../hooks/useToolCollapse';
|
||||
import { useBookmarks } from '../hooks/useBookmarks';
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
@@ -174,6 +175,9 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
const showTyping = isGenerating && !hasStreamedText(messages);
|
||||
|
||||
const { globalState, collapseAll, expandAll } = useToolCollapse();
|
||||
const { toggle: toggleBookmark, isBookmarked, getForSession: getBookmarks } = useBookmarks();
|
||||
const sessionBookmarks = useMemo(() => sessionKey ? getBookmarks(sessionKey) : [], [getBookmarks, sessionKey]);
|
||||
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||
const hasToolCalls = useMemo(() => messages.some(m => m.blocks.some(b => b.type === 'tool_use' || b.type === 'tool_result')), [messages]);
|
||||
|
||||
// Message search
|
||||
@@ -280,7 +284,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
</div>
|
||||
)}
|
||||
<div className={isActiveMatch ? 'ring-1 ring-pc-accent-light/40 rounded-lg' : ''}>
|
||||
<ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} agentAvatarUrl={agentAvatarUrl} isFirstInGroup={isFirstInGroup} />
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -288,6 +292,28 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
{showTyping && <TypingIndicator />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
{/* Bookmarks panel */}
|
||||
{showBookmarks && sessionBookmarks.length > 0 && (
|
||||
<div className="sticky bottom-14 z-20 flex justify-center pointer-events-none pb-1">
|
||||
<div className="pointer-events-auto w-72 max-h-48 overflow-y-auto rounded-2xl border border-pc-border-strong bg-pc-elevated/95 backdrop-blur-xl shadow-2xl p-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-pc-text-muted font-semibold px-2 py-1">{t('chat.bookmarks')}</div>
|
||||
{sessionBookmarks.map(b => (
|
||||
<button
|
||||
key={b.messageId}
|
||||
onClick={() => {
|
||||
const el = document.querySelector(`[data-msg-id="${b.messageId}"]`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setShowBookmarks(false);
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 rounded-xl hover:bg-[var(--pc-hover)] text-xs text-pc-text-secondary truncate transition-colors"
|
||||
>
|
||||
<span className="text-amber-400 mr-1">★</span>
|
||||
{b.preview || '(empty)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Floating action buttons — sticky to bottom of scroll area */}
|
||||
{(hasToolCalls || showScrollBtn || newMessageCount > 0) && (
|
||||
<div className="sticky bottom-3 z-10 flex justify-center pointer-events-none pb-1">
|
||||
@@ -302,6 +328,17 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
{globalState === 'expand-all' ? <ChevronsDownUp size={14} className="text-violet-300" /> : <ChevronsUpDown size={14} className="text-violet-300" />}
|
||||
</button>
|
||||
)}
|
||||
{sessionBookmarks.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowBookmarks(v => !v)}
|
||||
aria-label={t('chat.bookmarks')}
|
||||
title={t('chat.bookmarks')}
|
||||
className={`flex items-center gap-1.5 rounded-full border border-pc-border-strong bg-pc-elevated/90 backdrop-blur-lg px-3 py-2 text-xs text-pc-text shadow-lg hover:bg-pc-elevated/90 transition-all hover:shadow-amber-500/10 ${showBookmarks ? 'ring-1 ring-amber-400/40' : ''}`}
|
||||
>
|
||||
<Bookmark size={14} className="text-amber-300 fill-amber-300" />
|
||||
<span className="text-[10px] tabular-nums text-pc-text-muted">{sessionBookmarks.length}</span>
|
||||
</button>
|
||||
)}
|
||||
{(showScrollBtn || newMessageCount > 0) && (
|
||||
<button
|
||||
onClick={() => { scrollToBottom('smooth'); setNewMessageCount(0); }}
|
||||
|
||||
@@ -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 } from 'lucide-react';
|
||||
import { Bot, User, Wrench, Copy, Check, CheckCheck, RefreshCw, Zap, Info, Webhook, Braces, Clock, AlertCircle, Bookmark } from 'lucide-react';
|
||||
import { t, getLocale } from '../lib/i18n';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { stripWebhookScaffolding, hasWebhookScaffolding } from '../lib/systemEvent';
|
||||
@@ -412,7 +412,7 @@ function SystemEventMessage({ message }: { message: ChatMessageType }) {
|
||||
);
|
||||
}
|
||||
|
||||
export const ChatMessageComponent = memo(function ChatMessageComponent({ message: rawMessage, onRetry, agentAvatarUrl, isFirstInGroup = true }: { message: ChatMessageType; onRetry?: (text: string) => void; agentAvatarUrl?: string; isFirstInGroup?: boolean }) {
|
||||
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 }) {
|
||||
useLocale(); // re-render on locale change
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isLight = resolvedTheme === 'light';
|
||||
@@ -491,6 +491,16 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
|
||||
<CopyButton text={getPlainText(message)} />
|
||||
)}
|
||||
<div className={`absolute top-2 ${isUser ? 'left-2' : 'right-10'} flex gap-1 opacity-0 group-hover:opacity-100 transition-all z-10`}>
|
||||
{onToggleBookmark && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleBookmark(); }}
|
||||
className={`h-7 w-7 rounded-lg border border-pc-border bg-pc-elevated/80 backdrop-blur-sm flex items-center justify-center transition-all ${isBookmarked ? 'text-amber-400 opacity-100' : 'text-pc-text-secondary hover:text-amber-400'}`}
|
||||
title={isBookmarked ? t('message.removeBookmark') : t('message.bookmark')}
|
||||
aria-label={isBookmarked ? t('message.removeBookmark') : t('message.bookmark')}
|
||||
>
|
||||
<Bookmark size={13} className={isBookmarked ? 'fill-amber-400' : ''} />
|
||||
</button>
|
||||
)}
|
||||
<MetadataViewer metadata={message.metadata} />
|
||||
<RawJsonToggle isOpen={showRawJson} onToggle={() => setShowRawJson(o => !o)} />
|
||||
</div>
|
||||
@@ -538,8 +548,11 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
|
||||
{/* Raw JSON viewer */}
|
||||
{showRawJson && <RawJsonPanel message={rawMessage} />}
|
||||
</div>
|
||||
{(message.timestamp || wasWebhookMessage) && (
|
||||
{(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 && (
|
||||
<Bookmark size={10} className="text-amber-400 fill-amber-400" />
|
||||
)}
|
||||
{wasWebhookMessage && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-pc-text-faint" title="Webhook message (scaffolding stripped)">
|
||||
<Webhook size={10} className="opacity-60" />
|
||||
|
||||
Reference in New Issue
Block a user