Files
Epicure/apps/web/components/recipe/use-conversation-list.ts
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

95 lines
2.9 KiB
TypeScript

import { useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import type { Conversation } from "./conversation-menu";
/** Shared fetch/rename/delete logic for the general assistant's conversation
* list — used by both the dropdown (ConversationMenu, narrow/mobile) and the
* persistent sidebar (desktop fullscreen) skins, so the two never diverge. */
export function useConversationList(onDeletedActive: () => void, activeId: string | null) {
const t = useTranslations("conversations");
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
async function load() {
if (loaded) return;
setLoaded(true);
setLoading(true);
try {
const res = await fetch("/api/v1/ai/conversations");
if (!res.ok) return;
const json = await res.json() as { data: Conversation[] };
setConversations(json.data);
} finally {
setLoading(false);
}
}
async function create(): Promise<Conversation | null> {
const res = await fetch("/api/v1/ai/conversations", { method: "POST" });
if (!res.ok) {
toast.error(t("createFailed"));
return null;
}
const created = await res.json() as Conversation;
setConversations((prev) => [created, ...prev]);
return created;
}
function startRename(conversation: Conversation) {
setRenamingId(conversation.id);
setRenameValue(conversation.title ?? "");
}
async function submitRename() {
if (!renamingId) return;
const id = renamingId;
const title = renameValue.trim() || null;
setRenamingId(null);
const res = await fetch(`/api/v1/ai/conversations/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (!res.ok) {
toast.error(t("renameFailed"));
return;
}
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)));
}
async function confirmDelete() {
const id = deleteTargetId;
setDeleteTargetId(null);
if (!id) return;
const res = await fetch(`/api/v1/ai/conversations/${id}`, { method: "DELETE" });
if (!res.ok) {
toast.error(t("deleteFailed"));
return;
}
setConversations((prev) => prev.filter((c) => c.id !== id));
if (id === activeId) onDeletedActive();
}
return {
t,
conversations,
loading,
load,
create,
renamingId,
renameValue,
setRenameValue,
startRename,
cancelRename: () => setRenamingId(null),
submitRename,
deleteTargetId,
setDeleteTargetId,
confirmDelete,
};
}