mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 18:04:45 +10:00
221 lines
7.1 KiB
TypeScript
221 lines
7.1 KiB
TypeScript
import type { RouterOutput } from "@/libs/orpc/client";
|
|
import { t } from "@lingui/core/macro";
|
|
import { useLingui } from "@lingui/react";
|
|
import { Trans } from "@lingui/react/macro";
|
|
import {
|
|
ArchiveIcon,
|
|
ArrowLeftIcon,
|
|
ChatCircleDotsIcon,
|
|
DotsThreeVerticalIcon,
|
|
PlusIcon,
|
|
TrashIcon,
|
|
} from "@phosphor-icons/react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Link, useNavigate } from "@tanstack/react-router";
|
|
import { useMemo } from "react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@reactive-resume/ui/components/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@reactive-resume/ui/components/dropdown-menu";
|
|
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
|
import { cn } from "@reactive-resume/utils/style";
|
|
import { useConfirm } from "@/hooks/use-confirm";
|
|
import { getOrpcErrorMessage } from "@/libs/error-message";
|
|
import { orpc } from "@/libs/orpc/client";
|
|
|
|
type AgentThreadSummary = RouterOutput["agent"]["threads"]["list"][number];
|
|
|
|
const RELATIVE_TIME_DIVISIONS: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
|
|
{ amount: 31_536_000_000, unit: "year" },
|
|
{ amount: 2_592_000_000, unit: "month" },
|
|
{ amount: 604_800_000, unit: "week" },
|
|
{ amount: 86_400_000, unit: "day" },
|
|
{ amount: 3_600_000, unit: "hour" },
|
|
{ amount: 60_000, unit: "minute" },
|
|
];
|
|
|
|
function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat) {
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
const diffMs = date.getTime() - Date.now();
|
|
const absMs = Math.abs(diffMs);
|
|
|
|
if (absMs < 60_000) return formatter.format(0, "second");
|
|
|
|
const division = RELATIVE_TIME_DIVISIONS.find((candidate) => absMs >= candidate.amount);
|
|
if (!division) return "";
|
|
|
|
return formatter.format(Math.round(diffMs / division.amount), division.unit);
|
|
}
|
|
|
|
function ThreadActions({ thread, activeThreadId }: { thread: AgentThreadSummary; activeThreadId: string | null }) {
|
|
const navigate = useNavigate();
|
|
const confirm = useConfirm();
|
|
const queryClient = useQueryClient();
|
|
const archiveMutation = useMutation(orpc.agent.threads.archive.mutationOptions());
|
|
const deleteMutation = useMutation(orpc.agent.threads.delete.mutationOptions());
|
|
const isArchived = thread.status === "archived";
|
|
|
|
const handleArchive = () => {
|
|
archiveMutation.mutate(
|
|
{ id: thread.id },
|
|
{
|
|
onSuccess: async () => {
|
|
await queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() });
|
|
if (activeThreadId === thread.id) {
|
|
await queryClient.invalidateQueries({
|
|
queryKey: orpc.agent.threads.get.queryKey({ input: { id: thread.id } }),
|
|
});
|
|
}
|
|
},
|
|
onError: (error) => toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to archive thread.` })),
|
|
},
|
|
);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
const confirmed = await confirm(t`Delete this agent thread?`, {
|
|
description: t`This action cannot be undone. Messages and thread attachments will be removed.`,
|
|
});
|
|
|
|
if (!confirmed) return;
|
|
|
|
deleteMutation.mutate(
|
|
{ id: thread.id },
|
|
{
|
|
onSuccess: async () => {
|
|
await queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() });
|
|
if (activeThreadId === thread.id) void navigate({ to: "/agent" });
|
|
},
|
|
onError: (error) => toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to delete thread.` })),
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
className="absolute end-1.5 top-2 opacity-60 transition-opacity hover:opacity-100 focus-visible:opacity-100 group-hover/thread:opacity-100 aria-expanded:opacity-100"
|
|
>
|
|
<DotsThreeVerticalIcon />
|
|
<span className="sr-only">
|
|
<Trans>Thread actions</Trans>
|
|
</span>
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="end">
|
|
{!isArchived ? (
|
|
<DropdownMenuItem disabled={archiveMutation.isPending} onClick={handleArchive}>
|
|
<ArchiveIcon />
|
|
<Trans>Archive</Trans>
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
<DropdownMenuItem variant="destructive" disabled={deleteMutation.isPending} onClick={() => void handleDelete()}>
|
|
<TrashIcon />
|
|
<Trans>Delete</Trans>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
function ThreadRow({ thread, activeThreadId }: { thread: AgentThreadSummary; activeThreadId: string | null }) {
|
|
const { i18n } = useLingui();
|
|
const relativeTimeFormatter = useMemo(
|
|
() => Reflect.construct(Intl.RelativeTimeFormat, [i18n.locale, { numeric: "auto" }]) as Intl.RelativeTimeFormat,
|
|
[i18n.locale],
|
|
);
|
|
const isActive = thread.id === activeThreadId;
|
|
const isArchived = thread.status === "archived";
|
|
const title = thread.title === thread.resumeName ? t`New thread` : thread.title;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"group/thread relative rounded-md transition-colors hover:bg-accent",
|
|
isActive && "bg-accent",
|
|
isArchived && "opacity-60",
|
|
)}
|
|
>
|
|
<Link
|
|
to="/agent/$threadId"
|
|
params={{ threadId: thread.id }}
|
|
className="block min-w-0 rounded-md px-3 py-2 pe-10 text-sm outline-hidden ring-ring focus-visible:ring-2"
|
|
>
|
|
<div className="truncate font-medium">{title}</div>
|
|
<div className="truncate text-muted-foreground text-xs">
|
|
{formatRelativeTime(thread.lastMessageAt, relativeTimeFormatter)}
|
|
</div>
|
|
</Link>
|
|
<ThreadActions thread={thread} activeThreadId={activeThreadId} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AgentThreadSidebar({
|
|
activeThreadId = null,
|
|
className,
|
|
}: {
|
|
activeThreadId?: string | null;
|
|
className?: string;
|
|
}) {
|
|
const { data: threads, isLoading } = useQuery(orpc.agent.threads.list.queryOptions());
|
|
|
|
return (
|
|
<aside className={cn("flex h-full min-h-0 flex-col border-e bg-muted/30", className)}>
|
|
<div className="flex h-14 shrink-0 items-center justify-between gap-3 border-b px-3">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<ChatCircleDotsIcon className="shrink-0" />
|
|
<div className="min-w-0 truncate font-semibold">
|
|
<Trans>Threads</Trans>
|
|
</div>
|
|
</div>
|
|
<Button size="icon-sm" variant="ghost" nativeButton={false} render={<Link to="/dashboard/resumes" />}>
|
|
<ArrowLeftIcon />
|
|
<span className="sr-only">
|
|
<Trans>Back to resumes</Trans>
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<div className="space-y-1 p-2">
|
|
<Button
|
|
variant="ghost"
|
|
className="mb-2 w-full justify-start border border-dashed bg-background/40 text-muted-foreground hover:text-foreground"
|
|
nativeButton={false}
|
|
render={<Link to="/agent/new" />}
|
|
>
|
|
<PlusIcon />
|
|
<Trans>New thread</Trans>
|
|
</Button>
|
|
|
|
{isLoading ? (
|
|
<div className="px-3 py-2 text-muted-foreground text-sm">
|
|
<Trans>Loading threads…</Trans>
|
|
</div>
|
|
) : null}
|
|
|
|
{threads?.length === 0 ? (
|
|
<div className="rounded-md border border-dashed p-3 text-muted-foreground text-sm">
|
|
<Trans>No threads yet.</Trans>
|
|
</div>
|
|
) : null}
|
|
|
|
{threads?.map((thread) => (
|
|
<ThreadRow key={thread.id} thread={thread} activeThreadId={activeThreadId} />
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</aside>
|
|
);
|
|
}
|