Complete architecture for a ClickUp/Notion/Miro-class project management app: - Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM) - Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards) - NextAuth v5 authentication with credentials + OAuth providers - tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search - Three-panel UI: collapsible sidebar, center content area, push-in right panel - Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS - Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe - TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block - Real-time collaboration via Yjs + Hocuspocus with presence/cursors - tldraw whiteboard with custom shape cards (task, document, project) - MCP server exposing all app data/tools for AI agents - AI chat panel, editor AI slash commands, Cmd+K command palette - Template system with built-in templates (Bug Report, Meeting Notes, Sprint) - Full-text search with result highlighting - Docker Compose for full-stack deployment (web + collab + postgres + redis) Made-with: Cursor
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
"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<typeof aiRouter>;
|
|
|
|
/** 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: { 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: {
|
|
messages: { role: "user" | "assistant"; content: string }[];
|
|
context?: { workspaceId?: string; 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 (
|
|
<div className="flex items-center gap-1.5 px-1 py-2" aria-hidden>
|
|
{[0, 1, 2].map((i) => (
|
|
<span
|
|
key={i}
|
|
className="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground/70"
|
|
style={{ animationDelay: `${i * 0.15}s` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<ChatLine[]>([]);
|
|
const [sendError, setSendError] = React.useState<string | null>(null);
|
|
const bottomRef = React.useRef<HTMLDivElement>(null);
|
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
|
|
|
const objectQuery = api.objects.getById.useQuery(
|
|
{ id: objectId! },
|
|
{ enabled: !!objectId },
|
|
);
|
|
|
|
const objectSummary = objectQuery.data as ObjectSummary | undefined;
|
|
|
|
const suggestQuery = aiTrpc.suggestActions.useQuery(
|
|
{
|
|
objectId: objectId ?? undefined,
|
|
objectType: objectSummary?.type,
|
|
},
|
|
{ enabled: true },
|
|
);
|
|
|
|
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,
|
|
}));
|
|
|
|
chatMutation.mutate({
|
|
messages: payload,
|
|
context: {
|
|
workspaceId: workspace?.id,
|
|
objectId: objectId ?? undefined,
|
|
},
|
|
});
|
|
}, [input, isLoading, messages, chatMutation, workspace?.id, objectId]);
|
|
|
|
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
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 (
|
|
<div className="flex h-full min-h-0 flex-col bg-background">
|
|
<header className="flex shrink-0 items-start justify-between gap-2 border-b border-border/80 px-4 py-3">
|
|
<div className="min-w-0 space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<Sparkles className="size-5 shrink-0 text-primary" />
|
|
<h2 className="truncate text-base font-semibold tracking-tight">AI Assistant</h2>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge variant="secondary" className="font-normal">
|
|
{MODEL_LABEL}
|
|
</Badge>
|
|
<span
|
|
className="truncate text-xs text-muted-foreground"
|
|
title={contextLabel}
|
|
>
|
|
{objectId ? "Object: " : "Workspace: "}
|
|
<span className="text-foreground/90">{contextLabel}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0"
|
|
onClick={() => close()}
|
|
aria-label="Close panel"
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
</header>
|
|
|
|
<ScrollArea className="min-h-0 flex-1 px-3">
|
|
<div className="pb-4 pt-2">
|
|
{messages.length === 0 && !isLoading ? (
|
|
<div className="space-y-4 px-1">
|
|
<div className="rounded-2xl border border-dashed border-primary/25 bg-primary/5 px-4 py-6 text-center dark:bg-primary/10">
|
|
<p className="text-sm font-medium text-foreground">How can I help?</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Ask about planning, tasks, or this workspace — or try a suggestion below.
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<p className="text-xs font-medium text-muted-foreground">Try asking</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{WELCOME_CHIPS.map((chip) => (
|
|
<button
|
|
key={chip}
|
|
type="button"
|
|
onClick={() => insertChip(chip)}
|
|
className="rounded-full border border-border bg-card px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent"
|
|
>
|
|
{chip}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{messages.map((m) => (
|
|
<AIMessage
|
|
key={m.id}
|
|
role={m.role}
|
|
content={m.content}
|
|
timestamp={m.createdAt}
|
|
/>
|
|
))}
|
|
|
|
{sendError ? (
|
|
<AIMessage role="system" content={sendError} />
|
|
) : null}
|
|
|
|
{isLoading ? (
|
|
<div className="flex items-start gap-3 px-1 py-2">
|
|
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/60 bg-teal-500/15 text-teal-600 dark:text-teal-400">
|
|
<Sparkles className="size-4" />
|
|
</div>
|
|
<div className="rounded-2xl rounded-tl-md border border-border/80 bg-card px-4 py-3">
|
|
<TypingDots />
|
|
<span className="sr-only">Assistant is thinking</span>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<div className="shrink-0 border-t border-border/80 bg-muted/20 px-3 pb-3 pt-2">
|
|
{suggestions.length > 0 ? (
|
|
<div className="mb-2 flex flex-wrap gap-1.5">
|
|
{suggestions.slice(0, 6).map((action) => (
|
|
<button
|
|
key={action}
|
|
type="button"
|
|
onClick={() => insertChip(action)}
|
|
className={cn(
|
|
"rounded-full border border-primary/20 bg-background/80 px-2.5 py-1 text-xs text-foreground",
|
|
"transition-colors hover:border-primary/40 hover:bg-primary/5",
|
|
)}
|
|
>
|
|
{action}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex gap-2">
|
|
<div className="relative min-w-0 flex-1">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value.slice(0, MAX_CHARS))}
|
|
onKeyDown={onKeyDown}
|
|
placeholder="Ask AI anything..."
|
|
disabled={isLoading}
|
|
rows={3}
|
|
className={cn(
|
|
"w-full resize-none rounded-xl border border-input bg-background px-3 py-2.5 text-sm",
|
|
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
)}
|
|
/>
|
|
<div className="pointer-events-none absolute bottom-2 right-2 text-[10px] text-muted-foreground">
|
|
{input.length}/{MAX_CHARS}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
className="h-auto min-h-[5.5rem] shrink-0 rounded-xl bg-primary"
|
|
onClick={send}
|
|
disabled={isLoading || !input.trim()}
|
|
aria-label="Send message"
|
|
>
|
|
{isLoading ? (
|
|
<Loader2 className="size-5 animate-spin" />
|
|
) : (
|
|
<Send className="size-5" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|