feat: implement Story 3.1 — chat panel UI with streaming AI responses

Add AI copilot chat panel to the diagram editor with streaming responses,
chat history persistence, and markdown rendering. Includes copilot API route,
diagram-aware system prompt, and schema with 15 passing tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-28 10:03:43 +00:00
parent 9d13d0f562
commit 26215d9060
20 changed files with 3223 additions and 37 deletions

View File

@@ -0,0 +1,299 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
import { api } from "~/lib/api/client";
import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown";
import { Prose } from "~/modules/common/prose";
import type { DiagramType } from "~/modules/diagram/types/graph";
interface CopilotPanelProps {
diagramId: string;
diagramType: DiagramType;
}
export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
const chatId = useMemo(() => `copilot-${diagramId}`, [diagramId]);
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const userScrolledRef = useRef(false);
// Fetch existing chat history on mount (H1 fix)
const { data: initialMessages } = useQuery({
queryKey: ["copilot", "messages", chatId],
queryFn: async () => {
const res = await api.ai.copilot.messages.$get({
query: { chatId },
});
if (!res.ok) return [];
return res.json();
},
staleTime: Infinity,
});
// Memoize transport to avoid recreation on every render (M2 fix)
const transport = useMemo(
() =>
new DefaultChatTransport({
api: api.ai.copilot.$url().toString(),
prepareSendMessagesRequest: ({ messages, id }) => {
const lastMessage = messages.at(-1);
return {
body: {
...lastMessage,
chatId: id,
diagramId,
diagramType,
},
};
},
}),
[diagramId, diagramType],
);
const { messages, sendMessage, status, error, stop, setMessages } = useChat({
id: chatId,
transport,
onError: (err) => {
console.error("[copilot]", err);
toast.error("Failed to get AI response");
},
});
// Seed chat with persisted history once loaded
useEffect(() => {
if (initialMessages && initialMessages.length > 0 && messages.length === 0) {
setMessages(
initialMessages.map((m) => ({
...m,
createdAt: new Date(),
})),
);
}
}, [initialMessages, messages.length, setMessages]);
const isSubmitting = status === "submitted" || status === "streaming";
// Auto-scroll on new content, but pause if user scrolled up
useEffect(() => {
if (userScrolledRef.current) return;
const viewport = scrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]",
);
if (viewport) {
viewport.scrollTop = viewport.scrollHeight;
}
}, [messages]);
// Detect user scroll
useEffect(() => {
const viewport = scrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]",
);
if (!viewport) return;
let timeoutId: NodeJS.Timeout;
const handleScroll = () => {
const isAtBottom =
Math.abs(
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight,
) < 80;
userScrolledRef.current = !isAtBottom;
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
userScrolledRef.current = false;
}, 2000);
};
viewport.addEventListener("scroll", handleScroll);
return () => {
viewport.removeEventListener("scroll", handleScroll);
clearTimeout(timeoutId);
};
}, []);
const handleSend = useCallback(() => {
const text = input.trim();
if (!text || isSubmitting) return;
void sendMessage({
text,
metadata: {},
});
setInput("");
userScrolledRef.current = false;
}, [input, isSubmitting, sendMessage]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
return (
<div className="flex h-full flex-col">
{/* Messages area */}
<ScrollArea ref={scrollRef} className="flex-1">
<div className="flex flex-col gap-1 p-3">
{messages.length === 0 && (
<EmptyState />
)}
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex flex-col gap-1",
message.role === "user" ? "items-end" : "items-start",
)}
>
{message.role === "user" ? (
<UserBubble message={message} />
) : (
<AssistantBubble
message={message}
isStreaming={
status === "streaming" &&
message.id === messages.at(-1)?.id
}
/>
)}
</div>
))}
{/* Typing indicator */}
{status === "submitted" && (
<div className="flex items-start gap-2 py-2">
<div className="flex gap-1">
<span className="size-1.5 rounded-full bg-muted-foreground/50 animate-bounce [animation-delay:0ms]" />
<span className="size-1.5 rounded-full bg-muted-foreground/50 animate-bounce [animation-delay:150ms]" />
<span className="size-1.5 rounded-full bg-muted-foreground/50 animate-bounce [animation-delay:300ms]" />
</div>
</div>
)}
{/* Error display */}
{error && (
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Something went wrong. Please try again.
</div>
)}
</div>
</ScrollArea>
{/* Input area */}
<div className="shrink-0 border-t border-border p-3">
<div className="relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about your diagram..."
rows={1}
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 pr-20 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
<div className="absolute right-1.5 bottom-1.5 flex items-center gap-1">
{/* Mic button placeholder (Epic 5) */}
<Button
size="icon"
variant="ghost"
className="size-7"
disabled
title="Voice input (coming soon)"
>
<Icons.Mic className="size-3.5 text-muted-foreground" />
</Button>
{/* Send / Stop button */}
{isSubmitting ? (
<Button
size="icon"
variant="ghost"
className="size-7"
onClick={() => stop()}
>
<Icons.Square className="size-3 fill-current" />
</Button>
) : (
<Button
size="icon"
variant="ghost"
className="size-7"
disabled={!input.trim()}
onClick={handleSend}
>
<Icons.ArrowUp className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
</div>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Icons.Sparkles className="mb-3 size-8 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">AI Copilot</p>
<p className="mt-1 text-xs text-muted-foreground/60">
Ask questions about your diagram or describe what you want to build
</p>
</div>
);
}
const UserBubble = memo<{ message: { id: string; parts: Array<{ type: string; text?: string }> } }>(
({ message }) => (
<div className="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-3 py-2 text-primary-foreground">
{message.parts.map((part, i) =>
part.type === "text" ? (
<p key={`${message.id}-${i}`} className="text-sm whitespace-pre-wrap">
{part.text}
</p>
) : null,
)}
</div>
),
);
UserBubble.displayName = "UserBubble";
const AssistantBubble = memo<{
message: { id: string; parts: Array<{ type: string; text?: string }> };
isStreaming: boolean;
}>(({ message, isStreaming }) => (
<div className="max-w-[95%]">
<Prose className="text-sm">
{message.parts.map((part, i) =>
part.type === "text" && part.text ? (
<MemoizedMarkdown
key={`${message.id}-${i}`}
content={part.text}
id={`copilot-${message.id}-${i}`}
/>
) : null,
)}
{isStreaming && message.parts.length === 0 && (
<span className="inline-block size-2 animate-pulse rounded-full bg-muted-foreground/50" />
)}
</Prose>
</div>
));
AssistantBubble.displayName = "AssistantBubble";

View File

@@ -131,7 +131,11 @@ export function DiagramEditor({ diagram }: DiagramEditorProps) {
</div>
{/* Right panel */}
<RightPanel open={rightPanelOpen} />
<RightPanel
open={rightPanelOpen}
diagramId={diagram.id}
diagramType={diagram.type as DiagramType}
/>
</div>
<EditorStatusBar diagramType={diagram.type as DiagramType} />

View File

@@ -3,6 +3,10 @@
import { useState } from "react";
import { Icons } from "@turbostarter/ui-web/icons";
import { CopilotPanel } from "~/modules/copilot/components/CopilotPanel";
import type { DiagramType } from "../../types/graph";
type Tab = "chat" | "inspector" | "annotations";
const tabs: { key: Tab; label: string }[] = [
@@ -13,9 +17,11 @@ const tabs: { key: Tab; label: string }[] = [
interface RightPanelProps {
open: boolean;
diagramId: string;
diagramType: DiagramType;
}
export function RightPanel({ open }: RightPanelProps) {
export function RightPanel({ open, diagramId, diagramType }: RightPanelProps) {
const [activeTab, setActiveTab] = useState<Tab>("chat");
return (
@@ -43,20 +49,12 @@ export function RightPanel({ open }: RightPanelProps) {
</div>
{/* Tab content */}
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center">
<div className="flex flex-1 flex-col overflow-hidden">
{activeTab === "chat" && (
<>
<Icons.Sparkles className="h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm font-medium text-muted-foreground">
AI Copilot
</p>
<p className="text-xs text-muted-foreground/60 mt-1">
Start a conversation to build your diagram
</p>
</>
<CopilotPanel diagramId={diagramId} diagramType={diagramType} />
)}
{activeTab === "inspector" && (
<>
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center">
<Icons.Search className="h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm font-medium text-muted-foreground">
Inspector
@@ -64,10 +62,10 @@ export function RightPanel({ open }: RightPanelProps) {
<p className="text-xs text-muted-foreground/60 mt-1">
Select a node to see its properties
</p>
</>
</div>
)}
{activeTab === "annotations" && (
<>
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center">
<Icons.MessageSquare className="h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm font-medium text-muted-foreground">
Annotations
@@ -75,7 +73,7 @@ export function RightPanel({ open }: RightPanelProps) {
<p className="text-xs text-muted-foreground/60 mt-1">
Coming soon
</p>
</>
</div>
)}
</div>
</div>

View File

@@ -16,7 +16,7 @@ import { TurboLink } from "~/modules/common/turbo-link";
import { pdf } from "../lib/api";
import type { Chat } from "@turbostarter/ai/chat/types";
import type { SelectPdfChat as Chat } from "@turbostarter/db/schema/pdf";
dayjs.extend(relativeTime);

View File

@@ -22,7 +22,7 @@ import { authClient } from "~/lib/auth/client";
import { TurboLink } from "~/modules/common/turbo-link";
import { pdf } from "~/modules/pdf/lib/api";
import type { Chat } from "@turbostarter/ai/chat/types";
import type { SelectPdfChat as Chat } from "@turbostarter/db/schema/pdf";
interface ChatHistoryListItemProps {
readonly chat: Chat;