1ee3ec70ba
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>
95 lines
2.9 KiB
TypeScript
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,
|
|
};
|
|
}
|