Browse Source

feat(32-01): create ChatPanel component with useChat hook

- Add @ai-sdk/react package for useChat hook
- Create floating chat panel with streaming messages
- Display messages with role-based styling (user/assistant)
- Auto-scroll to bottom on new messages
- Show loading indicator during AI responses
- Dark theme matching app (neutral-800 bg)
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
c92af6aa72
  1. 51
      package-lock.json
  2. 1
      package.json
  3. 128
      src/components/ChatPanel.tsx

51
package-lock.json

@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@ai-sdk/google": "^3.0.13",
"@ai-sdk/react": "^3.0.51",
"@google/genai": "^1.30.0",
"@tailwindcss/postcss": "^4.1.17",
"@xyflow/react": "^12.9.3",
@ -115,6 +116,24 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/react": {
"version": "3.0.51",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.51.tgz",
"integrity": "sha512-7nmCwEJM52NQZB4/ED8qJ4wbDg7EEWh94qJ7K9GSJxD6sWF3GOKrRZ5ivm4qNmKhY+JfCxCAxfghGY5mTKOsxw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "4.0.9",
"ai": "6.0.49",
"swr": "^2.2.5",
"throttleit": "2.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@ -3458,7 +3477,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@ -4872,9 +4890,9 @@
}
},
"node_modules/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -5387,6 +5405,19 @@
"node": ">=8"
}
},
"node_modules/swr": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.8.tgz",
"integrity": "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@ -5413,6 +5444,18 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/throttleit": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

1
package.json

@ -13,6 +13,7 @@
},
"dependencies": {
"@ai-sdk/google": "^3.0.13",
"@ai-sdk/react": "^3.0.51",
"@google/genai": "^1.30.0",
"@tailwindcss/postcss": "^4.1.17",
"@xyflow/react": "^12.9.3",

128
src/components/ChatPanel.tsx

@ -0,0 +1,128 @@
"use client";
import { useRef, useEffect, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
interface ChatPanelProps {
isOpen: boolean;
onClose: () => void;
}
export function ChatPanel({ isOpen, onClose }: ChatPanelProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState("");
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
});
const isLoading = status === "streaming" || status === "submitted";
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
if (!isOpen) return null;
return (
<div className="fixed bottom-[220px] right-5 w-[380px] h-[450px] bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl flex flex-col z-40">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-700">
<h3 className="text-sm font-medium text-neutral-200">Workflow Assistant</h3>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-200 transition-colors"
aria-label="Close chat"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Messages area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0">
{messages.length === 0 && (
<div className="text-center text-neutral-500 text-sm py-8">
<p>Ask me anything about creating workflows!</p>
<p className="text-xs mt-2">e.g., &quot;How do I create product photos with different backgrounds?&quot;</p>
</div>
)}
{messages.map((message) => {
// Extract text from message parts
const textContent = message.parts
?.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("") || "";
return (
<div
key={message.id}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
message.role === "user"
? "bg-blue-600 text-white"
: "bg-neutral-700 text-neutral-200"
}`}
>
<p className="whitespace-pre-wrap">{textContent}</p>
</div>
</div>
);
})}
{/* Loading indicator */}
{isLoading && (
<div className="flex justify-start">
<div className="bg-neutral-700 rounded-lg px-3 py-2">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-neutral-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<div className="w-2 h-2 bg-neutral-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<div className="w-2 h-2 bg-neutral-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<form
onSubmit={(e) => {
e.preventDefault();
if (input.trim() && !isLoading) {
sendMessage({ text: input });
setInput("");
}
}}
className="p-3 border-t border-neutral-700"
>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
className="flex-1 bg-neutral-700 border border-neutral-600 rounded-lg px-3 py-2 text-sm text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-blue-500"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-3 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
</form>
</div>
);
}
Loading…
Cancel
Save