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>
))}