"use client"; import { useState } from "react"; import { toast } from "sonner"; import { Mic, Send, Sparkles, Square, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { useBoard } from "@/components/board/board-context"; import { useSpeechRecognition } from "@/hooks/use-speech-recognition"; import { converseAboutTodos, type TodoAiMessage, type TodoAiProposal } from "@/lib/actions/todo-ai"; import type { GroupDTO } from "@/types/board"; const TITLE_MAX = 20; export function TodoAiDialog({ group, open, onOpenChange, }: { group: GroupDTO; open: boolean; onOpenChange: (open: boolean) => void; }) { const { addTodos } = useBoard(); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); // Set once the model finalizes -- an editable review list the user has // to explicitly accept before any of it becomes real to-dos. const [pendingTodos, setPendingTodos] = useState(null); const { supported: micSupported, listening, start, stop } = useSpeechRecognition({ onFinalResult: (transcript) => setInput((prev) => (prev ? `${prev} ${transcript}` : transcript)), onError: setError, }); // Starts fresh every time it's opened -- a half-finished conversation // from last time would be confusing to resume out of context. function handleOpenChange(next: boolean) { if (!next) { stop(); setMessages([]); setInput(""); setError(null); setPendingTodos(null); } onOpenChange(next); } async function callAi(nextMessages: TodoAiMessage[], forceFinalize: boolean) { setError(null); setSending(true); const result = await converseAboutTodos( group.id, group.title, group.todos.map((t) => t.title), nextMessages, forceFinalize ); setSending(false); if ("error" in result) { setError(result.error); return; } if (result.action === "ask") { setMessages([...nextMessages, { role: "assistant", content: result.question }]); } else { setMessages(nextMessages); setPendingTodos(result.todos); } } async function handleSend() { const text = input.trim(); if (!text || sending) return; if (listening) stop(); const next: TodoAiMessage[] = [...messages, { role: "user", content: text }]; setMessages(next); setInput(""); await callAi(next, false); } async function handleGenerateNow() { if (sending || messages.length === 0) return; if (listening) stop(); await callAi(messages, true); } function updatePendingTodo(index: number, patch: Partial) { setPendingTodos((prev) => prev && prev.map((t, i) => (i === index ? { ...t, ...patch } : t))); } function removePendingTodo(index: number) { setPendingTodos((prev) => prev && prev.filter((_, i) => i !== index)); } function handleDiscard() { setPendingTodos(null); } async function handleAccept() { if (!pendingTodos || pendingTodos.length === 0) return; setSaving(true); const ok = await addTodos( group.id, group.categoryId, pendingTodos.map((t) => ({ title: t.title.trim(), details: t.details?.trim() || undefined, })) ); setSaving(false); if (ok) { toast.success(`Added ${pendingTodos.length} to-do${pendingTodos.length === 1 ? "" : "s"}.`); handleOpenChange(false); } } return ( Add using AI — {group.title} {pendingTodos ? ( <>
{pendingTodos.map((todo, index) => (
updatePendingTodo(index, { title: e.target.value })} />

{todo.title.length}/{TITLE_MAX}