From 1c0c049a89bfadfb301a99b2206733a7d949f21d Mon Sep 17 00:00:00 2001 From: Henry Eb Date: Fri, 24 Apr 2026 13:34:16 +0100 Subject: [PATCH] Refactor chat UI into useChat hook and ChatInterface component - Move chat state, typing, input handling, and mock response lifecycle into src/hooks/useChat.ts - Add src/app/components/ChatInterface.tsx to render chat UI from props only - Update src/app/page.tsx to consume the hook and delegate rendering - Add src/types/chat.ts for shared Message typing --- frontend/src/app/components/ChatInterface.tsx | 76 ++++++++ frontend/src/app/page.tsx | 172 ++---------------- frontend/src/hooks/useChat.ts | 120 ++++++++++++ frontend/src/types/chat.ts | 7 + 4 files changed, 221 insertions(+), 154 deletions(-) create mode 100644 frontend/src/app/components/ChatInterface.tsx create mode 100644 frontend/src/hooks/useChat.ts create mode 100644 frontend/src/types/chat.ts diff --git a/frontend/src/app/components/ChatInterface.tsx b/frontend/src/app/components/ChatInterface.tsx new file mode 100644 index 0000000..9b67db2 --- /dev/null +++ b/frontend/src/app/components/ChatInterface.tsx @@ -0,0 +1,76 @@ +import React, { useRef, useEffect } from 'react'; +import { Bot, Send, CheckCircle2, AlertCircle } from 'lucide-react'; +import type { Message } from '../../types/chat'; + +interface ChatInterfaceProps { + messages: Message[]; + inputState: string; + setInputState: (value: string) => void; + isTyping: boolean; + handleSendMessage: (e: React.FormEvent) => void; +} + +export function ChatInterface({ + messages, + inputState, + setInputState, + isTyping, + handleSendMessage, +}: ChatInterfaceProps) { + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, isTyping]); + + return ( +
+
+
+
+ +
+
+

OpenClaw Agent

+
+ Online +
+
+
+ +
+ {messages.map((msg) => ( +
+ {msg.proactive && ( +
+ Proactive Nudge +
+ )} +
{msg.text}
+
+ ))} + {isTyping && ( +
+
+ +
+
+ )} +
+
+ +
+ setInputState(e.target.value)} + /> + +
+
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 86b469c..2bdedbc 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,24 +1,15 @@ "use client" -import React, { useState, useEffect, useRef } from 'react'; -import { Bot, Send, Target, Activity, CheckCircle2, AlertCircle } from 'lucide-react'; +import React, { useState } from 'react'; +import { Target, Activity } from 'lucide-react'; import { PortfolioStats } from './components/PortfolioStats'; -import { evaluateGoalStatus, formatCurrency, getStatusColor, type GoalData } from '../utils/goalProjection'; +import { evaluateGoalStatus, getStatusColor, type GoalData } from '../utils/goalProjection'; import PortfolioChart from './PortfolioChart'; -import { parseAllocationsFromMessage, getDefaultAllocations } from '../utils/allocationParser'; -import type { AssetAllocation } from '../utils/chartUtils'; -import { useNotifications } from '../hooks/useNotifications'; import { DashboardHeader } from './components/DashboardHeader'; import { ConnectWalletButton } from './components/ConnectWalletButton'; import { useWallet } from './components/WalletContext'; import { ErrorBoundary } from './components/ErrorBoundary'; - -interface Message { - id: number; - sender: 'agent' | 'user'; - text: string; - proactive?: boolean; - timestamp?: string; -} +import { useChat } from '../hooks/useChat'; +import { ChatInterface } from './components/ChatInterface'; export default function Home() { const { publicKey, setPublicKey } = useWallet(); @@ -32,21 +23,10 @@ export default function Home() { try { const key = await window.freighterApi.getPublicKey(); setPublicKey(key); - } catch (e) { + } catch { // Optionally handle rejection } }; - const [messages, setMessages] = useState([ - { id: 1, sender: 'agent', text: "Welcome to Smasage! 👋 I'm OpenClaw, your personal AI savings assistant natively built on Stellar. What financial goal can we crush today?" } - ]); - const [inputState, setInputState] = useState(''); - const [isTyping, setIsTyping] = useState(false); - - const [progress, setProgress] = useState(0); - const [goalStatus, setGoalStatus] = useState<'On Track' | 'Ahead' | 'Falling Behind'>('On Track'); - const [allocations, setAllocations] = useState(getDefaultAllocations()); - const [wsConnected, setWsConnected] = useState(false); - const messagesEndRef = useRef(null); // Goal data const goalData: GoalData = { @@ -57,88 +37,13 @@ export default function Home() { expectedAPY: 8.5, }; - // WebSocket notifications - const { registerGoal } = useNotifications({ + const { messages, inputState, setInputState, isTyping, handleSendMessage, allocations, wsConnected } = useChat({ userId: 'user-demo-001', - onNotification: (notification) => { - if (notification.type === 'connected') { - console.log('[App] Connected to notification server'); - setWsConnected(true); - } else if (notification.type === 'agent-message') { - const payload = notification.payload as any; - const agentMsg: Message = { - id: Date.now(), - sender: 'agent', - text: payload.text, - proactive: payload.proactive, - timestamp: payload.timestamp, - }; - setMessages(prev => [...prev, agentMsg]); - - // Parse allocations if present - const parsedAllocations = parseAllocationsFromMessage(payload.text); - if (parsedAllocations) { - setAllocations(parsedAllocations); - } - } - }, - onError: (error) => { - console.error('[App] WebSocket error:', error); - }, - enabled: true, + goalData, }); - - // Auto scroll - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages, isTyping]); - - // Calculate goal status dynamically - useEffect(() => { - const result = evaluateGoalStatus(goalData); - setGoalStatus(result.status); - setProgress(result.progressPercentage); - }, []); - - // Register goal with notification server on mount - useEffect(() => { - if (wsConnected) { - registerGoal({ - currentBalance: goalData.currentBalance, - targetAmount: goalData.targetAmount, - targetDate: goalData.targetDate, - expectedAPY: goalData.expectedAPY, - monthlyContribution: goalData.monthlyContribution, - }); - } - }, [wsConnected]); - - const handleSendMessage = (e: React.FormEvent) => { - e.preventDefault(); - if (!inputState.trim()) return; - - const userMsg: Message = { id: Date.now(), sender: 'user', text: inputState }; - setMessages(prev => [...prev, userMsg]); - setInputState(''); - setIsTyping(true); - - // Mock agent response delay - setTimeout(() => { - setIsTyping(false); - const agentMsg: Message = { - id: Date.now() + 1, - sender: 'agent', - text: "That's a great goal. I'll allocate 60% to Stellar Blend for safe yield, and the rest to Soroswap XLM/USDC LP to accelerate returns. Does that sound good?" - }; - setMessages(prev => [...prev, agentMsg]); - - // Parse allocations from agent message - const parsedAllocations = parseAllocationsFromMessage(agentMsg.text); - if (parsedAllocations) { - setAllocations(parsedAllocations); - } - }, 1800); - }; + const goalResult = evaluateGoalStatus(goalData); + const progress = goalResult.progressPercentage; + const goalStatus = goalResult.status; return ( @@ -207,54 +112,13 @@ export default function Home() {
{/* Right Panel - Chat Agent */} -
-
-
-
- -
-
-

OpenClaw Agent

-
- Online -
-
-
- -
- {messages.map((msg) => ( -
- {msg.proactive && ( -
- Proactive Nudge -
- )} -
{msg.text}
-
- ))} - {isTyping && ( -
-
- -
-
- )} -
-
- -
- setInputState(e.target.value)} - /> - -
-
-
+ diff --git a/frontend/src/hooks/useChat.ts b/frontend/src/hooks/useChat.ts new file mode 100644 index 0000000..761bb3e --- /dev/null +++ b/frontend/src/hooks/useChat.ts @@ -0,0 +1,120 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useNotifications } from './useNotifications'; +import { parseAllocationsFromMessage, getDefaultAllocations } from '../utils/allocationParser'; +import type { AssetAllocation } from '../utils/chartUtils'; +import type { GoalData } from '../utils/goalProjection'; +import type { Message } from '../types/chat'; + +interface UseChatOptions { + userId: string; + goalData: GoalData; +} + +export function useChat({ userId, goalData }: UseChatOptions) { + const [messages, setMessages] = useState([ + { + id: 1, + sender: 'agent', + text: "Welcome to Smasage! 👋 I'm OpenClaw, your personal AI savings assistant natively built on Stellar. What financial goal can we crush today?", + }, + ]); + const [inputState, setInputState] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const [allocations, setAllocations] = useState(getDefaultAllocations()); + const [wsConnected, setWsConnected] = useState(false); + const responseTimeout = useRef(null); + + const { registerGoal } = useNotifications({ + userId, + onNotification: (notification) => { + if (notification.type === 'connected') { + setWsConnected(true); + } else if (notification.type === 'agent-message') { + const payload = notification.payload as { + text: string; + proactive?: boolean; + timestamp?: string; + }; + + const agentMsg: Message = { + id: Date.now(), + sender: 'agent', + text: payload.text, + proactive: payload.proactive, + timestamp: payload.timestamp, + }; + + setMessages((prev) => [...prev, agentMsg]); + + const parsedAllocations = parseAllocationsFromMessage(payload.text); + if (parsedAllocations) { + setAllocations(parsedAllocations); + } + } + }, + onError: (error) => { + console.error('[Chat] WebSocket error:', error); + }, + enabled: true, + }); + + useEffect(() => { + if (wsConnected) { + registerGoal({ + currentBalance: goalData.currentBalance, + targetAmount: goalData.targetAmount, + targetDate: goalData.targetDate, + expectedAPY: goalData.expectedAPY, + monthlyContribution: goalData.monthlyContribution, + }); + } + }, [wsConnected, registerGoal, goalData]); + + useEffect(() => { + return () => { + if (responseTimeout.current !== null) { + window.clearTimeout(responseTimeout.current); + } + }; + }, []); + + const handleSendMessage = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + if (!inputState.trim()) return; + + const userMsg: Message = { id: Date.now(), sender: 'user', text: inputState }; + setMessages((prev) => [...prev, userMsg]); + setInputState(''); + setIsTyping(true); + + responseTimeout.current = window.setTimeout(() => { + setIsTyping(false); + + const agentMsg: Message = { + id: Date.now() + 1, + sender: 'agent', + text: "That's a great goal. I'll allocate 60% to Stellar Blend for safe yield, and the rest to Soroswap XLM/USDC LP to accelerate returns. Does that sound good?", + }; + + setMessages((prev) => [...prev, agentMsg]); + + const parsedAllocations = parseAllocationsFromMessage(agentMsg.text); + if (parsedAllocations) { + setAllocations(parsedAllocations); + } + }, 1800); + }, + [inputState] + ); + + return { + messages, + inputState, + setInputState, + isTyping, + handleSendMessage, + allocations, + wsConnected, + }; +} diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts new file mode 100644 index 0000000..2c47037 --- /dev/null +++ b/frontend/src/types/chat.ts @@ -0,0 +1,7 @@ +export interface Message { + id: number; + sender: 'agent' | 'user'; + text: string; + proactive?: boolean; + timestamp?: string; +}