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:
299
apps/web/src/modules/copilot/components/CopilotPanel.tsx
Normal file
299
apps/web/src/modules/copilot/components/CopilotPanel.tsx
Normal 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";
|
||||
Reference in New Issue
Block a user