feat: redesign AgentChatPage - Agent-centric UI with side panels

- Rewrite AgentChatPage as primary interface with Perplexity-style layout
- Add right sidebar with Market Ticker, Positions, and Trader Status panels
- Sidebar is collapsible and responsive (auto-hides on mobile)
- Each sidebar section independently collapsible
- Chat area: centered 720px max-width with smooth streaming effect
- Simple markdown rendering (bold, code blocks, inline code)
- Quick action chips redesigned as pill buttons with hover effects
- Input upgraded to auto-resizing textarea
- New components: MarketTicker, PositionsPanel, TraderStatusPanel
- Dark theme consistent with NOFXi brand colors
- Real-time data via SWR for positions and trader status
This commit is contained in:
shinchan-zhai
2026-03-23 02:10:48 +08:00
parent 5382e89cae
commit a41ee3ca3d
4 changed files with 1025 additions and 83 deletions

View File

@@ -0,0 +1,132 @@
import { useState, useEffect } from 'react'
import { TrendingUp, TrendingDown, RefreshCw } from 'lucide-react'
interface TickerData {
symbol: string
lastPrice: string
priceChangePercent: string
highPrice: string
lowPrice: string
volume: string
}
const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT']
export function MarketTicker() {
const [tickers, setTickers] = useState<Record<string, TickerData>>({})
const [loading, setLoading] = useState(true)
const fetchTickers = async () => {
try {
const results = await Promise.all(
SYMBOLS.map(async (symbol) => {
const res = await fetch(`/api/agent/ticker?symbol=${symbol}`)
const data = await res.json()
return { symbol, ...data }
})
)
const map: Record<string, TickerData> = {}
results.forEach((r) => {
if (r.lastPrice) map[r.symbol] = r
})
setTickers(map)
} catch {
// ignore
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTickers()
const interval = setInterval(fetchTickers, 15000)
return () => clearInterval(interval)
}, [])
const formatPrice = (price: string) => {
const n = parseFloat(price)
if (n >= 1000) return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
if (n >= 1) return n.toFixed(2)
return n.toFixed(4)
}
const formatVolume = (vol: string) => {
const n = parseFloat(vol)
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
return n.toFixed(0)
}
if (loading) {
return (
<div style={{ padding: '12px 14px', color: '#5c5c72', fontSize: 12 }}>
<RefreshCw size={14} className="animate-spin inline mr-2" />
Loading market data...
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SYMBOLS.map((sym) => {
const t = tickers[sym]
if (!t) return null
const pct = parseFloat(t.priceChangePercent)
const isUp = pct >= 0
const color = isUp ? '#00e5a0' : '#F6465D'
const label = sym.replace('USDT', '')
return (
<div
key={sym}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
background: '#0d0d15',
borderRadius: 10,
border: '1px solid #1a1a28',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 7,
background: isUp ? 'rgba(0,229,160,0.08)' : 'rgba(246,70,93,0.08)',
display: 'grid',
placeItems: 'center',
}}
>
{isUp ? (
<TrendingUp size={14} color={color} />
) : (
<TrendingDown size={14} color={color} />
)}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}>
{label}
</div>
<div style={{ fontSize: 10, color: '#5c5c72' }}>
Vol {formatVolume(t.volume)}
</div>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}>
${formatPrice(t.lastPrice)}
</div>
<div style={{ fontSize: 11, fontWeight: 500, color }}>
{isUp ? '+' : ''}{pct.toFixed(2)}%
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,147 @@
import useSWR from 'swr'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { ArrowUpRight, ArrowDownRight, Wallet } from 'lucide-react'
import type { Position, TraderInfo } from '../../types'
export function PositionsPanel() {
const { user, token } = useAuth()
const { data: traders } = useSWR<TraderInfo[]>(
user && token ? 'agent-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
)
// Get first running trader's positions
const runningTrader = traders?.find((t) => t.is_running)
const traderId = runningTrader?.trader_id
const { data: positions } = useSWR<Position[]>(
traderId ? `agent-positions-${traderId}` : null,
() => api.getPositions(traderId),
{ refreshInterval: 15000, shouldRetryOnError: false }
)
if (!user || !token) {
return (
<div
style={{
padding: '20px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
<Wallet size={20} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
<div>Login to view positions</div>
</div>
)
}
const openPositions = positions?.filter((p) => p.quantity !== 0) || []
if (openPositions.length === 0) {
return (
<div
style={{
padding: '16px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
No open positions
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{openPositions.map((pos, i) => {
const pnl = pos.unrealized_pnl
const isProfit = pnl >= 0
const color = isProfit ? '#00e5a0' : '#F6465D'
const side = pos.side?.toUpperCase() || (pos.quantity > 0 ? 'LONG' : 'SHORT')
const symbol = (pos.symbol || '').replace('USDT', '')
return (
<div
key={i}
style={{
padding: '10px 12px',
background: '#0d0d15',
borderRadius: 10,
border: '1px solid #1a1a28',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: '#eaeaf0',
}}
>
{symbol}
</span>
<span
style={{
fontSize: 10,
fontWeight: 600,
padding: '1px 5px',
borderRadius: 4,
background:
side === 'LONG'
? 'rgba(0,229,160,0.12)'
: 'rgba(246,70,93,0.12)',
color: side === 'LONG' ? '#00e5a0' : '#F6465D',
}}
>
{side}
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 3,
color,
fontSize: 12,
fontWeight: 600,
}}
>
{isProfit ? (
<ArrowUpRight size={12} />
) : (
<ArrowDownRight size={12} />
)}
{isProfit ? '+' : ''}
{pnl.toFixed(2)}
</div>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
color: '#5c5c72',
}}
>
<span>Qty: {pos.quantity}</span>
<span>Entry: {pos.entry_price.toFixed(2)}</span>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,110 @@
import useSWR from 'swr'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { Activity, CircleOff, Bot } from 'lucide-react'
import type { TraderInfo } from '../../types'
export function TraderStatusPanel() {
const { user, token } = useAuth()
const { data: traders } = useSWR<TraderInfo[]>(
user && token ? 'agent-sidebar-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
)
if (!user || !token) {
return (
<div
style={{
padding: '20px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
<Bot size={20} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
<div>Login to view traders</div>
</div>
)
}
if (!traders || traders.length === 0) {
return (
<div
style={{
padding: '16px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
No traders configured
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{traders.map((trader) => (
<div
key={trader.trader_id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
background: '#0d0d15',
borderRadius: 10,
border: '1px solid #1a1a28',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 7,
background: trader.is_running
? 'rgba(0,229,160,0.08)'
: 'rgba(92,92,114,0.08)',
display: 'grid',
placeItems: 'center',
}}
>
{trader.is_running ? (
<Activity size={14} color="#00e5a0" />
) : (
<CircleOff size={14} color="#5c5c72" />
)}
</div>
<div>
<div
style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}
>
{trader.trader_name}
</div>
<div style={{ fontSize: 10, color: '#5c5c72' }}>
{trader.trader_id.slice(0, 8)}...
</div>
</div>
</div>
<div
style={{
fontSize: 10,
fontWeight: 600,
padding: '3px 8px',
borderRadius: 6,
background: trader.is_running
? 'rgba(0,229,160,0.12)'
: 'rgba(92,92,114,0.12)',
color: trader.is_running ? '#00e5a0' : '#5c5c72',
}}
>
{trader.is_running ? 'RUNNING' : 'STOPPED'}
</div>
</div>
))}
</div>
)
}