import { useState, useEffect, useCallback, useMemo } from 'react'; import { Search, Clock, RefreshCw } from 'lucide-react'; import type { ACPClient } from '../src/acp/client'; import type { AgentSessionInfo } from '../src/acp/types'; import { Input } from './ui/input'; import { ScrollArea } from './ui/scroll-area'; import { Button } from './ui/button'; import { cn } from '../src/lib/utils'; // Reference: Zed's TimeBucket in thread_history.rs type TimeBucket = 'today' | 'yesterday' | 'thisWeek' | 'pastWeek' | 'all'; // Reference: Zed's Display impl for TimeBucket const BUCKET_LABELS: Record = { today: 'Today', yesterday: 'Yesterday', thisWeek: 'This Week', pastWeek: 'Past Week', all: 'All', // Zed uses "All", not "Older" }; // Reference: Zed's TimeBucket::from_dates (line 1028-1051) // Rust's IsoWeek includes year, so we need to compare both year and week number function getTimeBucket(date: Date): TimeBucket { const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); const entryDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); if (entryDate.getTime() === today.getTime()) return 'today'; if (entryDate.getTime() === yesterday.getTime()) return 'yesterday'; // This week: same ISO week AND year const todayIsoWeek = getISOWeekYear(today); const entryIsoWeek = getISOWeekYear(entryDate); if (todayIsoWeek.year === entryIsoWeek.year && todayIsoWeek.week === entryIsoWeek.week) { return 'thisWeek'; } // Past week: (reference - 7days).iso_week() const lastWeekDate = new Date(today); lastWeekDate.setDate(lastWeekDate.getDate() - 7); const lastWeekIsoWeek = getISOWeekYear(lastWeekDate); if (lastWeekIsoWeek.year === entryIsoWeek.year && lastWeekIsoWeek.week === entryIsoWeek.week) { return 'pastWeek'; } return 'all'; } // Returns ISO week number AND ISO week year (important for year boundaries) function getISOWeekYear(date: Date): { week: number; year: number } { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); return { week, year: d.getUTCFullYear() }; // ISO week year, not calendar year } // Reference: Zed's formatted_time in HistoryEntryElement (line 904-921) // Exact format: Xd, Xh ago, Xm ago, Just now, Unknown function formatRelativeTime(date: Date | null): string { if (!date) return 'Unknown'; // Zed uses "Unknown" for missing updatedAt const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffMinutes = Math.floor(diffMs / (1000 * 60)); if (diffDays > 0) return `${diffDays}d`; if (diffHours > 0) return `${diffHours}h ago`; if (diffMinutes > 0) return `${diffMinutes}m ago`; return 'Just now'; } interface ThreadHistoryProps { client: ACPClient; // Returns Promise to allow loading state tracking; resolves when session is loaded onSelectSession: (session: AgentSessionInfo) => void | Promise; } interface GroupedSessions { bucket: TimeBucket; sessions: AgentSessionInfo[]; } export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) { const [sessions, setSessions] = useState([]); const [searchQuery, setSearchQuery] = useState(''); // Start with isLoading=true to prevent flash of "no threads" message const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [selectedIndex, setSelectedIndex] = useState(0); // Track which session is currently being loaded to show loading state and prevent double-clicks const [loadingSessionId, setLoadingSessionId] = useState(null); // Check if session history is supported const supportsHistory = client.supportsSessionHistory; const loadSessions = useCallback(async () => { if (!client.supportsSessionList) { setError('Session list not supported by this agent'); setIsLoading(false); return; } setIsLoading(true); setError(null); try { const response = await client.listSessions(); setSessions(response.sessions); } catch (err) { setError((err as Error).message); } finally { setIsLoading(false); } }, [client]); useEffect(() => { if (supportsHistory) { loadSessions(); } else { // Not supported, clear loading state setIsLoading(false); } }, [supportsHistory, loadSessions]); // Filter and group sessions // Reference: Zed's add_list_separators and filter_search_results const groupedSessions = useMemo((): GroupedSessions[] => { let filtered = sessions; // Simple search filter (Zed uses fuzzy matching, we use substring for simplicity) if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); filtered = sessions.filter( s => s.title?.toLowerCase().includes(query) || s.sessionId.toLowerCase().includes(query), ); } // Sort by updatedAt descending (most recent first) // Zed expects the API to return sorted data, but we ensure it client-side const sorted = [...filtered].sort((a, b) => { const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0; const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0; return dateB - dateA; // Descending }); // Group by time bucket (preserving sort order within each bucket) const groups = new Map(); for (const session of sorted) { const date = session.updatedAt ? new Date(session.updatedAt) : new Date(0); const bucket = getTimeBucket(date); if (!groups.has(bucket)) groups.set(bucket, []); groups.get(bucket)!.push(session); } // Return in chronological bucket order const bucketOrder: TimeBucket[] = ['today', 'yesterday', 'thisWeek', 'pastWeek', 'all']; return bucketOrder.filter(b => groups.has(b)).map(bucket => ({ bucket, sessions: groups.get(bucket)! })); }, [sessions, searchQuery]); const handleSelectSession = useCallback( async (session: AgentSessionInfo) => { // Prevent double-clicks while loading if (loadingSessionId) return; setLoadingSessionId(session.sessionId); try { await onSelectSession(session); } finally { setLoadingSessionId(null); } }, [onSelectSession, loadingSessionId], ); if (!supportsHistory) { return (

Session history is not supported by this agent.

); } const flatItems = groupedSessions.flatMap(g => g.sessions); return (
{/* Search header - Reference: Zed's search_editor */}
setSearchQuery(e.target.value)} className="h-8 border-0 focus-visible:ring-0 shadow-none" />
{/* Session list */} {error &&
{error}
} {!error && isLoading && sessions.length === 0 && (

Loading threads...

)} {!error && !isLoading && sessions.length === 0 && (

You don't have any past threads yet.

)} {!error && sessions.length > 0 && groupedSessions.length === 0 && (

No threads match your search.

)} {/* p-2 ensures rounded corners of buttons are not clipped */}
{groupedSessions.map((group, groupIndex) => (
{/* Bucket separator - Reference: Zed's BucketSeparator */}
0 && 'pt-3')}> {BUCKET_LABELS[group.bucket]}
{/* Session entries */} {group.sessions.map(session => { const globalIdx = flatItems.indexOf(session); const isSelected = globalIdx === selectedIndex; const isLoadingThis = loadingSessionId === session.sessionId; const isAnyLoading = loadingSessionId !== null; const date = session.updatedAt ? new Date(session.updatedAt) : null; return ( ); })}
))}
); }