mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 05:28:46 +02:00
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:
@@ -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 });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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]));
|
||||
|
||||
Reference in New Issue
Block a user