SessionList.tsx (6389B)
1 import { useState, useRef, useEffect } from "react"; 2 import { useSessionStore, type Session } from "../../store/session"; 3 4 function formatDate(ts?: number): string { 5 if (!ts) return ""; 6 return new Date(ts * 1000).toLocaleDateString(); 7 } 8 9 function formatRelative(ts?: number): string { 10 if (!ts) return ""; 11 const diff = Date.now() / 1000 - ts; 12 if (diff < 60) return "just now"; 13 if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; 14 if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; 15 return `${Math.floor(diff / 86400)}d ago`; 16 } 17 18 function SessionItem({ 19 session, 20 isActive, 21 onSelect, 22 onRename, 23 onDelete, 24 }: { 25 session: Session; 26 isActive: boolean; 27 onSelect: (id: string) => void; 28 onRename: (id: string, name: string) => void; 29 onDelete: (id: string) => void; 30 }) { 31 const [editing, setEditing] = useState(false); 32 const [name, setName] = useState(session.name ?? "Untitled"); 33 const [menuOpen, setMenuOpen] = useState(false); 34 const [confirmDelete, setConfirmDelete] = useState(false); 35 const inputRef = useRef<HTMLInputElement>(null); 36 const menuRef = useRef<HTMLDivElement>(null); 37 38 useEffect(() => { 39 if (editing) inputRef.current?.focus(); 40 }, [editing]); 41 42 useEffect(() => { 43 if (!menuOpen) return; 44 const close = () => setMenuOpen(false); 45 document.addEventListener("click", close); 46 return () => document.removeEventListener("click", close); 47 }, [menuOpen]); 48 49 const handleRename = () => { 50 if (name.trim() && name.trim() !== session.name) { 51 onRename(session.id, name.trim()); 52 } 53 setEditing(false); 54 }; 55 56 const handleDelete = () => { 57 onDelete(session.id); 58 setConfirmDelete(false); 59 }; 60 61 return ( 62 <div 63 className={`group flex cursor-pointer items-center justify-between rounded px-2 py-1.5 text-sm transition-colors ${ 64 isActive 65 ? "bg-indigo-600/20 text-indigo-300" 66 : "text-gray-400 hover:bg-gray-800 hover:text-gray-200" 67 }`} 68 onClick={() => onSelect(session.id)} 69 > 70 {editing ? ( 71 <input 72 ref={inputRef} 73 value={name} 74 onChange={(e) => setName(e.target.value)} 75 onBlur={handleRename} 76 onKeyDown={(e) => { 77 if (e.key === "Enter") handleRename(); 78 if (e.key === "Escape") setEditing(false); 79 }} 80 className="w-full rounded bg-gray-800 px-1 py-0.5 text-sm text-gray-100 outline-none ring-1 ring-indigo-500" 81 onClick={(e) => e.stopPropagation()} 82 /> 83 ) : ( 84 <div className="min-w-0 flex-1"> 85 <div className="truncate">{session.name ?? "Untitled"}</div> 86 <div className="mt-0.5 flex gap-2 text-xs text-gray-600"> 87 {session.created_at && <span>{formatDate(session.created_at)}</span>} 88 {session.last_activity && ( 89 <span>{formatRelative(session.last_activity)}</span> 90 )} 91 {session.message_count != null && ( 92 <span>{session.message_count} msgs</span> 93 )} 94 </div> 95 </div> 96 )} 97 98 {!editing && ( 99 <div className="relative ml-2" ref={menuRef}> 100 <button 101 className="rounded p-0.5 text-gray-600 opacity-0 transition-opacity hover:bg-gray-700 hover:text-gray-300 group-hover:opacity-100" 102 onClick={(e) => { 103 e.stopPropagation(); 104 setMenuOpen(!menuOpen); 105 }} 106 > 107 ⋮ 108 </button> 109 {menuOpen && ( 110 <div className="absolute right-0 top-full z-10 mt-1 w-36 rounded-md border border-gray-700 bg-gray-900 shadow-lg"> 111 <button 112 className="w-full px-3 py-1.5 text-left text-sm text-gray-300 hover:bg-gray-800" 113 onClick={(e) => { 114 e.stopPropagation(); 115 setEditing(true); 116 setMenuOpen(false); 117 }} 118 > 119 Rename 120 </button> 121 <button 122 className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-gray-800" 123 onClick={(e) => { 124 e.stopPropagation(); 125 setConfirmDelete(true); 126 setMenuOpen(false); 127 }} 128 > 129 Delete 130 </button> 131 </div> 132 )} 133 </div> 134 )} 135 136 {confirmDelete && ( 137 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> 138 <div className="rounded-lg bg-gray-900 p-4 shadow-xl"> 139 <p className="mb-3 text-sm text-gray-300"> 140 Delete "{session.name ?? "Untitled"}"? 141 </p> 142 <div className="flex justify-end gap-2"> 143 <button 144 className="rounded px-3 py-1 text-sm text-gray-400 hover:bg-gray-800" 145 onClick={() => setConfirmDelete(false)} 146 > 147 Cancel 148 </button> 149 <button 150 className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700" 151 onClick={handleDelete} 152 > 153 Delete 154 </button> 155 </div> 156 </div> 157 </div> 158 )} 159 </div> 160 ); 161 } 162 163 export function SessionList() { 164 const { 165 sessions, 166 activeSessionId, 167 setActiveSession, 168 updateSession, 169 deleteSession, 170 listSessions, 171 } = useSessionStore(); 172 173 useEffect(() => { 174 listSessions(); 175 }, [listSessions]); 176 177 return ( 178 <div className="flex flex-col gap-1"> 179 <div className="mb-2 flex items-center justify-between"> 180 <h2 className="text-xs font-semibold uppercase text-gray-500"> 181 Sessions 182 </h2> 183 <button 184 className="rounded p-1 text-gray-600 hover:bg-gray-800 hover:text-gray-300" 185 onClick={() => setActiveSession(null)} 186 title="New session" 187 > 188 + 189 </button> 190 </div> 191 {sessions.length === 0 && ( 192 <p className="text-sm text-gray-600">No sessions yet</p> 193 )} 194 {sessions.map((s) => ( 195 <SessionItem 196 key={s.id} 197 session={s} 198 isActive={s.id === activeSessionId} 199 onSelect={setActiveSession} 200 onRename={(id, name) => updateSession(id, { name })} 201 onDelete={deleteSession} 202 /> 203 ))} 204 </div> 205 ); 206 }