291 lines
9.9 KiB
TypeScript
291 lines
9.9 KiB
TypeScript
"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<TodoAiMessage[]>([]);
|
|
const [input, setInput] = useState("");
|
|
const [sending, setSending] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(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<TodoAiProposal[] | null>(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<TodoAiProposal>) {
|
|
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 (
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
<DialogContent className="flex max-h-[80vh] flex-col sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Sparkles className="size-4 text-primary" />
|
|
Add using AI — {group.title}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{pendingTodos ? (
|
|
<>
|
|
<div className="flex-1 space-y-3 overflow-y-auto">
|
|
{pendingTodos.map((todo, index) => (
|
|
<div key={index} className="space-y-2 rounded-md border p-3">
|
|
<div className="flex items-start gap-2">
|
|
<div className="flex-1 space-y-1">
|
|
<Label htmlFor={`ai-todo-title-${index}`} className="sr-only">
|
|
Title
|
|
</Label>
|
|
<Input
|
|
id={`ai-todo-title-${index}`}
|
|
value={todo.title}
|
|
maxLength={TITLE_MAX}
|
|
onChange={(e) => updatePendingTodo(index, { title: e.target.value })}
|
|
/>
|
|
<p className="text-right text-xs text-muted-foreground">
|
|
{todo.title.length}/{TITLE_MAX}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0"
|
|
onClick={() => removePendingTodo(index)}
|
|
aria-label={`Remove "${todo.title}"`}
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
</div>
|
|
<Textarea
|
|
value={todo.details ?? ""}
|
|
onChange={(e) => updatePendingTodo(index, { details: e.target.value })}
|
|
placeholder="Details (optional)"
|
|
rows={2}
|
|
className="resize-none"
|
|
/>
|
|
</div>
|
|
))}
|
|
{pendingTodos.length === 0 && (
|
|
<p className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
|
All proposed to-dos were removed. Keep chatting to generate more.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={handleDiscard} disabled={saving}>
|
|
Keep chatting
|
|
</Button>
|
|
<Button onClick={handleAccept} disabled={saving || pendingTodos.length === 0}>
|
|
{saving
|
|
? "Adding…"
|
|
: `Add ${pendingTodos.length} to-do${pendingTodos.length === 1 ? "" : "s"}`}
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex-1 space-y-3 overflow-y-auto">
|
|
{messages.length === 0 ? (
|
|
<p className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
|
Tell me about the to-dos you need — I'll ask follow-ups if I need more
|
|
detail.
|
|
</p>
|
|
) : (
|
|
messages.map((message, index) => (
|
|
<div
|
|
key={index}
|
|
className={cn(
|
|
"max-w-[85%] rounded-lg px-3 py-2 text-sm",
|
|
message.role === "user"
|
|
? "ml-auto bg-primary text-primary-foreground"
|
|
: "bg-muted text-foreground"
|
|
)}
|
|
>
|
|
{message.content}
|
|
</div>
|
|
))
|
|
)}
|
|
{sending && (
|
|
<div className="max-w-[85%] rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
|
|
Thinking…
|
|
</div>
|
|
)}
|
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-end gap-2">
|
|
<Textarea
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
}}
|
|
placeholder={listening ? "Listening…" : "Type or use the microphone…"}
|
|
rows={2}
|
|
className="flex-1 resize-none"
|
|
disabled={sending}
|
|
/>
|
|
<div className="flex flex-col gap-2">
|
|
{micSupported && (
|
|
<Button
|
|
type="button"
|
|
variant={listening ? "destructive" : "outline"}
|
|
size="icon"
|
|
onClick={listening ? stop : start}
|
|
disabled={sending}
|
|
aria-label={listening ? "Stop recording" : "Record voice input"}
|
|
>
|
|
{listening ? <Square className="size-4" /> : <Mic className="size-4" />}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
onClick={handleSend}
|
|
disabled={sending || !input.trim()}
|
|
aria-label="Send"
|
|
>
|
|
<Send className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-muted-foreground"
|
|
onClick={handleGenerateNow}
|
|
disabled={sending || messages.length === 0}
|
|
>
|
|
Generate now, using what I've said so far
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|