diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 3d2d35e..d13e672 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -1,9 +1,10 @@ - -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { Message } from "@/lib/api"; import { getMessages, sendMessage } from "@/lib/api"; import { wsClient } from "@/lib/ws"; +const PAGE_SIZE = 30; + const AUTHOR_ICON: Record = { human: "👤", agent: "🤖", @@ -20,19 +21,32 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) { const [input, setInput] = useState(""); const [open, setOpen] = useState(false); const [sending, setSending] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [hasMore, setHasMore] = useState(true); const bottomRef = useRef(null); + const scrollRef = useRef(null); + const initialLoad = useRef(true); + // Initial load — last N messages useEffect(() => { - if (chatId) { - getMessages({ chat_id: chatId, limit: 50 }).then(setMessages).catch(() => {}); - } + if (!chatId) return; + initialLoad.current = true; + setHasMore(true); + getMessages({ chat_id: chatId, limit: PAGE_SIZE }) + .then((msgs) => { + setMessages(msgs); + setHasMore(msgs.length >= PAGE_SIZE); + // Scroll to bottom on initial load + setTimeout(() => bottomRef.current?.scrollIntoView(), 50); + }) + .catch(() => {}); }, [chatId]); + // WS real-time messages useEffect(() => { const unsub = wsClient.on("message.new", (data: any) => { if (data.chat_id === chatId) { setMessages((prev) => { - // Dedup by message id if (prev.some((m) => m.id === data.id)) return prev; return [...prev, data as Message]; }); @@ -41,10 +55,61 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) { return () => { unsub?.(); }; }, [chatId]); + // Auto-scroll to bottom on new messages (only if already near bottom) useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + if (initialLoad.current) { + initialLoad.current = false; + return; + } + const el = scrollRef.current; + if (!el) return; + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100; + if (nearBottom) { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + } }, [messages]); + // Load older messages on scroll to top + const loadOlder = useCallback(async () => { + if (loadingOlder || !hasMore || !chatId || messages.length === 0) return; + setLoadingOlder(true); + const el = scrollRef.current; + const prevHeight = el?.scrollHeight || 0; + + try { + const older = await getMessages({ + chat_id: chatId, + limit: PAGE_SIZE, + offset: messages.length, + }); + if (older.length < PAGE_SIZE) setHasMore(false); + if (older.length > 0) { + setMessages((prev) => { + // Dedup + const existingIds = new Set(prev.map((m) => m.id)); + const unique = older.filter((m) => !existingIds.has(m.id)); + return [...unique, ...prev]; + }); + // Maintain scroll position + requestAnimationFrame(() => { + if (el) el.scrollTop = el.scrollHeight - prevHeight; + }); + } + } catch (e) { + console.error("Failed to load older messages:", e); + } finally { + setLoadingOlder(false); + } + }, [chatId, messages.length, loadingOlder, hasMore]); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + if (el.scrollTop < 50 && hasMore && !loadingOlder) { + loadOlder(); + } + }, [loadOlder, hasMore, loadingOlder]); + const handleSend = async () => { if (!input.trim() || !chatId || sending) return; const text = input.trim(); @@ -52,7 +117,11 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) { setSending(true); try { const msg = await sendMessage({ chat_id: chatId, content: text }); - setMessages((prev) => [...prev, msg]); + setMessages((prev) => { + if (prev.some((m) => m.id === msg.id)) return prev; + return [...prev, msg]; + }); + setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: "smooth" }), 50); } catch (e) { console.error("Failed to send:", e); setInput(text); @@ -61,21 +130,33 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) { } }; + const renderMessage = (msg: Message) => ( +
+
+ {AUTHOR_ICON[msg.author_type] || "👤"} + {msg.author_slug} + · + {new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })} +
+
{msg.content}
+
+ ); + if (fullscreen) { return (
-
- {messages.map((msg) => ( -
-
- {AUTHOR_ICON[msg.author_type] || "👤"} - {msg.author_slug} - · - {new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })} -
-
{msg.content}
-
- ))} +
+ {loadingOlder && ( +
Загрузка...
+ )} + {!hasMore && messages.length > 0 && ( +
— начало истории —
+ )} + {messages.map(renderMessage)} {messages.length === 0 && (
Нет сообщений
)} @@ -113,7 +194,14 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) { {open && (
-
+
+ {loadingOlder && ( +
Загрузка...
+ )} {messages.map((msg) => (
diff --git a/src/lib/api.ts b/src/lib/api.ts index 05cd026..0028c01 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -225,11 +225,12 @@ export async function deleteStep(taskId: string, stepId: string): Promise // --- Messages (unified: chat + task comments) --- -export async function getMessages(params: { chat_id?: string; task_id?: string; limit?: number }): Promise { +export async function getMessages(params: { chat_id?: string; task_id?: string; limit?: number; offset?: number }): Promise { const qs = new URLSearchParams(); if (params.chat_id) qs.set("chat_id", params.chat_id); if (params.task_id) qs.set("task_id", params.task_id); if (params.limit) qs.set("limit", String(params.limit)); + if (params.offset) qs.set("offset", String(params.offset)); return request(`/api/v1/messages?${qs}`); }