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:
Nicolas Varrot
2026-02-14 09:56:37 +00:00
parent 56fccc1e62
commit aa9680cad6
4 changed files with 132 additions and 5 deletions

View File

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

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 } 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" />