web-client/src/components/ChatPanel.tsx
Markov cb5b2afe16
All checks were successful
Deploy Web Client / deploy (push) Successful in 36s
feat: chat send via WebSocket instead of REST
2026-02-15 23:50:40 +01:00

187 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useRef, useState } from "react";
import {
Chat,
ChatMessage,
getProjectChat,
getChatMessages,
} from "@/lib/api";
import { wsClient } from "@/lib/ws";
interface ChatPanelProps {
projectId: string;
}
export default function ChatPanel({ projectId }: ChatPanelProps) {
const [chat, setChat] = useState<Chat | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [expanded, setExpanded] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Load chat and messages
useEffect(() => {
let cancelled = false;
getProjectChat(projectId).then((c) => {
if (cancelled) return;
setChat(c);
getChatMessages(c.id).then((msgs) => {
if (!cancelled) setMessages(msgs);
});
});
return () => { cancelled = true; };
}, [projectId]);
// WebSocket: subscribe to chat room
useEffect(() => {
if (!chat) return;
wsClient.connect();
wsClient.subscribeChat(chat.id);
const unsub = wsClient.on("chat.message", (msg: any) => {
if (msg.chat_id === chat.id) {
setMessages((prev) => [
...prev,
{
id: msg.id,
chat_id: msg.chat_id,
sender_type: msg.sender_type,
sender_id: msg.sender_id,
sender_name: msg.sender_name,
content: msg.content,
created_at: msg.created_at,
},
]);
}
});
return () => {
wsClient.unsubscribeChat(chat.id);
unsub();
};
}, [chat]);
// Auto-scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = () => {
if (!chat || !input.trim()) return;
const text = input.trim();
setInput("");
// Send via WebSocket — message will come back via chat.message event
wsClient.send("chat.send", {
chat_id: chat.id,
content: text,
sender_type: "human",
sender_name: "admin",
});
inputRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
if (collapsed) {
return (
<div className="border-b border-[var(--border)] px-4 py-2 flex items-center justify-between bg-[var(--bg-secondary)]">
<button
onClick={() => setCollapsed(false)}
className="text-sm text-[var(--muted)] hover:text-[var(--fg)] flex items-center gap-2"
>
💬 Чат проекта
{messages.length > 0 && (
<span className="bg-[var(--accent)] text-white text-xs px-1.5 py-0.5 rounded-full">
{messages.length}
</span>
)}
</button>
</div>
);
}
const panelHeight = expanded ? "h-full" : "h-[30vh]";
return (
<div className={`${panelHeight} border-b border-[var(--border)] flex flex-col bg-[var(--bg-secondary)] transition-all duration-200`}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)]">
<span className="text-sm font-medium">💬 Чат проекта</span>
<div className="flex items-center gap-1">
<button
onClick={() => setExpanded(!expanded)}
className="text-[var(--muted)] hover:text-[var(--fg)] p-1 text-sm"
title={expanded ? "Свернуть" : "Развернуть"}
>
{expanded ? "⬆️" : "⬇️"}
</button>
<button
onClick={() => { setCollapsed(true); setExpanded(false); }}
className="text-[var(--muted)] hover:text-[var(--fg)] p-1 text-sm"
title="Скрыть"
>
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-2 space-y-2">
{messages.length === 0 && (
<p className="text-sm text-[var(--muted)] text-center py-4">
Пока пусто. Напишите первое сообщение 💬
</p>
)}
{messages.map((msg) => (
<div key={msg.id} className="flex gap-2 text-sm">
<span className={`font-medium shrink-0 ${
msg.sender_type === "agent" ? "text-blue-400" :
msg.sender_type === "system" ? "text-yellow-500" :
"text-green-400"
}`}>
{msg.sender_name || msg.sender_type}:
</span>
<span className="text-[var(--fg)] break-words">{msg.content}</span>
<span className="text-[var(--muted)] text-xs shrink-0 self-end">
{new Date(msg.created_at).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="px-4 py-2 border-t border-[var(--border)] flex gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Написать сообщение..."
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-3 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
disabled={!chat}
/>
<button
onClick={handleSend}
disabled={!chat || !input.trim()}
className="bg-[var(--accent)] text-white px-4 py-1.5 rounded text-sm hover:opacity-90 disabled:opacity-50"
>
</button>
</div>
</div>
);
}