Files
EvoScientist-WebUI/src/app/components/ThreadList.tsx
T

498 lines
16 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
import {
Loader2,
MessageSquare,
Puzzle,
Search,
SquarePen,
X,
} from "lucide-react";
import { useQueryState } from "nuqs";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectGroup,
SelectLabel,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { ThreadItem } from "@/app/hooks/useThreads";
import { useThreads } from "@/app/hooks/useThreads";
type StatusFilter = "all" | "idle" | "busy" | "interrupted" | "error";
const GROUP_LABELS = {
interrupted: "Requiring Attention",
today: "Today",
yesterday: "Yesterday",
week: "This Week",
older: "Older",
} as const;
const STATUS_COLORS: Record<ThreadItem["status"], string> = {
idle: "bg-green-500",
busy: "bg-blue-500",
interrupted: "bg-orange-500",
error: "bg-red-600",
};
const STATUS_LABELS: Record<ThreadItem["status"], string> = {
idle: "Idle",
busy: "Busy",
interrupted: "Interrupted",
error: "Error",
};
function getThreadColor(status: ThreadItem["status"]): string {
return STATUS_COLORS[status] ?? "bg-gray-400";
}
function formatTime(date: Date, now = new Date()): string {
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
// Pin 24-hour time (h23) so the clock stays consistent regardless of the
// browser locale (e.g. en-US would otherwise render "06:55 PM"). The
// weekday/date below intentionally stay locale-aware.
return new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(date);
}
if (days === 1) return "Yesterday";
if (days < 7) {
return new Intl.DateTimeFormat(undefined, { weekday: "long" }).format(date);
}
return new Intl.DateTimeFormat(undefined, {
month: "2-digit",
day: "2-digit",
}).format(date);
}
function StatusFilterItem({
status,
label,
badge,
}: {
status: ThreadItem["status"];
label: string;
badge?: number;
}) {
return (
<span className="inline-flex items-center gap-2">
<span
className={cn(
"inline-block size-2 rounded-full",
getThreadColor(status)
)}
/>
{label}
{badge !== undefined && badge > 0 && (
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-red-600 px-1.5 py-0.5 text-xs font-bold leading-none text-white">
{badge}
</span>
)}
</span>
);
}
function ErrorState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<p className="text-sm text-red-600">Failed to load research</p>
<p className="mt-1 text-xs text-muted-foreground">{message}</p>
</div>
);
}
function LoadingState() {
return (
<div className="space-y-2 p-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton
key={i}
className="h-16 w-full"
/>
))}
</div>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<MessageSquare className="mb-2 h-12 w-12 text-gray-300" />
<p className="text-sm text-muted-foreground">No research yet</p>
</div>
);
}
interface ThreadListProps {
onThreadSelect: (id: string) => void;
onClose?: () => void;
onNewChat?: () => void;
onMutateReady?: (mutate: () => void) => void;
onInterruptCountChange?: (count: number) => void;
}
export function ThreadList({
onThreadSelect,
onClose,
onNewChat,
onMutateReady,
onInterruptCountChange,
}: ThreadListProps) {
const [currentThreadId, setThreadId] = useQueryState("threadId");
const [view, setView] = useQueryState("view");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [search, setSearch] = useState("");
const threads = useThreads({
status: statusFilter === "all" ? undefined : statusFilter,
limit: 20,
});
const flattened = useMemo(() => {
return threads.data?.flat() ?? [];
}, [threads.data]);
// Client-side filter of the loaded threads by title.
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return flattened;
return flattened.filter((t) => t.title.toLowerCase().includes(q));
}, [flattened, search]);
const isLoadingMore =
threads.size > 0 && threads.data?.[threads.size - 1] == null;
const isEmpty = threads.data?.at(0)?.length === 0;
const isReachingEnd = isEmpty || (threads.data?.at(-1)?.length ?? 0) < 20;
// Group threads by time and status
const grouped = useMemo(() => {
const now = new Date();
const groups: Record<keyof typeof GROUP_LABELS, ThreadItem[]> = {
interrupted: [],
today: [],
yesterday: [],
week: [],
older: [],
};
filtered.forEach((thread) => {
if (thread.status === "interrupted") {
groups.interrupted.push(thread);
return;
}
const diff = now.getTime() - thread.updatedAt.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
groups.today.push(thread);
} else if (days === 1) {
groups.yesterday.push(thread);
} else if (days < 7) {
groups.week.push(thread);
} else {
groups.older.push(thread);
}
});
return groups;
}, [filtered]);
const interruptedCount = useMemo(() => {
return flattened.filter((t) => t.status === "interrupted").length;
}, [flattened]);
// Expose thread list revalidation to parent component
// Use refs to create a stable callback that always calls the latest mutate function
const onMutateReadyRef = useRef(onMutateReady);
const mutateRef = useRef(threads.mutate);
useEffect(() => {
onMutateReadyRef.current = onMutateReady;
}, [onMutateReady]);
useEffect(() => {
mutateRef.current = threads.mutate;
}, [threads.mutate]);
const mutateFn = useCallback(() => {
mutateRef.current();
}, []);
useEffect(() => {
onMutateReadyRef.current?.(mutateFn);
// Only run once on mount to avoid infinite loops
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Notify parent of interrupt count changes
useEffect(() => {
onInterruptCountChange?.(interruptedCount);
}, [interruptedCount, onInterruptCountChange]);
return (
<div className="absolute inset-0 flex flex-col">
<button
type="button"
onClick={() => {
if (onNewChat) {
onNewChat();
} else {
setThreadId(null);
setView(null);
}
onClose?.();
}}
className="flex flex-shrink-0 items-center gap-3 border-b border-border p-4 text-left text-sm font-medium transition-colors hover:bg-accent"
>
<SquarePen
className="size-4"
aria-hidden="true"
/>
New Chat
</button>
<button
type="button"
onClick={() => {
setView("skills");
onClose?.();
}}
className={cn(
"flex flex-shrink-0 items-center gap-3 border-b border-border p-4 text-left text-sm font-medium transition-colors hover:bg-accent",
view === "skills" && "bg-accent"
)}
>
<Puzzle
className="size-4"
aria-hidden="true"
/>
Research Skills
</button>
{view !== "skills" && (
<div className="flex-shrink-0 border-b border-border p-3">
<div className="relative">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
type="search"
name="research-search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search research…"
aria-label="Search research"
autoComplete="off"
spellCheck={false}
className="w-full rounded-md border border-border bg-background py-1.5 pl-8 pr-8 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
{search && (
<button
type="button"
aria-label="Clear research search"
onClick={() => setSearch("")}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X
className="size-3.5"
aria-hidden="true"
/>
</button>
)}
</div>
</div>
)}
{/* Header with title and status filter */}
<div className="grid flex-shrink-0 grid-cols-[1fr_auto] items-center gap-3 border-b border-border p-4">
<h2 className="text-lg font-semibold tracking-tight">Research</h2>
<div className="flex items-center gap-2">
<Select
value={statusFilter}
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
>
<SelectTrigger className="w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="all">All statuses</SelectItem>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Active</SelectLabel>
<SelectItem value="idle">
<StatusFilterItem
status="idle"
label="Idle"
/>
</SelectItem>
<SelectItem value="busy">
<StatusFilterItem
status="busy"
label="Busy"
/>
</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Attention</SelectLabel>
<SelectItem value="interrupted">
<StatusFilterItem
status="interrupted"
label="Interrupted"
badge={interruptedCount}
/>
</SelectItem>
<SelectItem value="error">
<StatusFilterItem
status="error"
label="Error"
/>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{onClose && (
<button
type="button"
aria-label="Close research"
onClick={onClose}
className="rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X
className="size-4"
aria-hidden="true"
/>
</button>
)}
</div>
</div>
<ScrollArea className="h-0 flex-1">
{threads.error && <ErrorState message={threads.error.message} />}
{!threads.error && !threads.data && threads.isLoading && (
<LoadingState />
)}
{!threads.error && !threads.isLoading && isEmpty && <EmptyState />}
{!threads.error &&
!isEmpty &&
search.trim() &&
filtered.length === 0 && (
<div className="flex flex-col items-center justify-center p-8 text-center">
<p className="text-sm text-muted-foreground">
No research matches your search.
</p>
</div>
)}
{!threads.error && !isEmpty && filtered.length > 0 && (
<div className="box-border w-full max-w-full overflow-hidden p-2">
{(
Object.keys(GROUP_LABELS) as Array<keyof typeof GROUP_LABELS>
).map((group) => {
const groupThreads = grouped[group];
if (groupThreads.length === 0) return null;
return (
<div
key={group}
className="mb-4"
>
<h4 className="m-0 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{GROUP_LABELS[group]}
</h4>
<div className="flex flex-col gap-1">
{groupThreads.map((thread) => (
<button
key={thread.id}
type="button"
onClick={() => onThreadSelect(thread.id)}
className={cn(
"grid w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors duration-200",
"hover:bg-accent",
currentThreadId === thread.id
? "border border-primary bg-accent hover:bg-accent"
: "border border-transparent bg-transparent"
)}
aria-current={currentThreadId === thread.id}
>
<div className="min-w-0 flex-1">
{/* Title + Timestamp Row */}
<div className="mb-1 flex items-center justify-between">
<h3 className="truncate text-sm font-semibold">
{thread.title}
</h3>
<span className="ml-2 flex-shrink-0 text-xs tabular-nums text-muted-foreground">
{formatTime(thread.updatedAt)}
</span>
</div>
{/* Description + Status Row */}
<div className="flex items-center justify-between">
<p className="flex-1 truncate text-sm text-muted-foreground">
{thread.description}
</p>
<div className="ml-2 flex-shrink-0">
<span
role="img"
aria-label={`Status: ${
STATUS_LABELS[thread.status]
}`}
title={`Status: ${
STATUS_LABELS[thread.status]
}`}
className={cn(
"h-2 w-2 rounded-full",
getThreadColor(thread.status)
)}
/>
</div>
</div>
</div>
</button>
))}
</div>
</div>
);
})}
{!isReachingEnd && (
<div className="flex justify-center py-4">
<Button
variant="outline"
size="sm"
onClick={() => threads.setSize(threads.size + 1)}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading…
</>
) : (
"Load More"
)}
</Button>
</div>
)}
</div>
)}
</ScrollArea>
</div>
);
}