Files
Epicure/apps/web/components/recipe/conversation-sidebar.tsx
T
Arnaud 1ee3ec70ba feat: ChatGPT/Claude-style persistent sidebar for desktop fullscreen chat (v0.57.0)
Cooking assistant's fullscreen mode gets a real left sidebar (always-
visible conversation list, hover-reveal rename/delete) on desktop
(md+), replacing the small dropdown menu that made sense in the narrow
420px drawer but not in a full-viewport layout. Mobile fullscreen and
the normal drawer keep the dropdown (ConversationMenu) — no room for a
persistent sidebar there.

Extracted the shared fetch/rename/delete/create logic into
use-conversation-list.ts so the dropdown (ConversationMenu) and the new
sidebar (ConversationSidebar) can't silently diverge — both are thin
render layers over the same hook. ConversationMenu gained an optional
className prop so the panel can hide it with `md:hidden` specifically
when the sidebar is already covering that job.

Per-recipe chat is untouched — it's single-threaded by design and never
used either of these components.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 09:04:07 +02:00

167 lines
6.0 KiB
TypeScript

"use client";
import { useEffect } from "react";
import { Plus, Pencil, Trash2, Check, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useLocale } from "@/lib/i18n/provider";
import { useConversationList } from "./use-conversation-list";
import type { Conversation } from "./conversation-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
/**
* Persistent conversation list for the desktop-fullscreen chat layout —
* ChatGPT/Claude-style, always visible rather than a dropdown (that's
* ConversationMenu, used in the drawer/mobile layout instead). Shares its
* fetch/rename/delete logic with ConversationMenu via useConversationList.
*/
export function ConversationSidebar({
activeId,
onSelect,
onCreated,
onDeletedActive,
className,
}: {
activeId: string | null;
onSelect: (id: string) => void;
onCreated: (conversation: Conversation) => void;
onDeletedActive: () => void;
className?: string;
}) {
const { locale } = useLocale();
const {
t,
conversations,
loading,
load,
create,
renamingId,
renameValue,
setRenameValue,
startRename,
cancelRename,
submitRename,
deleteTargetId,
setDeleteTargetId,
confirmDelete,
} = useConversationList(onDeletedActive, activeId);
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps -- load() is idempotent (guarded by its own `loaded` flag), only needs to run once on mount
}, []);
async function handleCreate() {
const created = await create();
if (created) onCreated(created);
}
return (
<div className={cn("flex flex-col border-r bg-muted/30", className)}>
<div className="p-2 border-b shrink-0">
<button
type="button"
onClick={() => void handleCreate()}
className="w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-sm border bg-background hover:bg-accent transition-colors text-left"
>
<Plus className="h-3.5 w-3.5" />
{t("newConversation")}
</button>
</div>
<div className="overflow-y-auto flex-1 p-1.5 space-y-0.5">
{loading && <p className="text-xs text-muted-foreground text-center py-4">{t("loading")}</p>}
{!loading && conversations.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">{t("empty")}</p>
)}
{!loading && conversations.map((c) => (
<div
key={c.id}
className={cn(
"group flex items-center gap-1 px-2 py-2 rounded-md text-sm",
c.id === activeId ? "bg-accent" : "hover:bg-accent/50"
)}
>
{renamingId === c.id ? (
<>
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submitRename();
if (e.key === "Escape") cancelRename();
}}
maxLength={100}
className="flex-1 min-w-0 bg-transparent border-b border-primary text-sm outline-none"
/>
<button type="button" onClick={() => void submitRename()} className="p-1 rounded-md hover:bg-muted shrink-0" aria-label={t("renameSave")}>
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={cancelRename} className="p-1 rounded-md hover:bg-muted shrink-0" aria-label={t("renameCancel")}>
<X className="h-3.5 w-3.5" />
</button>
</>
) : (
<>
<button
type="button"
onClick={() => onSelect(c.id)}
className="flex-1 min-w-0 text-left truncate"
>
<span className="truncate block">{c.title || t("untitled")}</span>
<span className="text-xs text-muted-foreground">
{new Date(c.updatedAt).toLocaleDateString(locale, { month: "short", day: "numeric" })}
</span>
</button>
<button
type="button"
onClick={() => startRename(c)}
className="p-1 rounded-md hover:bg-muted shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
aria-label={t("renameAriaLabel")}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setDeleteTargetId(c.id)}
className="p-1 rounded-md hover:bg-muted shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
aria-label={t("deleteAriaLabel")}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
))}
</div>
<AlertDialog open={!!deleteTargetId} onOpenChange={(v) => { if (!v) setDeleteTargetId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("deleteConfirmDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("deleteCancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => { void confirmDelete(); }}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("deleteConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}