feat(history-specific): Implement session history tracking and display in SessionHistoryPanel

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-03 09:18:01 -07:00
parent c6fe614b64
commit 2b4d71bff8

View file

@ -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>