diff --git a/@applications/web/src/features/history/SessionHistoryPanel.tsx b/@applications/web/src/features/history/SessionHistoryPanel.tsx index 42180bb..e4f4ef2 100644 --- a/@applications/web/src/features/history/SessionHistoryPanel.tsx +++ b/@applications/web/src/features/history/SessionHistoryPanel.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState, useCallback } from 'react'; -import type { ReactElement } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; +import type { ReactElement, KeyboardEvent } from 'react'; import styled from '@lilith/ui-styled-components'; import { AnimatePresence, motion } from '@lilith/ui-motion'; @@ -9,6 +9,8 @@ export interface SessionSummary { last_activity_at: string; message_count: number; preview: string | null; + title: string | null; + title_is_manual: boolean; } export interface SessionHistoryPanelProps { @@ -83,16 +85,13 @@ const List = styled.div` overflow-y: auto; `; -const SessionRow = styled.button<{ $active: boolean }>` - width: 100%; +const SessionRow = styled.div<{ $active: boolean }>` display: flex; flex-direction: column; gap: 4px; - padding: 14px 20px; - border: none; + padding: 12px 20px; border-bottom: 1px solid #1a1a2e; background: ${({ $active }) => ($active ? '#1a1a2e' : 'transparent')}; - text-align: left; cursor: pointer; &:active { @@ -104,18 +103,34 @@ const SessionRow = styled.button<{ $active: boolean }>` } `; +const SessionTop = styled.div` + display: flex; + align-items: center; + gap: 6px; +`; + +const SessionTitle = styled.span` + font-size: 15px; + color: #e2e8f0; + font-weight: 500; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const SessionDate = styled.span` + font-size: 12px; + color: #4a5568; + flex-shrink: 0; +`; + const SessionMeta = styled.div` display: flex; align-items: center; justify-content: space-between; `; -const SessionDate = styled.span` - font-size: 13px; - color: #e2e8f0; - font-weight: 500; -`; - const SessionCount = styled.span` font-size: 12px; color: #4a5568; @@ -140,7 +155,38 @@ const ActiveBadge = styled.span` background: rgba(85, 60, 154, 0.15); border-radius: 4px; padding: 2px 6px; - margin-left: 8px; + flex-shrink: 0; +`; + +const EditButton = styled.button` + background: none; + border: none; + color: #4a5568; + cursor: pointer; + padding: 2px 4px; + display: flex; + align-items: center; + border-radius: 4px; + flex-shrink: 0; + + &:hover { + color: #a0aec0; + background: rgba(255, 255, 255, 0.05); + } +`; + +const TitleInput = styled.input` + flex: 1; + background: #1a1a2e; + border: 1px solid #553c9a; + border-radius: 6px; + color: #e2e8f0; + font-size: 15px; + font-weight: 500; + padding: 2px 8px; + outline: none; + font-family: inherit; + min-width: 0; `; const EmptyState = styled.div` @@ -163,14 +209,103 @@ function formatDate(iso: string): string { const diffMs = now.getTime() - d.getTime(); const diffDays = Math.floor(diffMs / 86400000); - if (diffDays === 0) { - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } + if (diffDays === 0) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (diffDays === 1) return 'Yesterday'; - if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'long' }); + if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' }); return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); } +function SessionItem({ + session, + isActive, + apiBaseUrl, + onSelect, + onTitleUpdated, +}: { + session: SessionSummary; + isActive: boolean; + apiBaseUrl: string; + onSelect: () => void; + onTitleUpdated: (sessionId: string, title: string) => void; +}): ReactElement { + const [editing, setEditing] = useState(false); + const [inputValue, setInputValue] = useState(''); + const inputRef = useRef(null); + + const displayTitle = session.title ?? session.preview?.slice(0, 40) ?? 'Untitled conversation'; + + const startEdit = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setInputValue(session.title ?? ''); + setEditing(true); + setTimeout(() => inputRef.current?.focus(), 0); + }, [session.title]); + + const commitEdit = useCallback(async () => { + const trimmed = inputValue.trim(); + setEditing(false); + if (!trimmed || trimmed === session.title) return; + + try { + await fetch(`${apiBaseUrl}/session/${session.session_id}/title`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: trimmed }), + }); + onTitleUpdated(session.session_id, trimmed); + } catch { + // non-fatal + } + }, [inputValue, session.title, session.session_id, apiBaseUrl, onTitleUpdated]); + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Enter') void commitEdit(); + if (e.key === 'Escape') setEditing(false); + }, [commitEdit]); + + return ( + + + {editing ? ( + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => void commitEdit()} + onClick={(e) => e.stopPropagation()} + placeholder="Conversation name…" + maxLength={255} + /> + ) : ( + <> + {displayTitle} + {isActive && current} + + )} + {!editing && ( + + + + + + )} + {formatDate(session.last_activity_at)} + + + {!session.title && session.preview ? ( + {session.preview} + ) : ( + + )} + + {session.message_count} {session.message_count === 1 ? 'msg' : 'msgs'} + + + + ); +} + export function SessionHistoryPanel({ open, onClose, @@ -189,8 +324,8 @@ export function SessionHistoryPanel({ fetch(`${apiBaseUrl}/session`) .then((r) => r.json()) - .then((data: unknown) => { - if (!cancelled) setSessions(Array.isArray(data) ? (data as SessionSummary[]) : []); + .then((data: SessionSummary[]) => { + if (!cancelled) setSessions(data); }) .catch(() => { if (!cancelled) setSessions([]); @@ -211,6 +346,14 @@ export function SessionHistoryPanel({ onClose(); }, [onSwitchSession, onClose]); + const handleTitleUpdated = useCallback((sessionId: string, title: string) => { + setSessions((prev) => + prev.map((s) => + s.session_id === sessionId ? { ...s, title, title_is_manual: true } : s, + ), + ); + }, []); + return ( {open && ( @@ -242,29 +385,16 @@ export function SessionHistoryPanel({ No past conversations )} - {!loading && sessions.map((s) => { - const isActive = s.session_id === currentSessionId; - return ( - handleSelect(s.session_id)} - > - - - {formatDate(s.last_activity_at)} - {isActive && current} - - - {s.message_count} {s.message_count === 1 ? 'message' : 'messages'} - - - {s.preview && ( - {s.preview} - )} - - ); - })} + {!loading && sessions.map((s) => ( + handleSelect(s.session_id)} + onTitleUpdated={handleTitleUpdated} + /> + ))}