feat(history-specific): ✨ Implement session history tracking and display in SessionHistoryPanel
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c6fe614b64
commit
2b4d71bff8
1 changed files with 173 additions and 43 deletions
|
|
@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') void commitEdit();
|
||||
if (e.key === 'Escape') setEditing(false);
|
||||
}, [commitEdit]);
|
||||
|
||||
return (
|
||||
<SessionRow $active={isActive} onClick={editing ? undefined : onSelect}>
|
||||
<SessionTop>
|
||||
{editing ? (
|
||||
<TitleInput
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => void commitEdit()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="Conversation name…"
|
||||
maxLength={255}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SessionTitle title={displayTitle}>{displayTitle}</SessionTitle>
|
||||
{isActive && <ActiveBadge>current</ActiveBadge>}
|
||||
</>
|
||||
)}
|
||||
{!editing && (
|
||||
<EditButton onClick={startEdit} aria-label="Rename conversation" title="Rename">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</svg>
|
||||
</EditButton>
|
||||
)}
|
||||
<SessionDate>{formatDate(session.last_activity_at)}</SessionDate>
|
||||
</SessionTop>
|
||||
<SessionMeta>
|
||||
{!session.title && session.preview ? (
|
||||
<SessionPreview>{session.preview}</SessionPreview>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<SessionCount>
|
||||
{session.message_count} {session.message_count === 1 ? 'msg' : 'msgs'}
|
||||
</SessionCount>
|
||||
</SessionMeta>
|
||||
</SessionRow>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
|
|
@ -242,29 +385,16 @@ export function SessionHistoryPanel({
|
|||
<EmptyState>No past conversations</EmptyState>
|
||||
)}
|
||||
|
||||
{!loading && sessions.map((s) => {
|
||||
const isActive = s.session_id === currentSessionId;
|
||||
return (
|
||||
<SessionRow
|
||||
key={s.session_id}
|
||||
$active={isActive}
|
||||
onClick={() => handleSelect(s.session_id)}
|
||||
>
|
||||
<SessionMeta>
|
||||
<SessionDate>
|
||||
{formatDate(s.last_activity_at)}
|
||||
{isActive && <ActiveBadge>current</ActiveBadge>}
|
||||
</SessionDate>
|
||||
<SessionCount>
|
||||
{s.message_count} {s.message_count === 1 ? 'message' : 'messages'}
|
||||
</SessionCount>
|
||||
</SessionMeta>
|
||||
{s.preview && (
|
||||
<SessionPreview>{s.preview}</SessionPreview>
|
||||
)}
|
||||
</SessionRow>
|
||||
);
|
||||
})}
|
||||
{!loading && sessions.map((s) => (
|
||||
<SessionItem
|
||||
key={s.session_id}
|
||||
session={s}
|
||||
isActive={s.session_id === currentSessionId}
|
||||
apiBaseUrl={apiBaseUrl}
|
||||
onSelect={() => handleSelect(s.session_id)}
|
||||
onTitleUpdated={handleTitleUpdated}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Sheet>
|
||||
</Overlay>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue