diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a53649b..35784fb 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 && ( + setShowBookmarks(false)} + onSelectSession={(sessionId) => { + void api.selectSession(sessionId); + }} + onUnpinMessage={(sessionId, messageId) => { + void api.setSessionMessagePinned({ sessionId, messageId, isPinned: false }); + }} + /> + )} ); } diff --git a/src/renderer/components/BookmarksPanel.tsx b/src/renderer/components/BookmarksPanel.tsx new file mode 100644 index 0000000..432a007 --- /dev/null +++ b/src/renderer/components/BookmarksPanel.tsx @@ -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(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( + () => 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 ( +
+
e.stopPropagation()} + onKeyDown={handleKeyDown} + tabIndex={-1} + ref={(el) => el?.focus()} + > + {/* Header */} +
+ + + Bookmarks + + + {hits.length} {hits.length === 1 ? 'message' : 'messages'} + +
+ + {/* List */} +
+ {hits.length === 0 ? ( +
+ + + No bookmarked messages yet + + + Pin messages from any session to save them here + +
+ ) : ( + hits.map((hit, index) => { + const isSelected = index === selectedIndex; + return ( + + + ); + }) + )} +
+ + {/* Footer hints */} +
+ + ↑↓ + navigate + + + + jump to message + + + esc + close + +
+
+
+ ); +} diff --git a/src/renderer/components/CommandPalette.tsx b/src/renderer/components/CommandPalette.tsx index 845cc07..3a1d0e6 100644 --- a/src/renderer/components/CommandPalette.tsx +++ b/src/renderer/components/CommandPalette.tsx @@ -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: , + 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(() => { diff --git a/src/renderer/lib/keyboardShortcuts.ts b/src/renderer/lib/keyboardShortcuts.ts index 249b60b..8d9f937 100644 --- a/src/renderer/lib/keyboardShortcuts.ts +++ b/src/renderer/lib/keyboardShortcuts.ts @@ -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' }, diff --git a/src/shared/domain/sessionLibrary.ts b/src/shared/domain/sessionLibrary.ts index 1a7f7c2..d831e16 100644 --- a/src/shared/domain/sessionLibrary.ts +++ b/src/shared/domain/sessionLibrary.ts @@ -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( + 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(workspace.projects.map((project) => [project.id, project])); const patternsById = new Map(workspace.patterns.map((pattern) => [pattern.id, pattern]));