feat: add session pinning to sidebar
- Pin icon appears on hover for each session, filled when pinned - Pinned sessions sort to top of list (preserved across page reloads via localStorage) - Subtle divider separates pinned from unpinned sessions - i18n support for pin/unpin labels (EN + FR)
This commit is contained in:
@@ -1,9 +1,25 @@
|
|||||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||||
import { X, Sparkles, Search } from 'lucide-react';
|
import { X, Sparkles, Search, Pin } from 'lucide-react';
|
||||||
import type { Session } from '../types';
|
import type { Session } from '../types';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
import { SessionIcon } from './SessionIcon';
|
import { SessionIcon } from './SessionIcon';
|
||||||
|
|
||||||
|
const PINNED_KEY = 'pinchchat-pinned-sessions';
|
||||||
|
|
||||||
|
function getPinnedSessions(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PINNED_KEY);
|
||||||
|
if (raw) return new Set(JSON.parse(raw) as string[]);
|
||||||
|
} catch { /* noop */ }
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePinnedSessions(pinned: Set<string>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PINNED_KEY, JSON.stringify([...pinned]));
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
activeSession: string;
|
activeSession: string;
|
||||||
@@ -16,9 +32,21 @@ export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Pr
|
|||||||
const t = useT();
|
const t = useT();
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [focusIdx, setFocusIdx] = useState(-1);
|
const [focusIdx, setFocusIdx] = useState(-1);
|
||||||
|
const [pinned, setPinned] = useState(getPinnedSessions);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const togglePin = useCallback((key: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setPinned(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
savePinnedSessions(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl+K or Cmd+K to focus search when sidebar is open
|
// Keyboard shortcut: Ctrl+K or Cmd+K to focus search when sidebar is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
@@ -37,12 +65,16 @@ export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Pr
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!filter.trim()) return sessions;
|
let list = sessions;
|
||||||
|
if (filter.trim()) {
|
||||||
const q = filter.toLowerCase();
|
const q = filter.toLowerCase();
|
||||||
return sessions.filter(s =>
|
list = sessions.filter(s => (s.label || s.key).toLowerCase().includes(q));
|
||||||
(s.label || s.key).toLowerCase().includes(q)
|
}
|
||||||
);
|
// Sort pinned sessions to top (preserving relative order within each group)
|
||||||
}, [sessions, filter]);
|
const pinnedList = list.filter(s => pinned.has(s.key));
|
||||||
|
const unpinnedList = list.filter(s => !pinned.has(s.key));
|
||||||
|
return [...pinnedList, ...unpinnedList];
|
||||||
|
}, [sessions, filter, pinned]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -127,14 +159,21 @@ export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Pr
|
|||||||
{filtered.map((s, idx) => {
|
{filtered.map((s, idx) => {
|
||||||
const isActive = s.key === activeSession;
|
const isActive = s.key === activeSession;
|
||||||
const isFocused = idx === focusIdx;
|
const isFocused = idx === focusIdx;
|
||||||
|
const isPinned = pinned.has(s.key);
|
||||||
|
const isFirstUnpinned = !isPinned && idx > 0 && pinned.has(filtered[idx - 1].key);
|
||||||
return (
|
return (
|
||||||
|
<div key={s.key}>
|
||||||
|
{isFirstUnpinned && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 mt-1 mb-1">
|
||||||
|
<div className="flex-1 h-px bg-white/5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
key={s.key}
|
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
onClick={() => { onSwitch(s.key); onClose(); }}
|
onClick={() => { onSwitch(s.key); onClose(); }}
|
||||||
onMouseEnter={() => setFocusIdx(idx)}
|
onMouseEnter={() => setFocusIdx(idx)}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl text-left text-sm transition-all mb-1 ${
|
className={`group/item w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl text-left text-sm transition-all mb-1 ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-white/5 text-cyan-200 border border-white/8 shadow-[0_0_12px_rgba(34,211,238,0.08)]'
|
? 'bg-white/5 text-cyan-200 border border-white/8 shadow-[0_0_12px_rgba(34,211,238,0.08)]'
|
||||||
: s.isActive
|
: s.isActive
|
||||||
@@ -154,6 +193,18 @@ export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Pr
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="flex-1 truncate">{s.label || s.key}</span>
|
<span className="flex-1 truncate">{s.label || s.key}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => togglePin(s.key, e)}
|
||||||
|
className={`shrink-0 p-0.5 rounded-lg transition-all ${
|
||||||
|
isPinned
|
||||||
|
? 'text-cyan-400 opacity-80 hover:opacity-100'
|
||||||
|
: 'text-zinc-600 opacity-0 group-hover/item:opacity-60 hover:!opacity-100 hover:text-zinc-400'
|
||||||
|
}`}
|
||||||
|
title={isPinned ? t('sidebar.unpin') : t('sidebar.pin')}
|
||||||
|
aria-label={isPinned ? t('sidebar.unpin') : t('sidebar.pin')}
|
||||||
|
>
|
||||||
|
<Pin size={12} className={isPinned ? 'fill-current' : ''} />
|
||||||
|
</button>
|
||||||
{s.messageCount != null && (
|
{s.messageCount != null && (
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded-full shrink-0 ${isActive ? 'bg-cyan-400/10 text-cyan-300' : 'bg-white/5 text-zinc-500'}`}>
|
<span className={`text-[11px] px-2 py-0.5 rounded-full shrink-0 ${isActive ? 'bg-cyan-400/10 text-cyan-300' : 'bg-white/5 text-zinc-500'}`}>
|
||||||
{s.messageCount}
|
{s.messageCount}
|
||||||
@@ -176,6 +227,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Pr
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ const en = {
|
|||||||
'sidebar.empty': 'No sessions',
|
'sidebar.empty': 'No sessions',
|
||||||
'sidebar.search': 'Search sessions…',
|
'sidebar.search': 'Search sessions…',
|
||||||
'sidebar.noResults': 'No matching sessions',
|
'sidebar.noResults': 'No matching sessions',
|
||||||
|
'sidebar.pin': 'Pin session',
|
||||||
|
'sidebar.unpin': 'Unpin session',
|
||||||
|
'sidebar.pinned': 'Pinned',
|
||||||
|
|
||||||
// Thinking
|
// Thinking
|
||||||
'thinking.label': 'Thinking',
|
'thinking.label': 'Thinking',
|
||||||
@@ -128,6 +131,9 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'sidebar.empty': 'Aucune session',
|
'sidebar.empty': 'Aucune session',
|
||||||
'sidebar.search': 'Rechercher…',
|
'sidebar.search': 'Rechercher…',
|
||||||
'sidebar.noResults': 'Aucun résultat',
|
'sidebar.noResults': 'Aucun résultat',
|
||||||
|
'sidebar.pin': 'Épingler la session',
|
||||||
|
'sidebar.unpin': 'Désépingler la session',
|
||||||
|
'sidebar.pinned': 'Épinglées',
|
||||||
|
|
||||||
'thinking.label': 'Réflexion',
|
'thinking.label': 'Réflexion',
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user