"use client"; import * as React from "react"; import type { inferRouterOutputs } from "@trpc/server"; import { Loader2, Send, Sparkles, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { api } from "@/lib/trpc"; import type { aiRouter } from "@/server/routers/ai"; import { usePanelStore } from "@/lib/stores/panel-store"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { AIMessage } from "@/components/ai/message"; type ChatRole = "user" | "assistant"; type ChatLine = { id: string; role: ChatRole; content: string; createdAt: Date; }; type ObjectSummary = { title: string; type: string }; type AiOutputs = inferRouterOutputs; /** Cast until `aiRouter` is merged into `appRouter` in `server/root.ts` */ // eslint-disable-next-line @typescript-eslint/no-explicit-any const aiTrpc = (api as any).ai as { suggestActions: { useQuery: ( input: { workspace: string; objectId?: string; objectType?: string }, opts?: { enabled?: boolean }, ) => { data: { actions: string[] } | undefined }; }; chat: { useMutation: (opts: { onMutate?: () => void; onSuccess?: (data: AiOutputs["chat"]) => void; onError?: (err: { message: string }) => void; }) => { mutate: (input: { workspace: string; messages: { role: "user" | "assistant"; content: string }[]; context?: { objectId?: string }; }) => void; isPending: boolean; isError: boolean; error: { message: string } | null; }; }; }; const MODEL_LABEL = "GPT-4o"; const MAX_CHARS = 8000; const WELCOME_CHIPS = [ "Create a project plan", "Summarize this document", "Generate task descriptions", ]; function TypingDots() { return (
{[0, 1, 2].map((i) => ( ))}
); } export function AIChatPanel() { const close = usePanelStore((s) => s.close); const objectId = usePanelStore((s) => s.objectId); const workspace = useWorkspaceStore((s) => s.currentWorkspace); const [input, setInput] = React.useState(""); const [messages, setMessages] = React.useState([]); const [sendError, setSendError] = React.useState(null); const bottomRef = React.useRef(null); const textareaRef = React.useRef(null); const workspaceHandle = workspace?.slug ?? workspace?.id; const objectQuery = api.objects.getById.useQuery( { id: objectId!, workspace: workspaceHandle! }, { enabled: !!objectId && Boolean(workspaceHandle) }, ); const objectSummary = objectQuery.data as ObjectSummary | undefined; const suggestQuery = aiTrpc.suggestActions.useQuery( { workspace: workspaceHandle!, objectId: objectId ?? undefined, objectType: objectSummary?.type, }, { enabled: Boolean(workspaceHandle) }, ); const chatMutation = aiTrpc.chat.useMutation({ onMutate: () => setSendError(null), onSuccess: (data) => { setMessages((prev) => [ ...prev, { id: crypto.randomUUID(), role: "assistant", content: data.text, createdAt: new Date(), }, ]); }, onError: (err) => setSendError(err.message ?? "Request failed"), }); const isLoading = chatMutation.isPending; React.useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, isLoading]); const send = React.useCallback(() => { const trimmed = input.trim(); if (!trimmed || isLoading) return; const userMsg: ChatLine = { id: crypto.randomUUID(), role: "user", content: trimmed, createdAt: new Date(), }; const nextMessages = [...messages, userMsg]; setMessages(nextMessages); setInput(""); const payload = nextMessages.map((m) => ({ role: m.role, content: m.content, })); if (!workspaceHandle) { setSendError("Select a workspace first."); return; } chatMutation.mutate({ workspace: workspaceHandle, messages: payload, context: { objectId: objectId ?? undefined, }, }); }, [input, isLoading, messages, chatMutation, workspaceHandle, objectId]); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }; const insertChip = (text: string) => { setInput((prev) => (prev ? `${prev}\n${text}` : text)); textareaRef.current?.focus(); }; const contextLabel = objectSummary?.title ? objectSummary.title : workspace?.name ? workspace.name : "No context"; const suggestions = suggestQuery.data?.actions ?? []; return (

AI Assistant

{MODEL_LABEL} {objectId ? "Object: " : "Workspace: "} {contextLabel}
{messages.length === 0 && !isLoading ? (

How can I help?

Ask about planning, tasks, or this workspace — or try a suggestion below.

Try asking

{WELCOME_CHIPS.map((chip) => ( ))}
) : null} {messages.map((m) => ( ))} {sendError ? ( ) : null} {isLoading ? (
Assistant is thinking
) : null}
{suggestions.length > 0 ? (
{suggestions.slice(0, 6).map((action) => ( ))}
) : null}