feat: remove chat section from dashboard
- Remove Chat from sidebar navigation - Remove /chat route from App.tsx - Delete ChatPage component, gateway.ts client lib - Delete backend chat routes and gateway-relay WebSocket code - No other features depended on removed code
This commit is contained in:
@@ -9,7 +9,6 @@ import { useSession } from "./lib/auth-client";
|
||||
// Lazy-loaded pages for code splitting
|
||||
const DashboardPage = lazy(() => import("./pages/DashboardPage").then(m => ({ default: m.DashboardPage })));
|
||||
const QueuePage = lazy(() => import("./pages/QueuePage").then(m => ({ default: m.QueuePage })));
|
||||
const ChatPage = lazy(() => import("./pages/ChatPage").then(m => ({ default: m.ChatPage })));
|
||||
const ProjectsPage = lazy(() => import("./pages/ProjectsPage").then(m => ({ default: m.ProjectsPage })));
|
||||
const TaskPage = lazy(() => import("./pages/TaskPage").then(m => ({ default: m.TaskPage })));
|
||||
const ActivityPage = lazy(() => import("./pages/ActivityPage").then(m => ({ default: m.ActivityPage })));
|
||||
@@ -36,7 +35,6 @@ function AuthenticatedApp() {
|
||||
<Route path="/queue" element={<Suspense fallback={<PageLoader />}><QueuePage /></Suspense>} />
|
||||
<Route path="/task/:taskRef" element={<Suspense fallback={<PageLoader />}><TaskPage /></Suspense>} />
|
||||
<Route path="/projects" element={<Suspense fallback={<PageLoader />}><ProjectsPage /></Suspense>} />
|
||||
<Route path="/chat" element={<Suspense fallback={<PageLoader />}><ChatPage /></Suspense>} />
|
||||
<Route path="/activity" element={<Suspense fallback={<PageLoader />}><ActivityPage /></Suspense>} />
|
||||
<Route path="/admin" element={<Suspense fallback={<PageLoader />}><AdminPage /></Suspense>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -12,7 +12,6 @@ const navItems = [
|
||||
{ to: "/queue", label: "Queue", icon: "📋", badgeKey: "queue" },
|
||||
{ to: "/projects", label: "Projects", icon: "📁", badgeKey: null },
|
||||
{ to: "/activity", label: "Activity", icon: "📝", badgeKey: null },
|
||||
{ to: "/chat", label: "Chat", icon: "💬", badgeKey: null },
|
||||
] as const;
|
||||
|
||||
export function DashboardLayout() {
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* Chat WebSocket client for Hammer Dashboard
|
||||
*
|
||||
* Connects to the dashboard backend's WebSocket relay (which proxies to the Clawdbot gateway).
|
||||
* Authentication is handled via BetterAuth session cookie.
|
||||
*/
|
||||
|
||||
type MessageHandler = (msg: any) => void;
|
||||
type StateHandler = (state: "connecting" | "connected" | "disconnected") => void;
|
||||
|
||||
let reqCounter = 0;
|
||||
function nextId() {
|
||||
return `r${++reqCounter}`;
|
||||
}
|
||||
|
||||
export class ChatClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private state: "connecting" | "connected" | "disconnected" = "disconnected";
|
||||
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;
|
||||
|
||||
connect() {
|
||||
this.shouldReconnect = true;
|
||||
this._connect();
|
||||
}
|
||||
|
||||
private _connect() {
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
|
||||
// Build WebSocket URL from current page origin
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
// Backend is at the same origin via nginx proxy on Dokploy
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/chat/ws`;
|
||||
|
||||
this.setState("connecting");
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
// Send auth message with session cookie
|
||||
this._send({
|
||||
type: "auth",
|
||||
cookie: document.cookie,
|
||||
});
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
this._handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse message:", e);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.setState("disconnected");
|
||||
if (this.shouldReconnect) {
|
||||
this.reconnectTimer = setTimeout(() => this._connect(), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose handles reconnect
|
||||
};
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
this.ws = null;
|
||||
this.setState("disconnected");
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.state === "connected";
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
onStateChange(handler: StateHandler): () => void {
|
||||
this.stateHandlers.add(handler);
|
||||
return () => { this.stateHandlers.delete(handler); };
|
||||
}
|
||||
|
||||
on(event: string, handler: MessageHandler): () => void {
|
||||
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.state !== "connected") {
|
||||
reject(new Error("Not connected"));
|
||||
return;
|
||||
}
|
||||
const id = nextId();
|
||||
this.pendingRequests.set(id, { resolve, reject });
|
||||
this._send({ type: method, id, ...params });
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error("Request timeout"));
|
||||
}
|
||||
}, 120000);
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
async chatAbort(sessionKey: string) {
|
||||
return this.request("chat.abort", { sessionKey });
|
||||
}
|
||||
|
||||
async sessionsList(limit = 50) {
|
||||
return this.request("sessions.list", { limit });
|
||||
}
|
||||
|
||||
private setState(state: "connecting" | "connected" | "disconnected") {
|
||||
this.state = state;
|
||||
this.stateHandlers.forEach((h) => h(state));
|
||||
}
|
||||
|
||||
private _send(msg: any) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMessage(msg: any) {
|
||||
// Auth response
|
||||
if (msg.type === "auth_ok") {
|
||||
this.setState("connected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "error" && this.state !== "connected") {
|
||||
console.error("Auth failed:", msg.error);
|
||||
this.shouldReconnect = false;
|
||||
this.ws?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Request response
|
||||
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 || "Request failed"));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Error for a specific request
|
||||
if (msg.type === "error" && msg.id) {
|
||||
const pending = this.pendingRequests.get(msg.id);
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(msg.id);
|
||||
pending.reject(new Error(msg.error || "Request failed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Gateway events (forwarded from backend)
|
||||
if (msg.type === "event") {
|
||||
const handlers = this.eventHandlers.get(msg.event);
|
||||
if (handlers) {
|
||||
handlers.forEach((h) => h(msg.payload));
|
||||
}
|
||||
const wildcardHandlers = this.eventHandlers.get("*");
|
||||
if (wildcardHandlers) {
|
||||
wildcardHandlers.forEach((h) => h({ event: msg.event, ...msg.payload }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
let _client: ChatClient | null = null;
|
||||
|
||||
export function getChatClient(): ChatClient {
|
||||
if (!_client) {
|
||||
_client = new ChatClient();
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
@@ -1,751 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { getChatClient, type ChatClient } from "../lib/gateway";
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface ChatThread {
|
||||
sessionKey: string;
|
||||
name: string;
|
||||
lastMessage?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface GatewaySession {
|
||||
sessionKey: string;
|
||||
kind?: string;
|
||||
channel?: string;
|
||||
lastActivity?: string;
|
||||
messageCount?: number;
|
||||
}
|
||||
|
||||
function ThreadList({
|
||||
threads,
|
||||
activeThread,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onRename,
|
||||
onDelete,
|
||||
onClose,
|
||||
onBrowseSessions,
|
||||
gatewaySessions,
|
||||
loadingGatewaySessions,
|
||||
showGatewaySessions,
|
||||
onToggleGatewaySessions,
|
||||
}: {
|
||||
threads: ChatThread[];
|
||||
activeThread: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
onCreate: () => void;
|
||||
onRename?: (key: string, name: string) => void;
|
||||
onDelete?: (key: string) => void;
|
||||
onClose?: () => void;
|
||||
onBrowseSessions?: () => void;
|
||||
gatewaySessions?: GatewaySession[];
|
||||
loadingGatewaySessions?: boolean;
|
||||
showGatewaySessions?: boolean;
|
||||
onToggleGatewaySessions?: () => void;
|
||||
}) {
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
|
||||
const startRename = (key: string, currentName: string) => {
|
||||
setEditingKey(key);
|
||||
setEditName(currentName);
|
||||
};
|
||||
|
||||
const commitRename = () => {
|
||||
if (editingKey && editName.trim() && onRename) {
|
||||
onRename(editingKey, editName.trim());
|
||||
}
|
||||
setEditingKey(null);
|
||||
};
|
||||
|
||||
// Check if a session key exists in local threads already
|
||||
const localSessionKeys = new Set(threads.map(t => t.sessionKey));
|
||||
|
||||
return (
|
||||
<div className="w-full sm:w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col h-full">
|
||||
<div className="p-3 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-600 dark:text-gray-400">Threads</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="sm:hidden text-gray-400 hover:text-gray-600 p-1"
|
||||
aria-label="Close threads"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Local threads */}
|
||||
{threads.length === 0 && !showGatewaySessions ? (
|
||||
<div className="p-4 text-sm text-gray-400 dark:text-gray-500 text-center">
|
||||
No threads yet
|
||||
</div>
|
||||
) : (
|
||||
threads.map((thread) => (
|
||||
<div
|
||||
key={thread.sessionKey}
|
||||
className={`group relative w-full text-left px-3 py-3 border-b border-gray-50 dark:border-gray-800 transition cursor-pointer ${
|
||||
activeThread === thread.sessionKey
|
||||
? "bg-amber-50 dark:bg-amber-900/20 border-l-2 border-l-amber-500"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
onClick={() => onSelect(thread.sessionKey)}
|
||||
>
|
||||
{editingKey === thread.sessionKey ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="text-sm font-medium text-gray-800 dark:text-gray-200 w-full bg-white dark:bg-gray-800 border border-amber-300 dark:border-amber-700 rounded px-1 py-0.5 outline-none"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitRename();
|
||||
if (e.key === "Escape") setEditingKey(null);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate pr-6"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename(thread.sessionKey, thread.name);
|
||||
}}
|
||||
>
|
||||
{thread.name}
|
||||
</div>
|
||||
)}
|
||||
{thread.lastMessage && (
|
||||
<div className="text-xs text-gray-400 truncate mt-0.5">
|
||||
{thread.lastMessage}
|
||||
</div>
|
||||
)}
|
||||
{onDelete && editingKey !== thread.sessionKey && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(thread.sessionKey);
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 text-gray-300 hover:text-red-400 transition p-1"
|
||||
aria-label="Delete thread"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Gateway sessions browser */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-800">
|
||||
<button
|
||||
onClick={() => {
|
||||
onToggleGatewaySessions?.();
|
||||
if (!showGatewaySessions) onBrowseSessions?.();
|
||||
}}
|
||||
className="w-full px-3 py-2.5 text-xs font-semibold text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition flex items-center justify-between"
|
||||
>
|
||||
<span>🔌 Gateway Sessions</span>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 transition-transform ${showGatewaySessions ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{showGatewaySessions && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50">
|
||||
{loadingGatewaySessions ? (
|
||||
<div className="px-3 py-3 text-xs text-gray-400 dark:text-gray-500 text-center">Loading sessions...</div>
|
||||
) : !gatewaySessions || gatewaySessions.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs text-gray-400 dark:text-gray-500 text-center">No sessions found</div>
|
||||
) : (
|
||||
gatewaySessions.filter(s => !localSessionKeys.has(s.sessionKey)).map((session) => (
|
||||
<div
|
||||
key={session.sessionKey}
|
||||
className="px-3 py-2.5 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition"
|
||||
onClick={() => onSelect(session.sessionKey)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs">
|
||||
{session.channel === "telegram" ? "📱" : session.kind === "cron" ? "⏰" : "💬"}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
{session.sessionKey}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{session.channel && (
|
||||
<span className="text-[10px] text-gray-400">{session.channel}</span>
|
||||
)}
|
||||
{session.kind && (
|
||||
<span className="text-[10px] text-gray-400">{session.kind}</span>
|
||||
)}
|
||||
{session.messageCount != null && (
|
||||
<span className="text-[10px] text-gray-400">{session.messageCount} msgs</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimestamp(ts?: number): string {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function MessageBubble({ msg }: { msg: ChatMessage }) {
|
||||
const [showTime, setShowTime] = useState(false);
|
||||
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 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-full">
|
||||
{msg.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${isUser ? "justify-end" : "justify-start"} mb-3 group`}
|
||||
onClick={() => setShowTime(!showTime)}
|
||||
>
|
||||
{!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="flex flex-col">
|
||||
<div
|
||||
className={`max-w-[75vw] sm:max-w-[60vw] rounded-2xl px-4 py-2.5 ${
|
||||
isUser
|
||||
? "bg-blue-500 text-white rounded-br-md"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-bl-md"
|
||||
}`}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{msg.content}</p>
|
||||
) : (
|
||||
<div className="text-sm leading-relaxed prose prose-sm prose-gray max-w-none [&_pre]:bg-gray-800 [&_pre]:text-gray-100 [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:overflow-x-auto [&_pre]:text-xs [&_code]:bg-gray-200 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_p]:mb-2 [&_p:last-child]:mb-0 [&_ul]:mb-2 [&_ol]:mb-2 [&_li]:mb-0.5 [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-sm [&_a]:text-amber-600 [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-gray-300 [&_blockquote]:pl-3 [&_blockquote]:text-gray-500">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{msg.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showTime && msg.timestamp && (
|
||||
<span className={`text-[10px] text-gray-400 mt-0.5 ${isUser ? "text-right" : "text-left"}`}>
|
||||
{formatTimestamp(msg.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingIndicator() {
|
||||
return (
|
||||
<div className="flex justify-start mb-3">
|
||||
<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="bg-gray-100 dark:bg-gray-800 rounded-2xl rounded-bl-md px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatArea({
|
||||
messages,
|
||||
loading,
|
||||
streaming,
|
||||
thinking,
|
||||
streamText,
|
||||
onSend,
|
||||
onAbort,
|
||||
connectionState,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
loading: boolean;
|
||||
streaming: boolean;
|
||||
thinking: boolean;
|
||||
streamText: string;
|
||||
onSend: (msg: string) => void;
|
||||
onAbort: () => void;
|
||||
connectionState: "connecting" | "connected" | "disconnected";
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, streamText, thinking]);
|
||||
|
||||
// Auto-resize textarea
|
||||
const autoResize = useCallback(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "42px";
|
||||
el.style.height = Math.min(el.scrollHeight, 128) + "px";
|
||||
}, []);
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim();
|
||||
if (!text || connectionState !== "connected") return;
|
||||
onSend(text);
|
||||
setInput("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const connected = connectionState === "connected";
|
||||
|
||||
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 dark:text-gray-500 py-12">Loading messages...</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center text-gray-400 dark:text-gray-500 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 }} />
|
||||
)}
|
||||
{thinking && !streaming && <ThinkingIndicator />}
|
||||
</>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3">
|
||||
{connectionState === "disconnected" && (
|
||||
<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>
|
||||
)}
|
||||
{connectionState === "connecting" && (
|
||||
<div className="text-xs text-amber-500 mb-2 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse" />
|
||||
Connecting...
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 items-end">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); autoResize(); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={connected ? "Type a message..." : "Connecting..."}
|
||||
disabled={!connected}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 disabled:opacity-50 max-h-32 placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
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 [client] = useState<ChatClient>(() => getChatClient());
|
||||
const [connectionState, setConnectionState] = useState<"connecting" | "connected" | "disconnected">("disconnected");
|
||||
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 [thinking, setThinking] = useState(false);
|
||||
const [streamText, setStreamText] = useState("");
|
||||
const [showThreads, setShowThreads] = useState(false);
|
||||
const [gatewaySessions, setGatewaySessions] = useState<GatewaySession[]>([]);
|
||||
const [loadingGatewaySessions, setLoadingGatewaySessions] = useState(false);
|
||||
const [showGatewaySessions, setShowGatewaySessions] = useState(false);
|
||||
|
||||
// Persist threads to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("hammer-chat-threads", JSON.stringify(threads));
|
||||
}, [threads]);
|
||||
|
||||
// Connect client
|
||||
useEffect(() => {
|
||||
client.connect();
|
||||
const unsub = client.onStateChange(setConnectionState);
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
// Listen for chat events (streaming responses)
|
||||
useEffect(() => {
|
||||
const unsub = client.on("chat", (payload: any) => {
|
||||
if (payload.sessionKey !== activeThread) return;
|
||||
|
||||
if (payload.state === "delta" && payload.message?.content) {
|
||||
setThinking(false);
|
||||
setStreaming(true);
|
||||
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") {
|
||||
setThinking(false);
|
||||
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, timestamp: Date.now() }]);
|
||||
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") {
|
||||
setThinking(false);
|
||||
setStreaming(false);
|
||||
if (streamText) {
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: streamText }]);
|
||||
}
|
||||
setStreamText("");
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [client, activeThread, streamText]);
|
||||
|
||||
// Load messages when thread changes
|
||||
const loadMessages = useCallback(
|
||||
async (sessionKey: string) => {
|
||||
setLoading(true);
|
||||
setMessages([]);
|
||||
try {
|
||||
const result = await client.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);
|
||||
}
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeThread && connectionState === "connected") {
|
||||
loadMessages(activeThread);
|
||||
}
|
||||
}, [activeThread, connectionState, loadMessages]);
|
||||
|
||||
const handleBrowseSessions = useCallback(async () => {
|
||||
if (!client.isConnected()) return;
|
||||
setLoadingGatewaySessions(true);
|
||||
try {
|
||||
const result = await client.sessionsList(50);
|
||||
if (result?.sessions) {
|
||||
setGatewaySessions(result.sessions.map((s: any) => ({
|
||||
sessionKey: s.sessionKey || s.key,
|
||||
kind: s.kind,
|
||||
channel: s.channel,
|
||||
lastActivity: s.lastActivity,
|
||||
messageCount: s.messageCount,
|
||||
})));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load sessions:", e);
|
||||
} finally {
|
||||
setLoadingGatewaySessions(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
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 handleSelectThread = (sessionKey: string) => {
|
||||
// If it's a gateway session not in local threads, add it
|
||||
if (!threads.find(t => t.sessionKey === sessionKey)) {
|
||||
const gwSession = gatewaySessions.find(s => s.sessionKey === sessionKey);
|
||||
const thread: ChatThread = {
|
||||
sessionKey,
|
||||
name: gwSession?.channel
|
||||
? `${gwSession.channel} (${sessionKey.slice(0, 12)}...)`
|
||||
: sessionKey.length > 20
|
||||
? `${sessionKey.slice(0, 20)}...`
|
||||
: sessionKey,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
setThreads((prev) => [thread, ...prev]);
|
||||
}
|
||||
setActiveThread(sessionKey);
|
||||
};
|
||||
|
||||
const handleRenameThread = (key: string, name: string) => {
|
||||
setThreads((prev) => prev.map((t) => (t.sessionKey === key ? { ...t, name } : t)));
|
||||
};
|
||||
|
||||
const handleDeleteThread = (key: string) => {
|
||||
setThreads((prev) => prev.filter((t) => t.sessionKey !== key));
|
||||
if (activeThread === key) {
|
||||
const remaining = threads.filter((t) => t.sessionKey !== key);
|
||||
setActiveThread(remaining.length > 0 ? remaining[0].sessionKey : null);
|
||||
setMessages([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (text: string) => {
|
||||
if (!activeThread) return;
|
||||
|
||||
setMessages((prev) => [...prev, { role: "user", content: text, timestamp: Date.now() }]);
|
||||
setThinking(true);
|
||||
setThreads((prev) =>
|
||||
prev.map((t) =>
|
||||
t.sessionKey === activeThread
|
||||
? { ...t, lastMessage: text.slice(0, 100), updatedAt: Date.now() }
|
||||
: t
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await client.chatSend(activeThread, text);
|
||||
} catch (e) {
|
||||
console.error("Failed to send:", e);
|
||||
setThinking(false);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "system", content: "Failed to send message. Please try again." },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbort = async () => {
|
||||
if (!activeThread) return;
|
||||
try {
|
||||
await client.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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-3.5rem)] md:h-screen flex flex-col">
|
||||
{/* Page Header */}
|
||||
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-30">
|
||||
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowThreads(!showThreads)}
|
||||
className="sm:hidden p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition"
|
||||
aria-label="Toggle threads"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h8m-8 6h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-lg font-bold text-gray-900 dark:text-gray-100">Chat</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
connectionState === "connected"
|
||||
? "bg-green-500"
|
||||
: connectionState === "connecting"
|
||||
? "bg-amber-500 animate-pulse"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
{connectionState === "connected" ? "Connected" : connectionState === "connecting" ? "Connecting..." : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Chat body */}
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{/* Thread list */}
|
||||
<div className={`
|
||||
absolute inset-0 z-20 sm:relative sm:inset-auto sm:z-auto
|
||||
${showThreads ? "block" : "hidden"} sm:block
|
||||
`}>
|
||||
{showThreads && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30 sm:hidden"
|
||||
onClick={() => setShowThreads(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10 h-full">
|
||||
<ThreadList
|
||||
threads={threads}
|
||||
activeThread={activeThread}
|
||||
onSelect={(key) => {
|
||||
handleSelectThread(key);
|
||||
setShowThreads(false);
|
||||
}}
|
||||
onCreate={() => {
|
||||
handleCreateThread();
|
||||
setShowThreads(false);
|
||||
}}
|
||||
onRename={handleRenameThread}
|
||||
onDelete={handleDeleteThread}
|
||||
onClose={() => setShowThreads(false)}
|
||||
onBrowseSessions={handleBrowseSessions}
|
||||
gatewaySessions={gatewaySessions}
|
||||
loadingGatewaySessions={loadingGatewaySessions}
|
||||
showGatewaySessions={showGatewaySessions}
|
||||
onToggleGatewaySessions={() => setShowGatewaySessions(!showGatewaySessions)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{activeThread ? (
|
||||
<ChatArea
|
||||
messages={messages}
|
||||
loading={loading}
|
||||
streaming={streaming}
|
||||
thinking={thinking}
|
||||
streamText={streamText}
|
||||
onSend={handleSend}
|
||||
onAbort={handleAbort}
|
||||
connectionState={connectionState}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 p-4 text-center">
|
||||
<div>
|
||||
<span className="text-3xl block mb-2">💬</span>
|
||||
<p>Select or create a thread</p>
|
||||
<button
|
||||
onClick={() => setShowThreads(true)}
|
||||
className="sm:hidden mt-3 text-sm text-amber-500 font-medium"
|
||||
>
|
||||
View Threads →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user