498 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|