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 ( ); })}
))}
); }