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 { ChatInput } from './ChatInput';
import { TypingIndicator } from './TypingIndicator'; import { TypingIndicator } from './TypingIndicator';
import type { ChatMessage, ConnectionStatus } from '../types'; 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 { MessageSearch } from './MessageSearch';
import { useT } from '../hooks/useLocale'; import { useT } from '../hooks/useLocale';
import { getLocale, type TranslationKey } from '../lib/i18n'; import { getLocale, type TranslationKey } from '../lib/i18n';
import { useToolCollapse } from '../hooks/useToolCollapse'; import { useToolCollapse } from '../hooks/useToolCollapse';
import { useBookmarks } from '../hooks/useBookmarks';
interface Props { interface Props {
messages: ChatMessage[]; messages: ChatMessage[];
@@ -174,6 +175,9 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
const showTyping = isGenerating && !hasStreamedText(messages); const showTyping = isGenerating && !hasStreamedText(messages);
const { globalState, collapseAll, expandAll } = useToolCollapse(); 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]); const hasToolCalls = useMemo(() => messages.some(m => m.blocks.some(b => b.type === 'tool_use' || b.type === 'tool_result')), [messages]);
// Message search // Message search
@@ -280,7 +284,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
</div> </div>
)} )}
<div className={isActiveMatch ? 'ring-1 ring-pc-accent-light/40 rounded-lg' : ''}> <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>
</div> </div>
); );
@@ -288,6 +292,28 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
{showTyping && <TypingIndicator />} {showTyping && <TypingIndicator />}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </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 */} {/* Floating action buttons — sticky to bottom of scroll area */}
{(hasToolCalls || showScrollBtn || newMessageCount > 0) && ( {(hasToolCalls || showScrollBtn || newMessageCount > 0) && (
<div className="sticky bottom-3 z-10 flex justify-center pointer-events-none pb-1"> <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" />} {globalState === 'expand-all' ? <ChevronsDownUp size={14} className="text-violet-300" /> : <ChevronsUpDown size={14} className="text-violet-300" />}
</button> </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) && ( {(showScrollBtn || newMessageCount > 0) && (
<button <button
onClick={() => { scrollToBottom('smooth'); setNewMessageCount(0); }} onClick={() => { scrollToBottom('smooth'); setNewMessageCount(0); }}

View File

@@ -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 } 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 { 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';
@@ -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 useLocale(); // re-render on locale change
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const isLight = resolvedTheme === 'light'; const isLight = resolvedTheme === 'light';
@@ -491,6 +491,16 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
<CopyButton text={getPlainText(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`}> <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} /> <MetadataViewer metadata={message.metadata} />
<RawJsonToggle isOpen={showRawJson} onToggle={() => setShowRawJson(o => !o)} /> <RawJsonToggle isOpen={showRawJson} onToggle={() => setShowRawJson(o => !o)} />
</div> </div>
@@ -538,8 +548,11 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
{/* Raw JSON viewer */} {/* Raw JSON viewer */}
{showRawJson && <RawJsonPanel message={rawMessage} />} {showRawJson && <RawJsonPanel message={rawMessage} />}
</div> </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'}`}> <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 && ( {wasWebhookMessage && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-pc-text-faint" title="Webhook message (scaffolding stripped)"> <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" /> <Webhook size={10} className="opacity-60" />

68
src/hooks/useBookmarks.ts Normal file
View File

@@ -0,0 +1,68 @@
import { useState, useCallback } from 'react';
const STORAGE_KEY = 'pinchchat-bookmarks';
export interface Bookmark {
messageId: string;
sessionKey: string;
preview: string;
timestamp: number;
bookmarkedAt: number;
}
function loadBookmarks(): Bookmark[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw) as Bookmark[];
} catch { /* noop */ }
return [];
}
function saveBookmarks(bookmarks: Bookmark[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks));
} catch { /* noop */ }
}
export function useBookmarks() {
const [bookmarks, setBookmarks] = useState<Bookmark[]>(loadBookmarks);
const toggle = useCallback((messageId: string, sessionKey: string, preview: string, timestamp: number) => {
setBookmarks(prev => {
const exists = prev.some(b => b.messageId === messageId);
const next = exists
? prev.filter(b => b.messageId !== messageId)
: [...prev, { messageId, sessionKey, preview: preview.slice(0, 120), timestamp, bookmarkedAt: Date.now() }];
saveBookmarks(next);
return next;
});
}, []);
const isBookmarked = useCallback((messageId: string) => {
return bookmarks.some(b => b.messageId === messageId);
}, [bookmarks]);
const getForSession = useCallback((sessionKey: string) => {
return bookmarks
.filter(b => b.sessionKey === sessionKey)
.sort((a, b) => a.timestamp - b.timestamp);
}, [bookmarks]);
const remove = useCallback((messageId: string) => {
setBookmarks(prev => {
const next = prev.filter(b => b.messageId !== messageId);
saveBookmarks(next);
return next;
});
}, []);
const clearSession = useCallback((sessionKey: string) => {
setBookmarks(prev => {
const next = prev.filter(b => b.sessionKey !== sessionKey);
saveBookmarks(next);
return next;
});
}, []);
return { bookmarks, toggle, isBookmarked, getForSession, remove, clearSession };
}

View File

@@ -142,6 +142,11 @@ const en = {
'settings.sendShortcut': 'Send with', 'settings.sendShortcut': 'Send with',
'settings.sendEnter': 'Enter', 'settings.sendEnter': 'Enter',
'settings.sendCtrlEnter': 'Ctrl+Enter', 'settings.sendCtrlEnter': 'Ctrl+Enter',
// Bookmarks
'message.bookmark': 'Bookmark message',
'message.removeBookmark': 'Remove bookmark',
'chat.bookmarks': 'Bookmarks',
} as const; } as const;
const fr: Record<keyof typeof en, string> = { const fr: Record<keyof typeof en, string> = {
@@ -264,6 +269,10 @@ const fr: Record<keyof typeof en, string> = {
'settings.sendShortcut': 'Envoyer avec', 'settings.sendShortcut': 'Envoyer avec',
'settings.sendEnter': 'Entrée', 'settings.sendEnter': 'Entrée',
'settings.sendCtrlEnter': 'Ctrl+Entrée', 'settings.sendCtrlEnter': 'Ctrl+Entrée',
'message.bookmark': 'Marquer le message',
'message.removeBookmark': 'Retirer le marque-page',
'chat.bookmarks': 'Marque-pages',
}; };
export type TranslationKey = keyof typeof en; export type TranslationKey = keyof typeof en;