diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 7acc5d1..79ea0e5 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -5,6 +5,8 @@ import { prisma } from "@/lib/db"; import { SideNavProvider } from "@/components/nav/side-nav-provider"; import { SideNav } from "@/components/nav/side-nav"; import { ProjectsProvider } from "@/components/projects/projects-context"; +import { ScheduledPanelProvider } from "@/components/scheduled/scheduled-panel-provider"; +import { ScheduledPanel } from "@/components/scheduled/scheduled-panel"; export default async function AppLayout({ children }: { children: React.ReactNode }) { const session = await auth(); @@ -22,12 +24,15 @@ export default async function AppLayout({ children }: { children: React.ReactNod return ( - -
- -
{children}
-
-
+ + +
+ +
{children}
+ +
+
+
); } diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 484549f..92a85d8 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -2,13 +2,18 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { getBoard } from "@/lib/board"; +import { getAiSettingsView } from "@/lib/ai-settings"; import { KanbanBoard } from "@/components/board/kanban-board"; export default async function HomePage() { const session = await auth(); if (!session?.user) redirect("/login"); - const board = await getBoard({ userId: session.user.id, projectId: null }); + const [board, aiSettings] = await Promise.all([ + getBoard({ userId: session.user.id, projectId: null }), + getAiSettingsView(), + ]); + const aiConfigured = !!(aiSettings.apiUrl && aiSettings.model); - return ; + return ; } diff --git a/app/(app)/projects/[projectId]/page.tsx b/app/(app)/projects/[projectId]/page.tsx index 015ecae..35e784d 100644 --- a/app/(app)/projects/[projectId]/page.tsx +++ b/app/(app)/projects/[projectId]/page.tsx @@ -4,6 +4,7 @@ import { auth } from "@/auth"; import { prisma } from "@/lib/db"; import { projectAccessFilter } from "@/lib/access"; import { getBoard } from "@/lib/board"; +import { getAiSettingsView } from "@/lib/ai-settings"; import { KanbanBoard } from "@/components/board/kanban-board"; export default async function ProjectPage({ @@ -23,7 +24,18 @@ export default async function ProjectPage({ // user's -- no need to distinguish "not found" from "not yours". if (!project) notFound(); - const board = await getBoard({ projectId: project.id }); + const [board, aiSettings] = await Promise.all([ + getBoard({ projectId: project.id }), + getAiSettingsView(), + ]); + const aiConfigured = !!(aiSettings.apiUrl && aiSettings.model); - return ; + return ( + + ); } diff --git a/components/board/board-context.tsx b/components/board/board-context.tsx index ca96e47..14d2d96 100644 --- a/components/board/board-context.tsx +++ b/components/board/board-context.tsx @@ -19,6 +19,7 @@ import { } from "@/lib/actions/groups"; import { createTodo, + createTodos, updateTodo as updateTodoAction, toggleTodo as toggleTodoAction, deleteTodo as deleteTodoAction, @@ -27,6 +28,9 @@ import { updateGroupNote } from "@/lib/actions/notes"; interface BoardContextValue { categories: CategoryDTO[]; + // Whether the admin-configured AI provider has an API URL + model saved + // -- gates AI-powered actions like the "Add using AI" to-do button. + aiConfigured: boolean; addCategory: (name: string) => Promise; removeCategory: (categoryId: string) => Promise; reorderLanes: (orderedIds: string[]) => Promise; @@ -53,6 +57,11 @@ interface BoardContextValue { title: string, details?: string ) => Promise; + addTodos: ( + groupId: string, + categoryId: string, + todos: { title: string; details?: string }[] + ) => Promise; editTodo: ( todoId: string, groupId: string, @@ -73,6 +82,7 @@ const BoardContext = createContext(null); export function BoardProvider({ initialCategories, projectId, + aiConfigured, children, }: { initialCategories: CategoryDTO[]; @@ -81,6 +91,7 @@ export function BoardProvider({ // on are the two that operate over "all of this board's categories" // rather than a specific existing category/group/todo id. projectId?: string; + aiConfigured: boolean; children: React.ReactNode; }) { const [categories, setCategories] = useState(initialCategories); @@ -299,6 +310,20 @@ export function BoardProvider({ [updateGroupInState] ); + const addTodos = useCallback( + async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => { + try { + const created = await createTodos(groupId, todos); + updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] })); + return true; + } catch { + toast.error("Couldn't add to-dos. Try again."); + return false; + } + }, + [updateGroupInState] + ); + const editTodo = useCallback( async ( todoId: string, @@ -379,6 +404,7 @@ export function BoardProvider({ (null); const [editOpen, setEditOpen] = useState(false); - const [aiOpen, setAiOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); + const [todoAiOpen, setTodoAiOpen] = useState(false); const { attributes, @@ -79,6 +79,10 @@ export function GroupCard({ group }: { group: GroupDTO }) { isDark ? color.dark : color.light, isDark ? "dark" : "light" ); + // Un-checked to-do text: a brighter tint of the group's own color rather + // than the default (near-white in dark mode) foreground, so the group + // title reads as the most prominent thing on the card. + const todoTextColor = getBrighterColor(borderColor, isDark ? "dark" : "light"); // Only offer archiving once there's actually something done -- a group // with no to-dos yet, or with any still open, isn't "finished" yet. @@ -133,10 +137,6 @@ export function GroupCard({ group }: { group: GroupDTO }) { Edit - setAiOpen(true)}> - - Ask AI - setConfirmOpen(true)}> @@ -153,6 +153,7 @@ export function GroupCard({ group }: { group: GroupDTO }) { + )} + {canArchive && ( - - - {group.title} — Notes - + <> + + + + + {group.title} — Notes + - {editing ? ( - setDraft(v ?? "")} height={360} /> - ) : draft ? ( -
- -
- ) : ( -

- No notes yet. Click Edit to add some. -

- )} - - {editing ? ( - <> - - - + setDraft(v ?? "")} height={360} /> + ) : draft ? ( +
+ +
) : ( - +

+ No notes yet. Click Edit to add some. +

)} -
-
-
+ + + {editing ? ( + <> + + + + ) : ( + <> + + + + )} + +
+ + + + ); } diff --git a/components/board/todo-ai-dialog.tsx b/components/board/todo-ai-dialog.tsx new file mode 100644 index 0000000..ce1d35e --- /dev/null +++ b/components/board/todo-ai-dialog.tsx @@ -0,0 +1,290 @@ +"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} +

+
+ +
+