From e24378aa758c37b50f703fda6a1f6dd0c3e63cb7 Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Thu, 12 Feb 2026 12:19:18 +0000 Subject: [PATCH] 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) --- src/components/Sidebar.tsx | 156 ++++++++++++++++++++++++------------- src/lib/i18n.ts | 6 ++ 2 files changed, 110 insertions(+), 52 deletions(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 1b44747..2b55719 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,9 +1,25 @@ 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 { useT } from '../hooks/useLocale'; import { SessionIcon } from './SessionIcon'; +const PINNED_KEY = 'pinchchat-pinned-sessions'; + +function getPinnedSessions(): Set { + 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) { + try { + localStorage.setItem(PINNED_KEY, JSON.stringify([...pinned])); + } catch { /* noop */ } +} + interface Props { sessions: Session[]; activeSession: string; @@ -16,9 +32,21 @@ export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Pr const t = useT(); const [filter, setFilter] = useState(''); const [focusIdx, setFocusIdx] = useState(-1); + const [pinned, setPinned] = useState(getPinnedSessions); const searchRef = useRef(null); const listRef = useRef(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 useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -37,12 +65,16 @@ export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Pr }, []); const filtered = useMemo(() => { - if (!filter.trim()) return sessions; - const q = filter.toLowerCase(); - return sessions.filter(s => - (s.label || s.key).toLowerCase().includes(q) - ); - }, [sessions, filter]); + let list = sessions; + if (filter.trim()) { + const q = filter.toLowerCase(); + list = sessions.filter(s => (s.label || s.key).toLowerCase().includes(q)); + } + // Sort pinned sessions to top (preserving relative order within each group) + 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 ( <> @@ -127,55 +159,75 @@ export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Pr {filtered.map((s, idx) => { const isActive = s.key === activeSession; const isFocused = idx === focusIdx; + const isPinned = pinned.has(s.key); + const isFirstUnpinned = !isPinned && idx > 0 && pinned.has(filtered[idx - 1].key); return ( - + {s.messageCount != null && ( + + {s.messageCount} + + )} + + {(() => { + if (!s.contextTokens) return null; + const pct = Math.min(100, ((s.totalTokens || 0) / s.contextTokens) * 100); + const barOpacity = Math.max(0.35, Math.min(1, pct / 100)); + const barStyle = { width: `${pct}%`, backgroundColor: `rgba(56, 189, 248, ${barOpacity})` }; + return ( +
+
+
+
+ {Math.round(pct)}%
- {Math.round(pct)}% -
- ); - })()} - - + ); + })()} + + + ); })} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 9514aad..f1f8034 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -49,6 +49,9 @@ const en = { 'sidebar.empty': 'No sessions', 'sidebar.search': 'Search sessions…', 'sidebar.noResults': 'No matching sessions', + 'sidebar.pin': 'Pin session', + 'sidebar.unpin': 'Unpin session', + 'sidebar.pinned': 'Pinned', // Thinking 'thinking.label': 'Thinking', @@ -128,6 +131,9 @@ const fr: Record = { 'sidebar.empty': 'Aucune session', 'sidebar.search': 'Rechercher…', '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',