feat: chat interface with gateway WebSocket integration (HQ-21)
- GatewayClient class: WS connection, auto-reconnect, request/response, events - ChatPage with thread list sidebar + message area - Real-time streaming responses via chat events - Thread management with localStorage persistence - Message bubbles with user/assistant/system styles - Build args for VITE_WS_URL and VITE_WS_TOKEN - SPA routing already supported by nginx config
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
FROM oven/bun:1 AS build
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_WS_URL=""
|
||||
ARG VITE_WS_TOKEN=""
|
||||
ENV VITE_WS_URL=$VITE_WS_URL
|
||||
ENV VITE_WS_TOKEN=$VITE_WS_TOKEN
|
||||
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
|
||||
|
||||
196
frontend/src/lib/gateway.ts
Normal file
196
frontend/src/lib/gateway.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// Gateway WebSocket client for Hammer Dashboard chat
|
||||
|
||||
type MessageHandler = (msg: any) => void;
|
||||
type StateHandler = (connected: boolean) => void;
|
||||
|
||||
let reqCounter = 0;
|
||||
function nextId() {
|
||||
return `r${++reqCounter}`;
|
||||
}
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private token: string;
|
||||
private connected = false;
|
||||
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void }>();
|
||||
private eventHandlers = new Map<string, Set<MessageHandler>>();
|
||||
private stateHandlers = new Set<StateHandler>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = true;
|
||||
|
||||
constructor(url: string, token: string) {
|
||||
this.url = url;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.shouldReconnect = true;
|
||||
this._connect();
|
||||
}
|
||||
|
||||
private _connect() {
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
// Send connect handshake
|
||||
const connectId = nextId();
|
||||
this._send({
|
||||
type: "req",
|
||||
id: connectId,
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: "webchat",
|
||||
displayName: "Hammer Dashboard",
|
||||
version: "1.0.0",
|
||||
platform: "web",
|
||||
mode: "webchat",
|
||||
instanceId: `dash-${Date.now()}`,
|
||||
},
|
||||
auth: {
|
||||
token: this.token,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for hello-ok
|
||||
this.pendingRequests.set(connectId, {
|
||||
resolve: () => {
|
||||
this.connected = true;
|
||||
this.stateHandlers.forEach((h) => h(true));
|
||||
},
|
||||
reject: (err) => {
|
||||
console.error("Gateway connect failed:", err);
|
||||
this.connected = false;
|
||||
this.stateHandlers.forEach((h) => h(false));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
this._handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse gateway message:", e);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.connected = false;
|
||||
this.stateHandlers.forEach((h) => h(false));
|
||||
if (this.shouldReconnect) {
|
||||
this.reconnectTimer = setTimeout(() => this._connect(), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose will handle reconnect
|
||||
};
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
this.ws = null;
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
onStateChange(handler: StateHandler) {
|
||||
this.stateHandlers.add(handler);
|
||||
return () => this.stateHandlers.delete(handler);
|
||||
}
|
||||
|
||||
on(event: string, handler: MessageHandler) {
|
||||
if (!this.eventHandlers.has(event)) {
|
||||
this.eventHandlers.set(event, new Set());
|
||||
}
|
||||
this.eventHandlers.get(event)!.add(handler);
|
||||
return () => this.eventHandlers.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
async request(method: string, params?: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.ws || !this.connected) {
|
||||
reject(new Error("Not connected"));
|
||||
return;
|
||||
}
|
||||
const id = nextId();
|
||||
this.pendingRequests.set(id, { resolve, reject });
|
||||
this._send({ type: "req", id, method, params });
|
||||
|
||||
// Timeout after 60s
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error("Request timeout"));
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
// Chat methods
|
||||
async chatHistory(sessionKey: string, limit = 50) {
|
||||
return this.request("chat.history", { sessionKey, limit });
|
||||
}
|
||||
|
||||
async chatSend(sessionKey: string, message: string) {
|
||||
return this.request("chat.send", {
|
||||
sessionKey,
|
||||
message,
|
||||
idempotencyKey: `dash-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async chatAbort(sessionKey: string) {
|
||||
return this.request("chat.abort", { sessionKey });
|
||||
}
|
||||
|
||||
async sessionsList(limit = 50) {
|
||||
return this.request("sessions.list", { limit });
|
||||
}
|
||||
|
||||
private _send(msg: any) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMessage(msg: any) {
|
||||
if (msg.type === "res") {
|
||||
const pending = this.pendingRequests.get(msg.id);
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(msg.id);
|
||||
if (msg.ok) {
|
||||
pending.resolve(msg.payload);
|
||||
} else {
|
||||
pending.reject(new Error(msg.error?.message || "Request failed"));
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "event") {
|
||||
const handlers = this.eventHandlers.get(msg.event);
|
||||
if (handlers) {
|
||||
handlers.forEach((h) => h(msg.payload));
|
||||
}
|
||||
// Also fire wildcard handlers
|
||||
const wildcardHandlers = this.eventHandlers.get("*");
|
||||
if (wildcardHandlers) {
|
||||
wildcardHandlers.forEach((h) => h({ event: msg.event, ...msg.payload }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,449 @@
|
||||
export function ChatPage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Page Header */}
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-30">
|
||||
<div className="max-w-4xl mx-auto px-6 py-4">
|
||||
<h1 className="text-xl font-bold text-gray-900">Chat</h1>
|
||||
<p className="text-sm text-gray-400">Talk to Hammer directly</p>
|
||||
</div>
|
||||
</header>
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { GatewayClient } from "../lib/gateway";
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-6">
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-12 text-center">
|
||||
<span className="text-4xl mb-4 block">💬</span>
|
||||
<h2 className="text-lg font-semibold text-gray-600 mb-2">Chat coming soon</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
You'll be able to chat with Hammer right here in the dashboard.
|
||||
</p>
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || `wss://${window.location.hostname.replace("dash.", "ws.hammer.")}`;
|
||||
const WS_TOKEN = import.meta.env.VITE_WS_TOKEN || "";
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface ChatThread {
|
||||
sessionKey: string;
|
||||
name: string;
|
||||
lastMessage?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function ThreadList({
|
||||
threads,
|
||||
activeThread,
|
||||
onSelect,
|
||||
onCreate,
|
||||
}: {
|
||||
threads: ChatThread[];
|
||||
activeThread: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-64 bg-white border-r border-gray-200 flex flex-col h-full">
|
||||
<div className="p-3 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-600">Threads</h3>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="text-xs bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600 transition font-medium"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{threads.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400 text-center">
|
||||
No threads yet
|
||||
</div>
|
||||
) : (
|
||||
threads.map((thread) => (
|
||||
<button
|
||||
key={thread.sessionKey}
|
||||
onClick={() => onSelect(thread.sessionKey)}
|
||||
className={`w-full text-left px-3 py-3 border-b border-gray-50 transition ${
|
||||
activeThread === thread.sessionKey
|
||||
? "bg-amber-50 border-l-2 border-l-amber-500"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-800 truncate">
|
||||
{thread.name}
|
||||
</div>
|
||||
{thread.lastMessage && (
|
||||
<div className="text-xs text-gray-400 truncate mt-0.5">
|
||||
{thread.lastMessage}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ msg }: { msg: ChatMessage }) {
|
||||
const isUser = msg.role === "user";
|
||||
const isSystem = msg.role === "system";
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="text-center my-2">
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-3 py-1 rounded-full">
|
||||
{msg.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? "justify-end" : "justify-start"} mb-3`}>
|
||||
{!isUser && (
|
||||
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center text-sm mr-2 shrink-0 mt-1">
|
||||
🔨
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[75%] rounded-2xl px-4 py-2.5 ${
|
||||
isUser
|
||||
? "bg-blue-500 text-white rounded-br-md"
|
||||
: "bg-gray-100 text-gray-800 rounded-bl-md"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatArea({
|
||||
messages,
|
||||
loading,
|
||||
streaming,
|
||||
streamText,
|
||||
onSend,
|
||||
onAbort,
|
||||
connected,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
loading: boolean;
|
||||
streaming: boolean;
|
||||
streamText: string;
|
||||
onSend: (msg: string) => void;
|
||||
onAbort: () => void;
|
||||
connected: boolean;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, streamText]);
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim();
|
||||
if (!text || !connected) return;
|
||||
onSend(text);
|
||||
setInput("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-400 py-12">Loading messages...</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-12">
|
||||
<span className="text-4xl block mb-3">🔨</span>
|
||||
<p className="text-sm">Send a message to start chatting with Hammer</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} msg={msg} />
|
||||
))}
|
||||
{streaming && streamText && (
|
||||
<MessageBubble msg={{ role: "assistant", content: streamText }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-gray-200 bg-white px-4 py-3">
|
||||
{!connected && (
|
||||
<div className="text-xs text-red-500 mb-2 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full" />
|
||||
Disconnected — reconnecting...
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 items-end">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={connected ? "Type a message..." : "Connecting..."}
|
||||
disabled={!connected}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-gray-200 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300 disabled:opacity-50 max-h-32"
|
||||
style={{ minHeight: "42px" }}
|
||||
/>
|
||||
{streaming ? (
|
||||
<button
|
||||
onClick={onAbort}
|
||||
className="px-4 py-2.5 bg-red-500 text-white rounded-xl text-sm font-medium hover:bg-red-600 transition shrink-0"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !connected}
|
||||
className="px-4 py-2.5 bg-amber-500 text-white rounded-xl text-sm font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatPage() {
|
||||
const [gateway] = useState(() => new GatewayClient(WS_URL, WS_TOKEN));
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [threads, setThreads] = useState<ChatThread[]>(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("hammer-chat-threads") || "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [activeThread, setActiveThread] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [streamText, setStreamText] = useState("");
|
||||
|
||||
// Persist threads to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("hammer-chat-threads", JSON.stringify(threads));
|
||||
}, [threads]);
|
||||
|
||||
// Connect to gateway
|
||||
useEffect(() => {
|
||||
if (!WS_TOKEN) return;
|
||||
|
||||
gateway.connect();
|
||||
const unsub = gateway.onStateChange(setConnected);
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
gateway.disconnect();
|
||||
};
|
||||
}, [gateway]);
|
||||
|
||||
// Listen for chat events (streaming responses)
|
||||
useEffect(() => {
|
||||
const unsub = gateway.on("chat", (payload: any) => {
|
||||
if (payload.sessionKey !== activeThread) return;
|
||||
|
||||
if (payload.state === "delta" && payload.message?.content) {
|
||||
setStreaming(true);
|
||||
// Accumulate delta text
|
||||
const textParts = payload.message.content
|
||||
.filter((c: any) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("");
|
||||
if (textParts) {
|
||||
setStreamText((prev) => prev + textParts);
|
||||
}
|
||||
} else if (payload.state === "final") {
|
||||
// Final message — add to messages
|
||||
if (payload.message?.content) {
|
||||
const text = payload.message.content
|
||||
.filter((c: any) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("");
|
||||
if (text) {
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: text }]);
|
||||
// Update thread last message
|
||||
setThreads((prev) =>
|
||||
prev.map((t) =>
|
||||
t.sessionKey === activeThread
|
||||
? { ...t, lastMessage: text.slice(0, 100), updatedAt: Date.now() }
|
||||
: t
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
setStreaming(false);
|
||||
setStreamText("");
|
||||
} else if (payload.state === "aborted" || payload.state === "error") {
|
||||
setStreaming(false);
|
||||
if (streamText) {
|
||||
// Save partial response
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: streamText }]);
|
||||
}
|
||||
setStreamText("");
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [gateway, activeThread, streamText]);
|
||||
|
||||
// Load messages when thread changes
|
||||
const loadMessages = useCallback(
|
||||
async (sessionKey: string) => {
|
||||
setLoading(true);
|
||||
setMessages([]);
|
||||
try {
|
||||
const result = await gateway.chatHistory(sessionKey);
|
||||
if (result?.messages) {
|
||||
const msgs: ChatMessage[] = result.messages
|
||||
.filter((m: any) => m.role === "user" || m.role === "assistant")
|
||||
.map((m: any) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content:
|
||||
typeof m.content === "string"
|
||||
? m.content
|
||||
: Array.isArray(m.content)
|
||||
? m.content
|
||||
.filter((c: any) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("")
|
||||
: "",
|
||||
}))
|
||||
.filter((m: ChatMessage) => m.content);
|
||||
setMessages(msgs);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load chat history:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[gateway]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeThread && connected) {
|
||||
loadMessages(activeThread);
|
||||
}
|
||||
}, [activeThread, connected, loadMessages]);
|
||||
|
||||
const handleCreateThread = () => {
|
||||
const id = `dash:chat:${Date.now()}`;
|
||||
const thread: ChatThread = {
|
||||
sessionKey: id,
|
||||
name: `Chat ${threads.length + 1}`,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
setThreads((prev) => [thread, ...prev]);
|
||||
setActiveThread(id);
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
const handleSend = async (text: string) => {
|
||||
if (!activeThread) return;
|
||||
|
||||
// Add user message immediately
|
||||
setMessages((prev) => [...prev, { role: "user", content: text }]);
|
||||
setThreads((prev) =>
|
||||
prev.map((t) =>
|
||||
t.sessionKey === activeThread
|
||||
? { ...t, lastMessage: text.slice(0, 100), updatedAt: Date.now() }
|
||||
: t
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await gateway.chatSend(activeThread, text);
|
||||
} catch (e) {
|
||||
console.error("Failed to send:", e);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "system", content: "Failed to send message. Please try again." },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbort = async () => {
|
||||
if (!activeThread) return;
|
||||
try {
|
||||
await gateway.chatAbort(activeThread);
|
||||
} catch (e) {
|
||||
console.error("Failed to abort:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-create first thread if none exist
|
||||
useEffect(() => {
|
||||
if (threads.length === 0) {
|
||||
handleCreateThread();
|
||||
} else if (!activeThread) {
|
||||
setActiveThread(threads[0].sessionKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!WS_TOKEN) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center p-8">
|
||||
<span className="text-4xl block mb-4">🔒</span>
|
||||
<h2 className="text-lg font-semibold text-gray-600 mb-2">Chat not configured</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Gateway WebSocket token not set. Add VITE_WS_TOKEN to environment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
{/* Page Header */}
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-30">
|
||||
<div className="px-6 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-gray-900">Chat</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Chat body */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<ThreadList
|
||||
threads={threads}
|
||||
activeThread={activeThread}
|
||||
onSelect={(key) => setActiveThread(key)}
|
||||
onCreate={handleCreateThread}
|
||||
/>
|
||||
{activeThread ? (
|
||||
<ChatArea
|
||||
messages={messages}
|
||||
loading={loading}
|
||||
streaming={streaming}
|
||||
streamText={streamText}
|
||||
onSend={handleSend}
|
||||
onAbort={handleAbort}
|
||||
connected={connected}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400">
|
||||
Select or create a thread
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user