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