feat: add bookmarks panel for viewing pinned messages across sessions

Add a new BookmarksPanel accessible via Ctrl/Cmd+Shift+B or the
command palette (View Bookmarks). The panel lists all pinned messages
across all sessions globally, with:

- Click-to-navigate: switches session and scrolls to the message
- Inline unpin: remove bookmarks directly from the panel
- Keyboard navigation: arrow keys, Enter, Escape
- Empty state when no messages are pinned

New shared helper listPinnedMessages() in sessionLibrary.ts derives
pinned messages from the workspace state in the renderer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-30 16:05:08 +01:00
co-authored by Copilot
parent e37d69bd63
commit 770a0f3529
5 changed files with 271 additions and 1 deletions
+23
View File
@@ -8,6 +8,7 @@ import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingMo
import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
import { NewSessionModal } from '@renderer/components/NewSessionModal';
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
import { SettingsPanel } from '@renderer/components/SettingsPanel';
import { Sidebar } from '@renderer/components/Sidebar';
@@ -118,6 +119,7 @@ export default function App() {
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [showBookmarks, setShowBookmarks] = useState(false);
// Terminal state
const [terminalOpen, setTerminalOpen] = useState(false);
@@ -324,6 +326,13 @@ export default function App() {
return;
}
// ── Ctrl/Cmd+Shift+B — Bookmarks ──
if (mod && e.shiftKey && e.key === 'B') {
e.preventDefault();
setShowBookmarks((prev) => !prev);
return;
}
// ── Ctrl/Cmd+, — Open settings ──
if (mod && e.key === ',') {
e.preventDefault();
@@ -824,6 +833,7 @@ export default function App() {
onOpenAppDataFolder={() => void api.openAppDataFolder()}
onShowShortcuts={() => setShowShortcuts(true)}
onShowSearch={() => setShowSearch(true)}
onShowBookmarks={() => setShowBookmarks(true)}
/>
)}
@@ -840,6 +850,19 @@ export default function App() {
}}
/>
)}
{showBookmarks && workspace && (
<BookmarksPanel
workspace={workspace}
onClose={() => setShowBookmarks(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
onUnpinMessage={(sessionId, messageId) => {
void api.setSessionMessagePinned({ sessionId, messageId, isPinned: false });
}}
/>
)}
</>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Bookmark, BookmarkMinus, ArrowRight } from 'lucide-react';
import type { WorkspaceState } from '@shared/domain/workspace';
import { listPinnedMessages, type PinnedMessageHit } from '@shared/domain/sessionLibrary';
export interface BookmarksPanelProps {
workspace: WorkspaceState;
onClose: () => void;
onSelectSession: (sessionId: string) => void;
onUnpinMessage: (sessionId: string, messageId: string) => void;
}
export function BookmarksPanel({ workspace, onClose, onSelectSession, onUnpinMessage }: BookmarksPanelProps) {
const [selectedIndex, setSelectedIndex] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
// Escape to close in capture phase
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopImmediatePropagation();
onClose();
}
};
document.addEventListener('keydown', handleEscape, true);
return () => document.removeEventListener('keydown', handleEscape, true);
}, [onClose]);
const hits = useMemo<PinnedMessageHit[]>(
() => listPinnedMessages(workspace),
[workspace],
);
// Clamp selected index when items are removed
useEffect(() => {
if (hits.length > 0 && selectedIndex >= hits.length) {
setSelectedIndex(hits.length - 1);
}
}, [hits.length, selectedIndex]);
const handleSelect = useCallback((hit: PinnedMessageHit) => {
onClose();
onSelectSession(hit.session.id);
requestAnimationFrame(() => {
setTimeout(() => {
const el = document.querySelector(`[data-message-id="${CSS.escape(hit.message.id)}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-1', 'ring-[var(--color-accent)]/40', 'rounded-lg');
setTimeout(() => el.classList.remove('ring-1', 'ring-[var(--color-accent)]/40', 'rounded-lg'), 2000);
}
}, 100);
});
}, [onClose, onSelectSession]);
const handleUnpin = useCallback((e: React.MouseEvent, hit: PinnedMessageHit) => {
e.stopPropagation();
onUnpinMessage(hit.session.id, hit.message.id);
}, [onUnpinMessage]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (hits.length > 0) setSelectedIndex((i) => Math.min(i + 1, hits.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const hit = hits[selectedIndex];
if (hit) handleSelect(hit);
}
},
[hits, selectedIndex, handleSelect],
);
useEffect(() => {
const item = listRef.current?.querySelector(`[data-bookmark-index="${selectedIndex}"]`);
item?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
return (
<div
className="palette-backdrop-enter fixed inset-0 z-[60] flex justify-center bg-[#07080e]/80 pt-[15vh] backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Bookmarks"
>
<div
className="palette-enter glow-border flex h-fit max-h-[min(520px,65vh)] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-[var(--color-surface-1)] shadow-[0_16px_64px_rgba(0,0,0,0.5)]"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
tabIndex={-1}
ref={(el) => el?.focus()}
>
{/* Header */}
<div className="flex items-center gap-3 border-b border-[var(--color-border)] px-4 py-3.5">
<Bookmark className="size-4 shrink-0 text-[var(--color-text-accent)]" />
<span className="text-[14px] font-medium text-[var(--color-text-primary)]">
Bookmarks
</span>
<span className="text-[11px] text-[var(--color-text-muted)]">
{hits.length} {hits.length === 1 ? 'message' : 'messages'}
</span>
</div>
{/* List */}
<div ref={listRef} className="flex-1 overflow-y-auto py-1.5" role="listbox">
{hits.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-4 py-10 text-center">
<Bookmark className="size-6 text-[var(--color-text-muted)]/40" />
<span className="text-[13px] text-[var(--color-text-muted)]">
No bookmarked messages yet
</span>
<span className="text-[11px] text-[var(--color-text-muted)]/60">
Pin messages from any session to save them here
</span>
</div>
) : (
hits.map((hit, index) => {
const isSelected = index === selectedIndex;
return (
<button
key={`${hit.session.id}-${hit.message.id}`}
data-bookmark-index={index}
className={`group/row flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-glass-hover)] hover:text-[var(--color-text-primary)]'
}`}
onClick={() => handleSelect(hit)}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={isSelected}
type="button"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
{/* Session title row */}
<div className="flex items-center gap-2">
<Bookmark
className={`size-3.5 shrink-0 fill-[var(--color-accent-sky)] text-[var(--color-accent-sky)]`}
/>
<span className="truncate text-[12px] font-medium">{hit.session.title}</span>
<span className="text-[10px] text-[var(--color-text-muted)]">·</span>
<span className="truncate text-[10px] text-[var(--color-text-muted)]">{hit.projectName}</span>
<ArrowRight className="ml-auto size-3 shrink-0 text-[var(--color-text-muted)]" />
</div>
{/* Message snippet */}
<div className="pl-5.5 text-[12px] leading-relaxed text-[var(--color-text-muted)]">
<span className="line-clamp-2">{hit.snippet}</span>
</div>
<div className="pl-5.5 text-[10px] text-[var(--color-text-muted)]">
{hit.message.role === 'user' ? 'You' : hit.message.authorName}
</div>
</div>
{/* Unpin button */}
<button
className="mt-1 flex size-6 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)] opacity-0 transition-all duration-100 hover:bg-[var(--color-surface-2)] hover:text-[var(--color-status-error)] group-hover/row:opacity-100"
onClick={(e) => handleUnpin(e, hit)}
aria-label={`Unpin message from ${hit.session.title}`}
title="Remove bookmark"
type="button"
>
<BookmarkMinus className="size-3.5" />
</button>
</button>
);
})
)}
</div>
{/* Footer hints */}
<div className="flex items-center gap-4 border-t border-[var(--color-border)] px-4 py-2 text-[11px] text-[var(--color-text-muted)]">
<span className="flex items-center gap-1">
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 font-mono text-[10px]"></kbd>
navigate
</span>
<span className="flex items-center gap-1">
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 font-mono text-[10px]"></kbd>
jump to message
</span>
<span className="flex items-center gap-1">
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 font-mono text-[10px]">esc</kbd>
close
</span>
</div>
</div>
</div>
);
}
+14 -1
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Archive,
Bookmark,
Copy,
FolderOpen,
FolderPlus,
@@ -51,6 +52,7 @@ export interface CommandPaletteProps {
onOpenAppDataFolder: () => void;
onShowShortcuts: () => void;
onShowSearch: () => void;
onShowBookmarks: () => void;
}
/** Score how well `query` matches `text` (and optional `keywords`). 0 = no match. */
@@ -92,6 +94,7 @@ export function CommandPalette({
onOpenAppDataFolder,
onShowShortcuts,
onShowSearch,
onShowBookmarks,
}: CommandPaletteProps) {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -234,6 +237,16 @@ export function CommandPalette({
action: onShowSearch,
});
cmds.push({
id: 'view-bookmarks',
label: 'View Bookmarks',
category: 'General',
keywords: 'pin pinned bookmark bookmarked saved',
shortcut: shortcutKeys('bookmarks'),
icon: <Bookmark className={ICON} />,
action: onShowBookmarks,
});
cmds.push({
id: 'settings',
label: 'Open Settings',
@@ -316,7 +329,7 @@ export function CommandPalette({
onSelectSession, onSelectProject, onNewSession, onCreateScratchpad,
onOpenSettings, onOpenProjectSettings, onToggleTerminal, onSetTheme,
onDuplicateSession, onPinSession, onArchiveSession, onAddProject,
onOpenAppDataFolder, onShowShortcuts, onShowSearch,
onOpenAppDataFolder, onShowShortcuts, onShowSearch, onShowBookmarks,
]);
const filteredCommands = useMemo(() => {
+1
View File
@@ -18,6 +18,7 @@ export const shortcuts: ShortcutDefinition[] = [
// ── Navigation ──
{ id: 'command-palette', label: 'Command palette', keys: `${MOD}+K`, category: 'Navigation' },
{ id: 'search-sessions', label: 'Search sessions', keys: `${MOD}+Shift+F`, category: 'Navigation' },
{ id: 'bookmarks', label: 'View bookmarks', keys: `${MOD}+Shift+B`, category: 'Navigation' },
{ id: 'settings', label: 'Open settings', keys: `${MOD}+,`, category: 'Navigation' },
{ id: 'toggle-terminal', label: 'Toggle terminal', keys: 'Ctrl+`', category: 'Navigation' },
{ id: 'shortcut-help', label: 'Keyboard shortcuts', keys: `${MOD}+/`, category: 'Navigation' },
+38
View File
@@ -395,6 +395,44 @@ export function editAndResendSessionRecord(
};
}
// ── Pinned messages ──
export interface PinnedMessageHit {
session: SessionRecord;
projectName: string;
message: ChatMessageRecord;
/** Truncated preview of the message content. */
snippet: string;
}
function extractMessageSnippet(content: string, maxLength = 120): string {
const collapsed = content.replace(/\n+/g, ' ').trim();
if (collapsed.length <= maxLength) return collapsed;
return collapsed.slice(0, maxLength) + '…';
}
export function listPinnedMessages(workspace: WorkspaceState): PinnedMessageHit[] {
const projectNames = new Map<string, string>(
workspace.projects.map((p) => [p.id, isScratchpadProject(p) ? 'Scratchpad' : p.name]),
);
return workspace.sessions
.filter((session) => !session.isArchived)
.flatMap((session) =>
session.messages
.filter((message) => message.isPinned && message.content)
.map((message) => ({
session,
projectName: projectNames.get(session.projectId) ?? 'Unknown',
message,
snippet: extractMessageSnippet(message.content),
})),
)
.sort((a, b) => b.message.createdAt.localeCompare(a.message.createdAt));
}
// ── Session query ──
export function querySessions(workspace: WorkspaceState, input: QuerySessionsInput): SessionQueryResult[] {
const projectsById = new Map<string, ProjectRecord>(workspace.projects.map((project) => [project.id, project]));
const patternsById = new Map<string, PatternDefinition>(workspace.patterns.map((pattern) => [pattern.id, pattern]));