Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions frontend/src/app/components/ChatInterface.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"use client";

import React, { useEffect, useRef, useState } from "react";
import { AlertCircle, Bot, CheckCircle2, Send } from "lucide-react";

export interface ChatMessage {
id: number;
sender: "agent" | "user";
text: string;
proactive?: boolean;
timestamp?: string;
}

export interface ChatInterfaceProps {
messages: ChatMessage[];
isTyping: boolean;
onSendMessage: (message: string) => void;
placeholder?: string;
}

export function ChatInterface({
messages,
isTyping,
onSendMessage,
placeholder = "Ask Smasage to adjust goals...",
}: ChatInterfaceProps) {
const [inputValue, setInputValue] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);

useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isTyping]);

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmed = inputValue.trim();
if (!trimmed) return;

onSendMessage(trimmed);
setInputValue("");
};

return (
<div className="chat-container">
<div className="chat-header">
<div className="agent-avatar" aria-hidden="true">
<Bot size={28} />
</div>
<div>
<h2 style={{ margin: 0, fontSize: "1.25rem" }}>OpenClaw Agent</h2>
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
fontSize: "0.85rem",
color: "var(--success)",
}}
>
<CheckCircle2
size={12}
fill="var(--success)"
color="var(--bg-card)"
aria-hidden="true"
/>{" "}
Online
</div>
</div>
</div>

<div
className="chat-messages"
role="log"
aria-label="Chat messages"
aria-live="polite"
aria-relevant="additions"
>
{messages.map((msg) => (
<div key={msg.id} className={`message ${msg.sender}`}>
{msg.proactive && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
fontSize: "0.75rem",
color: "var(--accent-primary)",
marginBottom: "4px",
fontWeight: 600,
textTransform: "uppercase",
}}
>
<AlertCircle size={12} aria-hidden="true" /> Proactive Nudge
</div>
)}
<div className="message-bubble">{msg.text}</div>
</div>
))}

{isTyping && (
<div className="message agent" role="status">
<span className="sr-only">Agent is typing...</span>
<div className="typing-indicator" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</div>
)}

<div ref={messagesEndRef} />
</div>

<form onSubmit={handleSubmit} className="chat-input-container">
<input
id="chat-input"
type="text"
placeholder={placeholder}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
aria-label="Message input"
/>
<button type="submit" className="send-button" aria-label="Send message">
<Send size={18} aria-hidden="true" />
</button>
</form>
</div>
);
}
8 changes: 5 additions & 3 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,9 @@ h2 {

.agent .message-bubble {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom-left-radius: 4px;
color: var(--text-main);
}
Expand Down Expand Up @@ -501,7 +503,7 @@ h2 {
border-radius: 12px;
border-bottom-left-radius: 4px;
width: fit-content;
border: 1px solid var(--border);
border: 1px solid rgba(255, 255, 255, 0.1);
}

.typing-indicator span {
Expand Down Expand Up @@ -621,7 +623,7 @@ h2 {
outline: none; /* replaced by explicit ring below */
border-color: var(--accent-primary);
background: rgba(0, 0, 0, 0.45);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15), inset 0 0 8px rgba(139, 92, 246, 0.1);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
}

.chat-input-container input:focus-visible {
Expand Down
146 changes: 19 additions & 127 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
"use client";
import React, { useState, useEffect, useRef, useMemo } from "react";
import {
Bot,
Send,
Target,
Activity,
CheckCircle2,
AlertCircle,
} from "lucide-react";
import React, { useState, useEffect, useMemo } from "react";
import { Target, Activity } from "lucide-react";
import { PortfolioStats } from "./components/PortfolioStats";
import {
evaluateGoalStatus,
Expand Down Expand Up @@ -35,14 +28,7 @@ import {
PortfolioChartSkeleton,
} from "./components/SkeletonLoader";
import { WalletModal } from "./components/WalletModal";

interface Message {
id: number;
sender: "agent" | "user";
text: string;
proactive?: boolean;
timestamp?: string;
}
import { ChatInterface, type ChatMessage } from "./components/ChatInterface";

export default function Home() {
const {
Expand All @@ -53,14 +39,13 @@ export default function Home() {
isConnecting
} = useFreighter();

const [messages, setMessages] = useState<Message[]>([
const [messages, setMessages] = useState<ChatMessage[]>([
{
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<AssetAllocation[]>(
Expand All @@ -69,7 +54,6 @@ export default function Home() {

const [wsConnected, setWsConnected] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);

// Goal data (Memoized to avoid unnecessary effect triggers)
const goalData = useMemo<GoalData>(() => ({
Expand Down Expand Up @@ -100,14 +84,14 @@ export default function Home() {
} else if (isAgentMessageNotification(notification)) {
// payload is fully typed as AgentMessagePayload — no cast needed
const { text, proactive, timestamp } = notification.payload;
const agentMsg: Message = {
const agentMsg: ChatMessage = {
id: Date.now(),
sender: "agent",
text,
proactive,
timestamp,
};
setMessages((prev) => [...prev, agentMsg]);
setMessages((prev: ChatMessage[]) => [...prev, agentMsg]);

// Parse allocations if present
const parsedAllocations = parseAllocationsFromMessage(text);
Expand All @@ -128,11 +112,6 @@ export default function Home() {
return () => clearTimeout(t);
}, []);

// Auto scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isTyping]);

// Register goal with notification server on mount
useEffect(() => {
if (wsConnected) {
Expand All @@ -146,28 +125,27 @@ export default function Home() {
}
}, [wsConnected, registerGoal, goalData]);

const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!inputState.trim()) return;
const handleSendMessage = (message: string) => {
const trimmed = message.trim();
if (!trimmed) return;

const userMsg: Message = {
const userMsg: ChatMessage = {
id: Date.now(),
sender: "user",
text: inputState,
text: trimmed,
};
setMessages((prev) => [...prev, userMsg]);
setInputState("");
setMessages((prev: ChatMessage[]) => [...prev, userMsg]);
setIsTyping(true);

// Mock agent response delay
setTimeout(() => {
setIsTyping(false);
const agentMsg: Message = {
const agentMsg: ChatMessage = {
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]);
setMessages((prev: ChatMessage[]) => [...prev, agentMsg]);

// Parse allocations from agent message
const parsedAllocations = parseAllocationsFromMessage(agentMsg.text);
Expand Down Expand Up @@ -299,97 +277,11 @@ export default function Home() {

{/* Right Panel - Chat Agent */}
<div className="glass-panel">
<div className="chat-container">
<div className="chat-header">
<div className="agent-avatar" aria-hidden="true">
<Bot size={28} />
</div>
<div>
<h2 style={{ margin: 0, fontSize: "1.25rem" }}>
OpenClaw Agent
</h2>
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
fontSize: "0.85rem",
color: "var(--success)",
}}
>
<CheckCircle2
size={12}
fill="var(--success)"
color="var(--bg-card)"
aria-hidden="true"
/>{" "}
Online
</div>
</div>
</div>

<div
className="chat-messages"
role="log"
aria-label="Chat messages"
aria-live="polite"
aria-relevant="additions"
>
{messages.map((msg) => (
<div key={msg.id} className={`message ${msg.sender}`}>
{msg.proactive && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
fontSize: "0.75rem",
color: "var(--accent-primary)",
marginBottom: "4px",
fontWeight: 600,
textTransform: "uppercase",
}}
>
<AlertCircle size={12} aria-hidden="true" /> Proactive Nudge
</div>
)}
<div className="message-bubble">{msg.text}</div>
</div>
))}
{isTyping && (
<div className="message agent" role="status">
<span className="sr-only">Agent is typing…</span>
<div className="typing-indicator" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>

<form
onSubmit={handleSendMessage}
className="chat-input-container"
>
<input
id="chat-input"
type="text"
placeholder="Ask Smasage to adjust goals..."
value={inputState}
onChange={(e) => setInputState(e.target.value)}
aria-label="Message input"
/>
<button
type="submit"
className="send-button"
aria-label="Send message"
>
<Send size={18} aria-hidden="true" />
</button>
</form>
</div>
<ChatInterface
messages={messages}
isTyping={isTyping}
onSendMessage={handleSendMessage}
/>
</div>
</main>
</>
Expand Down
Loading