feat: cooking assistant can propose adding items to a shopping list

Second tool alongside createRecipe, same safety shape: addToShoppingList's
execute only validates/echoes input, no DB write. The proposal card lets
the user pick an existing list (fetched lazily) or name a new one, then
confirms through the exact two-call flow AddToShoppingListButton already
uses — POST /api/v1/shopping-lists (if new) then POST .../items — no new
persistence code path.

generateMealPlan intentionally not built as a tool: the existing
/api/v1/ai/meal-plan/generate endpoint generates and writes in one step
with a week/preferences input, not a specific plan payload, so it has no
"save this exact draft" entry point to confirm against without a larger
refactor. Documented as a known gap rather than forcing a weaker pattern.

v0.46.0
This commit is contained in:
Arnaud
2026-07-17 18:37:07 +02:00
parent 982d4e3264
commit 9eecdbac3c
11 changed files with 244 additions and 11 deletions
@@ -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 { ShoppingListProposalCard, type ShoppingListProposal } from "./shopping-list-proposal-card";
type RecipeProposal = {
title: string;
@@ -25,11 +26,15 @@ type RecipeProposal = {
steps: Array<{ instruction: string }>;
};
type ProposalStatus = "pending" | "creating" | "created" | "discarded";
type Message = {
role: "user" | "assistant";
content: string;
proposedRecipe?: RecipeProposal;
proposalStatus?: "pending" | "creating" | "created" | "discarded";
proposalStatus?: ProposalStatus;
proposedShoppingList?: ShoppingListProposal;
shoppingProposalStatus?: ProposalStatus;
};
type HistoryEntry = { role: "user" | "assistant"; content: string };
@@ -119,7 +124,12 @@ export function CookingAssistantPanel() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question, conversationId: conversationId ?? undefined }),
});
const data = await res.json() as { answer?: string; error?: string; proposedRecipe?: RecipeProposal };
const data = await res.json() as {
answer?: string;
error?: string;
proposedRecipe?: RecipeProposal;
proposedShoppingList?: ShoppingListProposal;
};
setMessages((prev) => [
...prev,
{
@@ -127,6 +137,8 @@ export function CookingAssistantPanel() {
content: data.answer ?? t("sorry"),
proposedRecipe: data.proposedRecipe,
proposalStatus: data.proposedRecipe ? "pending" : undefined,
proposedShoppingList: data.proposedShoppingList,
shoppingProposalStatus: data.proposedShoppingList ? "pending" : undefined,
},
]);
} catch {
@@ -182,6 +194,10 @@ export function CookingAssistantPanel() {
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, proposalStatus: "discarded" } : m)));
}
function handleShoppingProposalStatusChange(index: number, status: ProposalStatus) {
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, shoppingProposalStatus: status } : m)));
}
const suggestions = [
t("suggestion1"),
t("suggestion2"),
@@ -331,6 +347,14 @@ export function CookingAssistantPanel() {
)}
</div>
)}
{msg.proposedShoppingList && msg.shoppingProposalStatus && (
<ShoppingListProposalCard
proposal={msg.proposedShoppingList}
status={msg.shoppingProposalStatus}
onStatusChange={(status) => handleShoppingProposalStatusChange(i, status)}
/>
)}
</div>
))}
@@ -0,0 +1,155 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { ShoppingCart, Check, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
export type ShoppingListProposal = {
items: Array<{ rawName: string; quantity?: number; unit?: string }>;
suggestedListName?: string;
};
type ShoppingList = { id: string; name: string };
const NEW_LIST_VALUE = "__new__";
/**
* Renders a pending addToShoppingList tool proposal — lets the user pick an
* existing list or name a new one, then confirms through the exact same
* two-endpoint flow AddToShoppingListButton already uses (create list if
* needed, then POST items) rather than a new code path.
*/
export function ShoppingListProposalCard({
proposal,
status,
onStatusChange,
}: {
proposal: ShoppingListProposal;
status: "pending" | "creating" | "created" | "discarded";
onStatusChange: (status: "pending" | "creating" | "created" | "discarded") => void;
}) {
const t = useTranslations("recipe");
const tShopping = useTranslations("shoppingLists");
const tChat = useTranslations("cookingChat");
const [lists, setLists] = useState<ShoppingList[] | null>(null);
const [targetListId, setTargetListId] = useState<string>(NEW_LIST_VALUE);
const [newListName, setNewListName] = useState(proposal.suggestedListName || "Shopping List");
useEffect(() => {
if (status !== "pending" || lists !== null) return;
fetch("/api/v1/shopping-lists")
.then((res) => (res.ok ? (res.json() as Promise<ShoppingList[]>) : []))
.then((data) => {
setLists(data);
if (data.length > 0) setTargetListId(data[0]!.id);
})
.catch(() => setLists([]));
}, [status, lists]);
async function handleConfirm() {
onStatusChange("creating");
try {
let listId = targetListId;
if (listId === NEW_LIST_VALUE) {
const res = await fetch("/api/v1/shopping-lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newListName.trim() || "Shopping List" }),
});
if (!res.ok) throw new Error();
listId = (await res.json() as { id: string }).id;
}
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: proposal.items.map((item) => ({
rawName: item.rawName,
quantity: item.quantity !== undefined ? String(item.quantity) : undefined,
unit: item.unit,
})),
}),
});
if (!res.ok) throw new Error();
onStatusChange("created");
toast.success(tShopping("addedCount", { count: proposal.items.length }));
} catch {
onStatusChange("pending");
toast.error(t("shoppingListAddFailed"));
}
}
return (
<div className="ml-9 rounded-lg border bg-card p-3 space-y-2 max-w-[80%]">
<div className="flex items-center gap-2">
<ShoppingCart className="h-3.5 w-3.5 text-primary shrink-0" />
<span className="font-medium text-sm">{tChat("shoppingProposalTitle", { count: proposal.items.length })}</span>
</div>
<ul className="text-xs text-muted-foreground space-y-0.5">
{proposal.items.slice(0, 5).map((item, i) => (
<li key={i} className="truncate">
{item.quantity ? `${item.quantity} ` : ""}{item.unit ? `${item.unit} ` : ""}{item.rawName}
</li>
))}
{proposal.items.length > 5 && <li>{tChat("shoppingProposalMore", { count: proposal.items.length - 5 })}</li>}
</ul>
{status === "created" ? (
<p className="text-xs text-primary flex items-center gap-1"><Check className="h-3 w-3" />{tChat("proposalCreated")}</p>
) : status === "discarded" ? (
<p className="text-xs text-muted-foreground flex items-center gap-1"><X className="h-3 w-3" />{tChat("proposalDiscarded")}</p>
) : (
<>
{lists !== null && (
<div className="space-y-1.5">
<Select value={targetListId} onValueChange={(v) => setTargetListId(v ?? NEW_LIST_VALUE)}>
<SelectTrigger className="h-7 text-xs w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{lists.map((l) => (
<SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>
))}
<SelectItem value={NEW_LIST_VALUE}>{tShopping("modeNew")}</SelectItem>
</SelectContent>
</Select>
{targetListId === NEW_LIST_VALUE && (
<Input
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
placeholder={tShopping("listNamePlaceholder")}
className="h-7 text-xs"
maxLength={100}
/>
)}
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
className="h-7 text-xs gap-1"
disabled={status === "creating" || lists === null}
onClick={() => void handleConfirm()}
>
{status === "creating" ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
{tChat("shoppingProposalAddButton")}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
disabled={status === "creating"}
onClick={() => onStatusChange("discarded")}
>
{tChat("proposalDiscardButton")}
</Button>
</div>
</>
)}
</div>
);
}