| import { useMemo, useState } from 'react' |
| import useAppStore from '../store/appStore' |
| import { useSession } from '../hooks/useSession' |
|
|
| function bucketFor(updatedAt) { |
| const now = new Date() |
| const t = new Date(updatedAt) |
| const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()) |
| const startOfYesterday = new Date(startOfToday) |
| startOfYesterday.setDate(startOfYesterday.getDate() - 1) |
| const sevenDaysAgo = new Date(startOfToday) |
| sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7) |
|
|
| if (t >= startOfToday) return 'Today' |
| if (t >= startOfYesterday) return 'Yesterday' |
| if (t >= sevenDaysAgo) return 'Last 7 days' |
| return 'Older' |
| } |
|
|
| const BUCKET_ORDER = ['Today', 'Yesterday', 'Last 7 days', 'Older'] |
|
|
| export default function SessionSidebar() { |
| const { sessions, activeSessionId, sidebarOpen } = useAppStore() |
| const { createSession, openSession, deleteSession, renameSession } = useSession() |
|
|
| const [renamingId, setRenamingId] = useState(null) |
| const [renameValue, setRenameValue] = useState('') |
| const [query, setQuery] = useState('') |
|
|
| const grouped = useMemo(() => { |
| const filtered = query.trim() |
| ? sessions.filter((s) => s.name.toLowerCase().includes(query.trim().toLowerCase())) |
| : sessions |
| const buckets = { Today: [], Yesterday: [], 'Last 7 days': [], Older: [] } |
| for (const s of filtered) buckets[bucketFor(s.updated_at)].push(s) |
| return buckets |
| }, [sessions, query]) |
|
|
| if (!sidebarOpen) return null |
|
|
| async function handleNew() { |
| const session = await createSession('New Session') |
| if (session) await openSession(session.id) |
| } |
|
|
| function startRename(s) { |
| setRenamingId(s.id) |
| setRenameValue(s.name) |
| } |
|
|
| async function submitRename(id) { |
| if (renameValue.trim()) await renameSession(id, renameValue.trim()) |
| setRenamingId(null) |
| } |
|
|
| return ( |
| <div |
| className="h-full flex flex-col" |
| style={{ background: 'var(--surface)', borderRight: '1px solid var(--border)' }} |
| > |
| {/* Header */} |
| <div className="p-3 border-b" style={{ borderColor: 'var(--border)' }}> |
| <button |
| onClick={handleNew} |
| className="w-full h-8 rounded-md text-[12px] font-medium transition-all flex items-center justify-center gap-1.5" |
| style={{ |
| background: 'linear-gradient(135deg, var(--accent), var(--accent-lo))', |
| color: '#fff', |
| boxShadow: '0 2px 10px var(--accent-glow)', |
| }} |
| title="New session ⌘N" |
| > |
| <span className="text-base leading-none">+</span> New Session |
| </button> |
| <div className="mt-2 relative"> |
| <svg |
| width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" |
| className="absolute left-2.5 top-1/2 -translate-y-1/2" |
| style={{ color: 'var(--text-faint)' }} |
| > |
| <circle cx="7" cy="7" r="5" /> |
| <path d="M11 11l3.5 3.5" strokeLinecap="round" /> |
| </svg> |
| <input |
| value={query} |
| onChange={(e) => setQuery(e.target.value)} |
| placeholder="Search sessions" |
| className="w-full h-7 pl-7 pr-2 rounded-md text-[11.5px] outline-none transition-colors" |
| style={{ |
| background: 'var(--surface-2)', |
| border: '1px solid var(--border)', |
| color: 'var(--text)', |
| }} |
| onFocus={(e) => (e.target.style.borderColor = 'var(--accent)')} |
| onBlur={(e) => (e.target.style.borderColor = 'var(--border)')} |
| /> |
| </div> |
| </div> |
| |
| {/* Session list — grouped */} |
| <div className="flex-1 overflow-y-auto py-2 px-1"> |
| {sessions.length === 0 && ( |
| <div className="text-center text-[11px] py-4" style={{ color: 'var(--text-faint)' }}> |
| No sessions yet |
| </div> |
| )} |
| |
| {BUCKET_ORDER.map((bucket) => { |
| const items = grouped[bucket] |
| if (!items?.length) return null |
| return ( |
| <div key={bucket} className="mb-2"> |
| <div |
| className="px-2.5 py-1 text-[9.5px] uppercase tracking-[0.12em] mono" |
| style={{ color: 'var(--text-faint)' }} |
| > |
| {bucket} |
| </div> |
| {items.map((s) => { |
| const isActive = s.id === activeSessionId |
| return ( |
| <div |
| key={s.id} |
| onClick={() => openSession(s.id)} |
| className="group relative flex items-center justify-between mx-1 my-0.5 px-2.5 py-1.5 rounded-md cursor-pointer text-[12px] transition-colors" |
| style={{ |
| background: isActive ? 'var(--accent-bg)' : 'transparent', |
| color: isActive ? 'var(--text)' : 'var(--text-dim)', |
| }} |
| onMouseEnter={(e) => { |
| if (!isActive) e.currentTarget.style.background = 'var(--surface-2)' |
| }} |
| onMouseLeave={(e) => { |
| if (!isActive) e.currentTarget.style.background = 'transparent' |
| }} |
| > |
| {isActive && ( |
| <span |
| className="absolute left-0 top-1.5 bottom-1.5 w-[2px] rounded-full" |
| style={{ background: 'var(--accent)' }} |
| /> |
| )} |
| |
| {renamingId === s.id ? ( |
| <input |
| autoFocus |
| value={renameValue} |
| onChange={(e) => setRenameValue(e.target.value)} |
| onBlur={() => submitRename(s.id)} |
| onKeyDown={(e) => { |
| if (e.key === 'Enter') submitRename(s.id) |
| if (e.key === 'Escape') setRenamingId(null) |
| }} |
| onClick={(e) => e.stopPropagation()} |
| className="bg-transparent border-b outline-none w-full text-[12px]" |
| style={{ borderColor: 'var(--accent)', color: 'var(--text)' }} |
| /> |
| ) : ( |
| <span className="truncate flex-1 pl-1">{s.name}</span> |
| )} |
| |
| <div className="hidden group-hover:flex items-center gap-0.5 ml-1 shrink-0"> |
| <button |
| onClick={(e) => { e.stopPropagation(); startRename(s) }} |
| className="w-5 h-5 grid place-items-center rounded transition-colors hover:bg-white/5" |
| style={{ color: 'var(--text-dim)' }} |
| title="Rename" |
| > |
| <svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> |
| <path d="M11.5 1.5l3 3-9 9H2.5v-3z" /> |
| </svg> |
| </button> |
| <button |
| onClick={(e) => { e.stopPropagation(); deleteSession(s.id) }} |
| className="w-5 h-5 grid place-items-center rounded transition-colors hover:bg-red-500/10" |
| style={{ color: 'var(--text-dim)' }} |
| title="Delete" |
| > |
| <svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> |
| <path d="M3 4h10M5 4V2.5h6V4M5 4v9.5a1 1 0 001 1h4a1 1 0 001-1V4" /> |
| </svg> |
| </button> |
| </div> |
| </div> |
| ) |
| })} |
| </div> |
| ) |
| })} |
| |
| {query && Object.values(grouped).every((arr) => arr.length === 0) && ( |
| <div className="text-center text-[11px] py-4" style={{ color: 'var(--text-faint)' }}> |
| No sessions match "{query}" |
| </div> |
| )} |
| </div> |
| |
| <div className="px-3 py-2 border-t text-[10px] mono flex items-center justify-between" |
| style={{ borderColor: 'var(--border)', color: 'var(--text-faint)' }}> |
| <span>{sessions.length} session{sessions.length === 1 ? '' : 's'}</span> |
| <span>⌘K</span> |
| </div> |
| </div> |
| ) |
| } |
|
|