187 lines
5.7 KiB
TypeScript
187 lines
5.7 KiB
TypeScript
"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>
|
||
);
|
||
}
|