diff --git a/CHANGELOG.md b/CHANGELOG.md index d107d24..4269637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.57.0 — 2026-07-20 09:20 + +### Added +- Cooking assistant, fullscreen on desktop, now has a persistent conversation sidebar on the left (like ChatGPT/Claude) instead of a dropdown menu. Mobile and the normal drawer view keep the dropdown. + ## 0.56.0 — 2026-07-20 09:10 ### Added diff --git a/apps/web/components/recipe/conversation-menu.tsx b/apps/web/components/recipe/conversation-menu.tsx index 3f2ddda..3a527a7 100644 --- a/apps/web/components/recipe/conversation-menu.tsx +++ b/apps/web/components/recipe/conversation-menu.tsx @@ -1,11 +1,10 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { useTranslations } from "next-intl"; import { List, Plus, Pencil, Trash2, Check, X } from "lucide-react"; -import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { useLocale } from "@/lib/i18n/provider"; +import { useConversationList } from "./use-conversation-list"; import { AlertDialog, AlertDialogAction, @@ -20,112 +19,71 @@ import { export type Conversation = { id: string; title: string | null; createdAt: string; updatedAt: string }; /** - * Lists, creates, renames, and deletes the general assistant's conversations - * — the homepage chat can hold more than one thread, unlike the per-recipe - * chat (which stays single-threaded, one conversation per recipe, by design). + * Dropdown skin for the general assistant's conversation list — a narrow + * popover, used in the drawer/mobile layout. The desktop-fullscreen layout + * uses ConversationSidebar (a persistent list) instead; both share their + * fetch/rename/delete logic via useConversationList so they can't diverge. + * The per-recipe chat stays single-threaded, one conversation per recipe, + * by design — it never uses either of these. */ export function ConversationMenu({ activeId, onSelect, onCreated, onDeletedActive, + className, }: { activeId: string | null; onSelect: (id: string) => void; onCreated: (conversation: Conversation) => void; onDeletedActive: () => void; + className?: string; }) { - const t = useTranslations("conversations"); const { locale } = useLocale(); const [open, setOpen] = useState(false); - const [conversations, setConversations] = useState([]); - const [loading, setLoading] = useState(false); - const [renamingId, setRenamingId] = useState(null); - const [renameValue, setRenameValue] = useState(""); - const [deleteTargetId, setDeleteTargetId] = useState(null); const containerRef = useRef(null); - const loadedRef = useRef(false); + const { + t, + conversations, + loading, + load, + create, + renamingId, + renameValue, + setRenameValue, + startRename, + cancelRename, + submitRename, + deleteTargetId, + setDeleteTargetId, + confirmDelete, + } = useConversationList(onDeletedActive, activeId); useEffect(() => { function handleClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); - setRenamingId(null); + cancelRename(); } } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - async function loadConversations() { - 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); - } - } + }, [cancelRename]); function handleToggle() { setOpen((v) => !v); - if (!loadedRef.current) { - loadedRef.current = true; - void loadConversations(); - } + void load(); } async function handleCreate() { - const res = await fetch("/api/v1/ai/conversations", { method: "POST" }); - if (!res.ok) { - toast.error(t("createFailed")); - return; - } - const created = await res.json() as Conversation; - setConversations((prev) => [created, ...prev]); + const created = await create(); + if (!created) return; onCreated(created); setOpen(false); } - 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 ( -
+
- diff --git a/apps/web/components/recipe/conversation-sidebar.tsx b/apps/web/components/recipe/conversation-sidebar.tsx new file mode 100644 index 0000000..fe3783f --- /dev/null +++ b/apps/web/components/recipe/conversation-sidebar.tsx @@ -0,0 +1,166 @@ +"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 ( +
+
+ +
+ +
+ {loading &&

{t("loading")}

} + {!loading && conversations.length === 0 && ( +

{t("empty")}

+ )} + {!loading && conversations.map((c) => ( +
+ {renamingId === c.id ? ( + <> + 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" + /> + + + + ) : ( + <> + + + + + )} +
+ ))} +
+ + { if (!v) setDeleteTargetId(null); }}> + + + {t("deleteConfirmTitle")} + {t("deleteConfirmDescription")} + + + {t("deleteCancel")} + { void confirmDelete(); }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t("deleteConfirm")} + + + + +
+ ); +} diff --git a/apps/web/components/recipe/cooking-assistant-panel.tsx b/apps/web/components/recipe/cooking-assistant-panel.tsx index 52f37be..3d47b44 100644 --- a/apps/web/components/recipe/cooking-assistant-panel.tsx +++ b/apps/web/components/recipe/cooking-assistant-panel.tsx @@ -12,6 +12,7 @@ import ReactMarkdown from "react-markdown"; import { cn } from "@/lib/utils"; import { ChatHistorySearch } from "./chat-history-search"; import { ConversationMenu, type Conversation } from "./conversation-menu"; +import { ConversationSidebar } from "./conversation-sidebar"; import { ShoppingListProposalCard, type ShoppingListProposal } from "./shopping-list-proposal-card"; type RecipeProposal = { @@ -224,10 +225,20 @@ export function CookingAssistantPanel() { + {fullscreen && ( + + )} +
@@ -244,7 +255,11 @@ export function CookingAssistantPanel() { {fullscreen ? : } + {/* On desktop fullscreen, ConversationSidebar (left) replaces this + dropdown — kept for mobile fullscreen and the normal drawer, + where there's no room for a persistent sidebar. */} +
diff --git a/apps/web/components/recipe/use-conversation-list.ts b/apps/web/components/recipe/use-conversation-list.ts new file mode 100644 index 0000000..7062c65 --- /dev/null +++ b/apps/web/components/recipe/use-conversation-list.ts @@ -0,0 +1,94 @@ +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([]); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const [deleteTargetId, setDeleteTargetId] = useState(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 { + 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, + }; +} diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 2ea126c..490b498 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.56.0"; +export const APP_VERSION = "0.57.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.57.0", + date: "2026-07-20 09:20", + added: [ + "Cooking assistant, fullscreen on desktop, now has a persistent conversation sidebar on the left (like ChatGPT/Claude) instead of a dropdown menu — switch chats without leaving the conversation you're reading. Mobile and the normal drawer view keep the dropdown, since there's no room for a sidebar there.", + ], + }, { version: "0.56.0", date: "2026-07-20 09:10", diff --git a/apps/web/package.json b/apps/web/package.json index 27572e0..2144c3e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.56.0", + "version": "0.57.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index a4490d6..c4e4173 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.56.0", + "version": "0.57.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",