+ );
+}
diff --git a/components/board/group-card.tsx b/components/board/group-card.tsx
index 761f1d2..60a4368 100644
--- a/components/board/group-card.tsx
+++ b/components/board/group-card.tsx
@@ -6,6 +6,7 @@ import { CSS } from "@dnd-kit/utilities";
import { useTheme } from "next-themes";
import {
Archive,
+ ClipboardList,
GripVertical,
MoreVertical,
Pencil,
@@ -33,6 +34,7 @@ import { TodoCreatePopover } from "@/components/board/todo-create-popover";
import { TodoEditDialog } from "@/components/board/todo-edit-dialog";
import { TodoProgressPie } from "@/components/board/todo-progress-pie";
import { EditGroupDialog } from "@/components/board/edit-group-dialog";
+import { StatusUpdateDialog } from "@/components/board/status-update-dialog";
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
import type { GroupDTO, TodoDTO } from "@/types/board";
@@ -43,6 +45,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
const [editOpen, setEditOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [todoAiOpen, setTodoAiOpen] = useState(false);
+ const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
const {
attributes,
@@ -137,6 +140,12 @@ export function GroupCard({ group }: { group: GroupDTO }) {
Edit
+ {aiConfigured && (
+ setStatusUpdateOpen(true)}>
+
+ Status Update
+
+ )}
setConfirmOpen(true)}>
@@ -237,6 +246,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
+
+
{
+ if (open && !editing) {
+ setDraft(group.noteContent);
+ }
+ }, [group.noteContent, open, editing]);
+
async function handleSave() {
setPending(true);
const ok = await saveNote(group.id, group.categoryId, draft);
diff --git a/components/board/status-update-dialog.tsx b/components/board/status-update-dialog.tsx
new file mode 100644
index 0000000..bf74dc8
--- /dev/null
+++ b/components/board/status-update-dialog.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTheme } from "next-themes";
+import { toast } from "sonner";
+import { Check, Copy, Sparkles } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { MarkdownPreview } from "@/components/markdown/markdown-widgets";
+import { ChatMessageList, ChatComposer, type ChatBubbleMessage } from "@/components/ai/chat-thread";
+import { useSpeechRecognition } from "@/hooks/use-speech-recognition";
+import {
+ converseAboutGroupStatus,
+ type StatusChatMessage,
+} from "@/lib/actions/group-status-ai";
+import type { GroupDTO } from "@/types/board";
+
+export function StatusUpdateDialog({
+ group,
+ open,
+ onOpenChange,
+}: {
+ group: GroupDTO;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const { resolvedTheme } = useTheme();
+ const colorMode = resolvedTheme === "dark" ? "dark" : "light";
+
+ // The generated summary, shown permanently once produced -- there's no
+ // accept/discard step, since nothing here is ever saved back to the
+ // group's notes (export-only, by design).
+ const [summary, setSummary] = useState(null);
+ const [generating, setGenerating] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState("");
+ const [sending, setSending] = useState(false);
+ const [error, setError] = useState(null);
+
+ const { supported: micSupported, listening, start, stop } = useSpeechRecognition({
+ onFinalResult: (transcript) =>
+ setInput((prev) => (prev ? `${prev} ${transcript}` : transcript)),
+ onError: setError,
+ });
+
+ // Fires the initial summary the moment the dialog opens -- no user
+ // action needed, per spec. Guarded by summary === null so it doesn't
+ // re-fire on every re-render while open, and skipped once a summary (or
+ // an error in its place) already exists for this open session.
+ useEffect(() => {
+ if (!open || summary !== null || generating) return;
+ let cancelled = false;
+ setGenerating(true);
+ setError(null);
+ converseAboutGroupStatus(group.id, []).then((result) => {
+ if (cancelled) return;
+ setGenerating(false);
+ if ("error" in result) {
+ setError(result.error);
+ return;
+ }
+ setSummary(result.content);
+ });
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open]);
+
+ // Starts fresh every time it's reopened -- matches GroupAiDialog/
+ // TodoAiDialog's existing behavior.
+ function handleOpenChange(next: boolean) {
+ if (!next) {
+ stop();
+ setSummary(null);
+ setGenerating(false);
+ setCopied(false);
+ setMessages([]);
+ setInput("");
+ setError(null);
+ }
+ onOpenChange(next);
+ }
+
+ async function handleCopy() {
+ if (!summary) return;
+ try {
+ await navigator.clipboard.writeText(summary);
+ setCopied(true);
+ toast.success("Copied to clipboard.");
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ toast.error("Couldn't copy to clipboard.");
+ }
+ }
+
+ async function handleSend() {
+ const text = input.trim();
+ if (!text || sending) return;
+ if (listening) stop();
+
+ const next: StatusChatMessage[] = [...messages, { role: "user", content: text }];
+ setMessages(next);
+ setInput("");
+ setError(null);
+ setSending(true);
+ const result = await converseAboutGroupStatus(group.id, next);
+ setSending(false);
+
+ if ("error" in result) {
+ setError(result.error);
+ return;
+ }
+ setMessages([...next, { role: "assistant", content: result.content }]);
+ }
+
+ return (
+
+ );
+}
diff --git a/components/chat/chat-page-client.tsx b/components/chat/chat-page-client.tsx
new file mode 100644
index 0000000..e763c79
--- /dev/null
+++ b/components/chat/chat-page-client.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { useState } from "react";
+import { useTheme } from "next-themes";
+import { MessageCircle } from "lucide-react";
+
+import { ChatMessageList, ChatComposer, type ChatBubbleMessage } from "@/components/ai/chat-thread";
+import { useSpeechRecognition } from "@/hooks/use-speech-recognition";
+import { sendChatMessage, type ChatMessage } from "@/lib/actions/chat-ai";
+
+export function ChatPageClient({ aiConfigured }: { aiConfigured: boolean }) {
+ const { resolvedTheme } = useTheme();
+ const colorMode = resolvedTheme === "dark" ? "dark" : "light";
+
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState("");
+ const [sending, setSending] = useState(false);
+ const [error, setError] = useState(null);
+
+ const { supported: micSupported, listening, start, stop } = useSpeechRecognition({
+ onFinalResult: (transcript) =>
+ setInput((prev) => (prev ? `${prev} ${transcript}` : transcript)),
+ onError: setError,
+ });
+
+ async function handleSend() {
+ const text = input.trim();
+ if (!text || sending) return;
+ if (listening) stop();
+
+ const next: ChatMessage[] = [...messages, { role: "user", content: text }];
+ setMessages(next);
+ setInput("");
+ setError(null);
+ setSending(true);
+ const result = await sendChatMessage(next);
+ setSending(false);
+
+ if ("error" in result) {
+ setError(result.error);
+ return;
+ }
+ setMessages([...next, { role: "assistant", content: result.content }]);
+ }
+
+ return (
+
+
+
+
+ Chat
+
+
+ Ask about anything across your boards — notes, open to-dos, and things you've
+ already finished.
+
+
+
+ {!aiConfigured ? (
+
+ AI generation isn't configured yet. Ask an administrator to set it up.
+
+ ) : (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/components/nav/nav-items.ts b/components/nav/nav-items.ts
index 9129f3c..80b64dc 100644
--- a/components/nav/nav-items.ts
+++ b/components/nav/nav-items.ts
@@ -1,4 +1,4 @@
-import { Home, ShieldUser } from "lucide-react";
+import { Home, MessageCircle, ShieldUser } from "lucide-react";
import type { LucideIcon } from "lucide-react";
export interface NavItem {
@@ -9,7 +9,10 @@ export interface NavItem {
// Scaffolded with just "Home" per the spec -- trivially extended by pushing
// more entries here later.
-export const NAV_ITEMS: NavItem[] = [{ href: "/", label: "Home", icon: Home }];
+export const NAV_ITEMS: NavItem[] = [
+ { href: "/", label: "Home", icon: Home },
+ { href: "/chat", label: "Chat", icon: MessageCircle },
+];
// Only shown to admins -- appended conditionally by SideNav, not part of
// the always-visible list above.
diff --git a/lib/actions/chat-ai.ts b/lib/actions/chat-ai.ts
new file mode 100644
index 0000000..3b73b8b
--- /dev/null
+++ b/lib/actions/chat-ai.ts
@@ -0,0 +1,40 @@
+"use server";
+
+import { requireUserId } from "@/lib/auth-helpers";
+import { getAllGroupsForUser } from "@/lib/board";
+import { formatAllGroupsForPrompt } from "@/lib/ai/context";
+import { callChatCompletion } from "@/lib/ai/chat-completion";
+
+export interface ChatMessage {
+ role: "user" | "assistant";
+ content: string;
+}
+
+export type GlobalChatResult = { content: string } | { error: string };
+
+const SYSTEM_PROMPT_HEADER = `You are a helpful assistant answering questions about the user's personal/team to-do board app. Below is the full current state of every board they own, including completed to-dos and archived groups (kept so you can answer historical questions like "what did we do about X").
+
+Answer only using the information below. If the answer isn't in it, say so plainly instead of guessing. When it's helpful, mention which project/category/group the information came from.
+
+`;
+
+/**
+ * Free-form Q&A across everything the user owns (Home board + every
+ * Project), for the global Chat page. Re-fetches and re-serializes the
+ * full context on every turn -- simpler and always fresh, and re-checks
+ * the user's own access each call the same way the existing group-scoped
+ * AI actions do (lib/actions/group-ai.ts, lib/actions/todo-ai.ts).
+ * Unlike those, there's no artifact to save, so this returns the model's
+ * plain-text reply directly rather than an ask/finalize JSON contract.
+ */
+export async function sendChatMessage(messages: ChatMessage[]): Promise {
+ const userId = await requireUserId();
+ const groups = await getAllGroupsForUser(userId);
+ const context = formatAllGroupsForPrompt(groups);
+
+ const systemPrompt = `${SYSTEM_PROMPT_HEADER}${context}`;
+
+ return callChatCompletion([{ role: "system", content: systemPrompt }, ...messages], {
+ temperature: 0.3,
+ });
+}
diff --git a/lib/actions/group-status-ai.ts b/lib/actions/group-status-ai.ts
new file mode 100644
index 0000000..f2d66d0
--- /dev/null
+++ b/lib/actions/group-status-ai.ts
@@ -0,0 +1,70 @@
+"use server";
+
+import { prisma } from "@/lib/db";
+import { requireUserId } from "@/lib/auth-helpers";
+import { categoryAccessFilter } from "@/lib/access";
+import { formatGroupForPrompt } from "@/lib/ai/context";
+import { callChatCompletion } from "@/lib/ai/chat-completion";
+
+export interface StatusChatMessage {
+ role: "user" | "assistant";
+ content: string;
+}
+
+export type StatusChatResult = { content: string } | { error: string };
+
+/**
+ * Powers the group card's "Status Update" feature: with no prior
+ * messages, produces an initial markdown status summary of the group
+ * (notes + todos); with prior messages, answers a follow-up question
+ * about it. One action rather than two "generate" / "ask" actions --
+ * both are the same shape of call (group context + message history in,
+ * plain text out), and splitting them would just duplicate the
+ * group-loading and system-prompt code for no behavioral benefit. Unlike
+ * lib/actions/group-ai.ts/todo-ai.ts, nothing here is ever saved, so
+ * there's no ask/finalize JSON contract to parse -- just plain text.
+ */
+export async function converseAboutGroupStatus(
+ groupId: string,
+ messages: StatusChatMessage[]
+): Promise {
+ const userId = await requireUserId();
+
+ const group = await prisma.group.findFirst({
+ where: { id: groupId, category: categoryAccessFilter(userId) },
+ select: {
+ title: true,
+ noteContent: true,
+ todos: { orderBy: { order: "asc" } },
+ },
+ });
+ if (!group) return { error: "Group not found." };
+
+ const context = formatGroupForPrompt({
+ title: group.title,
+ noteContent: group.noteContent,
+ todos: group.todos.map((todo) => ({
+ title: todo.title,
+ details: todo.details,
+ completed: todo.completed,
+ completedAt: todo.completedAt?.toISOString() ?? null,
+ })),
+ });
+ const systemPrompt = `You are summarizing one group ("${group.title}") from a to-do board for its owner. Here is everything known about it:
+
+${context}
+
+If there are no prior messages below, your reply must be a concise markdown status update: a couple of short paragraphs and/or a short list covering what this group is about, what's been done, and what's still outstanding. Do not repeat the group's title as a heading, and don't add commentary like "Here's the status update" -- output only the summary itself.
+
+If there are prior messages below, they're a follow-up question about this group -- answer it using only the context above, in plain markdown, and say plainly if the answer isn't in the data.`;
+
+ // An empty conversation means "generate the initial summary" -- a
+ // synthetic, UI-invisible user turn keeps that a normal chat completion
+ // call instead of a special-cased empty-messages request.
+ const effectiveMessages: StatusChatMessage[] =
+ messages.length > 0 ? messages : [{ role: "user", content: "Generate the status update now." }];
+
+ return callChatCompletion([{ role: "system", content: systemPrompt }, ...effectiveMessages], {
+ temperature: 0.3,
+ });
+}
diff --git a/lib/ai/chat-completion.ts b/lib/ai/chat-completion.ts
new file mode 100644
index 0000000..e63b674
--- /dev/null
+++ b/lib/ai/chat-completion.ts
@@ -0,0 +1,91 @@
+import "server-only";
+
+import { getAiCredentials, getAiSettingsView, joinApiUrl } from "@/lib/ai-settings";
+
+export interface ChatCompletionMessage {
+ role: "system" | "user" | "assistant";
+ content: string;
+}
+
+export type ChatCompletionResult = { content: string } | { error: string };
+
+function extractMessageContent(body: unknown): string | null {
+ if (!body || typeof body !== "object") return null;
+ const choices = (body as { choices?: unknown }).choices;
+ if (!Array.isArray(choices) || !choices[0]) return null;
+ const message = (choices[0] as { message?: unknown }).message;
+ if (!message || typeof message !== "object") return null;
+ const content = (message as { content?: unknown }).content;
+ return typeof content === "string" ? content : null;
+}
+
+/**
+ * Shared fetch/parse/error-handling for calling the configured
+ * OpenAI-compatible AI provider (see lib/ai-settings.ts) with a plain chat
+ * completion -- no structured ask/finalize contract, just "here's a
+ * conversation, give me back the assistant's reply". Used by features
+ * that want the model to answer freely (global chat, group status
+ * updates) rather than produce a JSON artifact to review-and-save; those
+ * artifact-producing features (lib/actions/group-ai.ts,
+ * lib/actions/todo-ai.ts) predate this helper and have their own
+ * ask/finalize parsing layered on top of the same fetch shape, so they're
+ * left as-is rather than retrofitted onto this.
+ */
+export async function callChatCompletion(
+ messages: ChatCompletionMessage[],
+ opts?: { temperature?: number }
+): Promise {
+ const [{ apiUrl, apiKey }, settings] = await Promise.all([
+ getAiCredentials(),
+ getAiSettingsView(),
+ ]);
+ if (!apiUrl || !settings.model) {
+ return { error: "AI generation isn't configured yet. Ask an administrator to set it up." };
+ }
+
+ let chatUrl: URL;
+ try {
+ chatUrl = joinApiUrl(apiUrl, "chat/completions");
+ } catch {
+ return { error: "The configured AI API URL is invalid." };
+ }
+
+ let response: Response;
+ try {
+ response = await fetch(chatUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
+ },
+ body: JSON.stringify({
+ model: settings.model,
+ messages,
+ temperature: opts?.temperature ?? 0.4,
+ }),
+ signal: AbortSignal.timeout(30_000),
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ return { error: `Couldn't reach the AI provider -- ${message}` };
+ }
+
+ if (!response.ok) {
+ if (response.status === 401 || response.status === 403) {
+ return { error: "The AI provider rejected the configured API key." };
+ }
+ return { error: `The AI provider responded with ${response.status} ${response.statusText}.` };
+ }
+
+ let body: unknown;
+ try {
+ body = await response.json();
+ } catch {
+ return { error: "The AI provider didn't return valid JSON." };
+ }
+
+ const content = extractMessageContent(body);
+ if (!content) return { error: "The AI provider's response was empty." };
+
+ return { content: content.trim() };
+}
diff --git a/lib/ai/context.ts b/lib/ai/context.ts
new file mode 100644
index 0000000..a2c64fc
--- /dev/null
+++ b/lib/ai/context.ts
@@ -0,0 +1,90 @@
+import "server-only";
+
+import type { AiGroupContext } from "@/types/ai";
+
+// This app is single-user/self-hosted scale (see lib/access.ts's comments
+// on Projects being single-owner for now) -- there's no retrieval/ranking
+// here, the whole relevant dataset just goes straight into the prompt,
+// truncated only by the hard caps below. If a board ever grows large
+// enough for this to blow the model's context window, the fix is a
+// RAG-style retrieval step, not a bigger cap.
+const NOTE_CHAR_CAP = 4_000;
+const DETAILS_CHAR_CAP = 300;
+const TOTAL_CHAR_CAP = 60_000;
+
+function truncate(text: string, max: number): string {
+ const trimmed = text.trim();
+ if (trimmed.length <= max) return trimmed;
+ return `${trimmed.slice(0, max)}… [truncated]`;
+}
+
+function formatTodoLine(todo: {
+ title: string;
+ details: string | null;
+ completed: boolean;
+ completedAt: string | null;
+}): string {
+ const box = todo.completed ? "[x]" : "[ ]";
+ const doneNote = todo.completed && todo.completedAt ? ` (done ${todo.completedAt.slice(0, 10)})` : "";
+ const details = todo.details?.trim() ? ` — ${truncate(todo.details, DETAILS_CHAR_CAP)}` : "";
+ return `- ${box} ${todo.title}${doneNote}${details}`;
+}
+
+/** Renders one group's notes + todos as compact markdown-ish prompt text. */
+export function formatGroupForPrompt(
+ group: {
+ title: string;
+ noteContent: string;
+ todos: { title: string; details: string | null; completed: boolean; completedAt: string | null }[];
+ },
+ opts?: { archived?: boolean }
+): string {
+ const openCount = group.todos.filter((t) => !t.completed).length;
+ const doneCount = group.todos.length - openCount;
+ const notes = group.noteContent.trim()
+ ? truncate(group.noteContent, NOTE_CHAR_CAP)
+ : "(no notes)";
+ const todoLines = group.todos.length > 0
+ ? group.todos.map(formatTodoLine).join("\n")
+ : "(no to-dos)";
+ const archivedTag = opts?.archived ? " [archived]" : "";
+
+ return `Group${archivedTag}: "${group.title}"\n\nNotes:\n${notes}\n\nTo-dos (${openCount} open, ${doneCount} done):\n${todoLines}`;
+}
+
+/**
+ * Renders every group the user can see, grouped by board (Home/Project)
+ * then category, for the global chat's system prompt. Archived groups
+ * are marked inline rather than omitted, so historical questions stay
+ * answerable.
+ */
+export function formatAllGroupsForPrompt(groups: AiGroupContext[]): string {
+ const byScope = new Map>();
+ for (const group of groups) {
+ if (!byScope.has(group.scopeLabel)) byScope.set(group.scopeLabel, new Map());
+ const byCategory = byScope.get(group.scopeLabel)!;
+ if (!byCategory.has(group.categoryName)) byCategory.set(group.categoryName, []);
+ byCategory.get(group.categoryName)!.push(group);
+ }
+
+ const sections: string[] = [];
+ for (const [scopeLabel, byCategory] of byScope) {
+ sections.push(`# ${scopeLabel}`);
+ for (const [categoryName, categoryGroups] of byCategory) {
+ sections.push(`## Category: ${categoryName}`);
+ for (const group of categoryGroups) {
+ const formatted = formatGroupForPrompt(
+ { title: group.groupTitle, noteContent: group.noteContent, todos: group.todos },
+ { archived: group.archived }
+ );
+ sections.push(`### ${formatted}`);
+ }
+ }
+ }
+
+ let result = sections.join("\n\n");
+ if (result.length > TOTAL_CHAR_CAP) {
+ result = `${result.slice(0, TOTAL_CHAR_CAP)}\n\n[... additional groups omitted for length ...]`;
+ }
+ return result;
+}
diff --git a/lib/board.ts b/lib/board.ts
index c2a79ca..1cc125f 100644
--- a/lib/board.ts
+++ b/lib/board.ts
@@ -1,7 +1,9 @@
import "server-only";
import { prisma } from "@/lib/db";
+import { categoryAccessFilter } from "@/lib/access";
import type { CategoryDTO } from "@/types/board";
+import type { AiGroupContext } from "@/types/ai";
/**
* Fetches one board's worth of categories/groups/todos, shaped for the
@@ -50,3 +52,43 @@ export async function getBoard(
})),
}));
}
+
+/**
+ * Every group the user can see across every board they own -- the Home
+ * board plus every Project (see lib/access.ts's categoryAccessFilter) --
+ * flattened into one list for feeding to the AI as context (see
+ * lib/ai/context.ts). Unlike getBoard, archived groups are deliberately
+ * *not* filtered out: the global chat feature needs to answer historical
+ * questions about finished work, not just describe what's currently on
+ * screen. One query, not one getBoard() call per project.
+ */
+export async function getAllGroupsForUser(userId: string): Promise {
+ const categories = await prisma.category.findMany({
+ where: categoryAccessFilter(userId),
+ orderBy: { order: "asc" },
+ include: {
+ project: { select: { title: true } },
+ groups: {
+ orderBy: { order: "asc" },
+ include: { todos: { orderBy: { order: "asc" } } },
+ },
+ },
+ });
+
+ return categories.flatMap((category) =>
+ category.groups.map((group) => ({
+ groupId: group.id,
+ groupTitle: group.title,
+ scopeLabel: category.project?.title ?? "Home",
+ categoryName: category.name,
+ archived: group.archivedAt !== null,
+ noteContent: group.noteContent,
+ todos: group.todos.map((todo) => ({
+ title: todo.title,
+ details: todo.details,
+ completed: todo.completed,
+ completedAt: todo.completedAt?.toISOString() ?? null,
+ })),
+ }))
+ );
+}
diff --git a/types/ai.ts b/types/ai.ts
new file mode 100644
index 0000000..ab0ef46
--- /dev/null
+++ b/types/ai.ts
@@ -0,0 +1,23 @@
+/**
+ * One group's worth of data, flattened for feeding to the AI as context --
+ * used by the global chat feature, which needs every group across every
+ * board (Home + all owned Projects) rather than one board's scoped tree
+ * (see CategoryDTO/GroupDTO in types/board.ts). Unlike the board DTOs,
+ * this deliberately includes archived groups, so historical questions
+ * ("what did we do about X") are answerable.
+ */
+export interface AiGroupContext {
+ groupId: string;
+ groupTitle: string;
+ // "Home" for the personal board, or the owning Project's title.
+ scopeLabel: string;
+ categoryName: string;
+ archived: boolean;
+ noteContent: string;
+ todos: {
+ title: string;
+ details: string | null;
+ completed: boolean;
+ completedAt: string | null;
+ }[];
+}