mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-09 05:08:44 +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 { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
|
||||||
import { NewSessionModal } from '@renderer/components/NewSessionModal';
|
import { NewSessionModal } from '@renderer/components/NewSessionModal';
|
||||||
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
|
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
|
||||||
|
import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
|
||||||
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
|
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
|
||||||
import { SettingsPanel } from '@renderer/components/SettingsPanel';
|
import { SettingsPanel } from '@renderer/components/SettingsPanel';
|
||||||
import { Sidebar } from '@renderer/components/Sidebar';
|
import { Sidebar } from '@renderer/components/Sidebar';
|
||||||
@@ -118,6 +119,7 @@ export default function App() {
|
|||||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||||
|
|
||||||
// Terminal state
|
// Terminal state
|
||||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||||
@@ -324,6 +326,13 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ctrl/Cmd+Shift+B — Bookmarks ──
|
||||||
|
if (mod && e.shiftKey && e.key === 'B') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowBookmarks((prev) => !prev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Ctrl/Cmd+, — Open settings ──
|
// ── Ctrl/Cmd+, — Open settings ──
|
||||||
if (mod && e.key === ',') {
|
if (mod && e.key === ',') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -824,6 +833,7 @@ export default function App() {
|
|||||||
onOpenAppDataFolder={() => void api.openAppDataFolder()}
|
onOpenAppDataFolder={() => void api.openAppDataFolder()}
|
||||||
onShowShortcuts={() => setShowShortcuts(true)}
|
onShowShortcuts={() => setShowShortcuts(true)}
|
||||||
onShowSearch={() => setShowSearch(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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
|
Bookmark,
|
||||||
Copy,
|
Copy,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
@@ -51,6 +52,7 @@ export interface CommandPaletteProps {
|
|||||||
onOpenAppDataFolder: () => void;
|
onOpenAppDataFolder: () => void;
|
||||||
onShowShortcuts: () => void;
|
onShowShortcuts: () => void;
|
||||||
onShowSearch: () => void;
|
onShowSearch: () => void;
|
||||||
|
onShowBookmarks: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Score how well `query` matches `text` (and optional `keywords`). 0 = no match. */
|
/** Score how well `query` matches `text` (and optional `keywords`). 0 = no match. */
|
||||||
@@ -92,6 +94,7 @@ export function CommandPalette({
|
|||||||
onOpenAppDataFolder,
|
onOpenAppDataFolder,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onShowSearch,
|
onShowSearch,
|
||||||
|
onShowBookmarks,
|
||||||
}: CommandPaletteProps) {
|
}: CommandPaletteProps) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
@@ -234,6 +237,16 @@ export function CommandPalette({
|
|||||||
action: onShowSearch,
|
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({
|
cmds.push({
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
label: 'Open Settings',
|
label: 'Open Settings',
|
||||||
@@ -316,7 +329,7 @@ export function CommandPalette({
|
|||||||
onSelectSession, onSelectProject, onNewSession, onCreateScratchpad,
|
onSelectSession, onSelectProject, onNewSession, onCreateScratchpad,
|
||||||
onOpenSettings, onOpenProjectSettings, onToggleTerminal, onSetTheme,
|
onOpenSettings, onOpenProjectSettings, onToggleTerminal, onSetTheme,
|
||||||
onDuplicateSession, onPinSession, onArchiveSession, onAddProject,
|
onDuplicateSession, onPinSession, onArchiveSession, onAddProject,
|
||||||
onOpenAppDataFolder, onShowShortcuts, onShowSearch,
|
onOpenAppDataFolder, onShowShortcuts, onShowSearch, onShowBookmarks,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const filteredCommands = useMemo(() => {
|
const filteredCommands = useMemo(() => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const shortcuts: ShortcutDefinition[] = [
|
|||||||
// ── Navigation ──
|
// ── Navigation ──
|
||||||
{ id: 'command-palette', label: 'Command palette', keys: `${MOD}+K`, category: '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: '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: 'settings', label: 'Open settings', keys: `${MOD}+,`, category: 'Navigation' },
|
||||||
{ id: 'toggle-terminal', label: 'Toggle terminal', keys: 'Ctrl+`', category: 'Navigation' },
|
{ id: 'toggle-terminal', label: 'Toggle terminal', keys: 'Ctrl+`', category: 'Navigation' },
|
||||||
{ id: 'shortcut-help', label: 'Keyboard shortcuts', keys: `${MOD}+/`, 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[] {
|
export function querySessions(workspace: WorkspaceState, input: QuerySessionsInput): SessionQueryResult[] {
|
||||||
const projectsById = new Map<string, ProjectRecord>(workspace.projects.map((project) => [project.id, project]));
|
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]));
|
const patternsById = new Map<string, PatternDefinition>(workspace.patterns.map((pattern) => [pattern.id, pattern]));
|
||||||
|
|||||||
Reference in New Issue
Block a user