feat: infinite scroll — load older messages on scroll up
This commit is contained in:
parent
c96bd88f2a
commit
f4f09bfeee
@ -1,9 +1,10 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import type { Message } from "@/lib/api";
|
import type { Message } from "@/lib/api";
|
||||||
import { getMessages, sendMessage } from "@/lib/api";
|
import { getMessages, sendMessage } from "@/lib/api";
|
||||||
import { wsClient } from "@/lib/ws";
|
import { wsClient } from "@/lib/ws";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
const AUTHOR_ICON: Record<string, string> = {
|
const AUTHOR_ICON: Record<string, string> = {
|
||||||
human: "👤",
|
human: "👤",
|
||||||
agent: "🤖",
|
agent: "🤖",
|
||||||
@ -20,19 +21,32 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) {
|
|||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const initialLoad = useRef(true);
|
||||||
|
|
||||||
|
// Initial load — last N messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chatId) {
|
if (!chatId) return;
|
||||||
getMessages({ chat_id: chatId, limit: 50 }).then(setMessages).catch(() => {});
|
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]);
|
}, [chatId]);
|
||||||
|
|
||||||
|
// WS real-time messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = wsClient.on("message.new", (data: any) => {
|
const unsub = wsClient.on("message.new", (data: any) => {
|
||||||
if (data.chat_id === chatId) {
|
if (data.chat_id === chatId) {
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
// Dedup by message id
|
|
||||||
if (prev.some((m) => m.id === data.id)) return prev;
|
if (prev.some((m) => m.id === data.id)) return prev;
|
||||||
return [...prev, data as Message];
|
return [...prev, data as Message];
|
||||||
});
|
});
|
||||||
@ -41,10 +55,61 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) {
|
|||||||
return () => { unsub?.(); };
|
return () => { unsub?.(); };
|
||||||
}, [chatId]);
|
}, [chatId]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom on new messages (only if already near bottom)
|
||||||
useEffect(() => {
|
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]);
|
}, [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 () => {
|
const handleSend = async () => {
|
||||||
if (!input.trim() || !chatId || sending) return;
|
if (!input.trim() || !chatId || sending) return;
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
@ -52,7 +117,11 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) {
|
|||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
const msg = await sendMessage({ chat_id: chatId, content: text });
|
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) {
|
} catch (e) {
|
||||||
console.error("Failed to send:", e);
|
console.error("Failed to send:", e);
|
||||||
setInput(text);
|
setInput(text);
|
||||||
@ -61,21 +130,33 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderMessage = (msg: Message) => (
|
||||||
|
<div key={msg.id} className="text-sm">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
|
||||||
|
<span>{AUTHOR_ICON[msg.author_type] || "👤"}</span>
|
||||||
|
<span className="font-medium">{msg.author_slug}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-5 whitespace-pre-wrap">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (fullscreen) {
|
if (fullscreen) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3">
|
<div
|
||||||
{messages.map((msg) => (
|
ref={scrollRef}
|
||||||
<div key={msg.id} className="text-sm">
|
onScroll={handleScroll}
|
||||||
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
|
className="flex-1 overflow-y-auto px-6 py-4 space-y-3"
|
||||||
<span>{AUTHOR_ICON[msg.author_type] || "👤"}</span>
|
>
|
||||||
<span className="font-medium">{msg.author_slug}</span>
|
{loadingOlder && (
|
||||||
<span>·</span>
|
<div className="text-center text-xs text-[var(--muted)] py-2">Загрузка...</div>
|
||||||
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
)}
|
||||||
</div>
|
{!hasMore && messages.length > 0 && (
|
||||||
<div className="ml-5 whitespace-pre-wrap">{msg.content}</div>
|
<div className="text-center text-xs text-[var(--muted)] py-2">— начало истории —</div>
|
||||||
</div>
|
)}
|
||||||
))}
|
{messages.map(renderMessage)}
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div className="text-sm text-[var(--muted)] italic py-8 text-center">Нет сообщений</div>
|
<div className="text-sm text-[var(--muted)] italic py-8 text-center">Нет сообщений</div>
|
||||||
)}
|
)}
|
||||||
@ -113,7 +194,14 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) {
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="flex flex-col h-[calc(100%-2.5rem)]">
|
<div className="flex flex-col h-[calc(100%-2.5rem)]">
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-2 space-y-2">
|
<div
|
||||||
|
ref={!fullscreen ? scrollRef : undefined}
|
||||||
|
onScroll={!fullscreen ? handleScroll : undefined}
|
||||||
|
className="flex-1 overflow-y-auto px-4 py-2 space-y-2"
|
||||||
|
>
|
||||||
|
{loadingOlder && (
|
||||||
|
<div className="text-center text-xs text-[var(--muted)] py-1">Загрузка...</div>
|
||||||
|
)}
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div key={msg.id} className="text-sm">
|
<div key={msg.id} className="text-sm">
|
||||||
<span className="text-xs text-[var(--muted)]">
|
<span className="text-xs text-[var(--muted)]">
|
||||||
|
|||||||
@ -225,11 +225,12 @@ export async function deleteStep(taskId: string, stepId: string): Promise<void>
|
|||||||
|
|
||||||
// --- Messages (unified: chat + task comments) ---
|
// --- Messages (unified: chat + task comments) ---
|
||||||
|
|
||||||
export async function getMessages(params: { chat_id?: string; task_id?: string; limit?: number }): Promise<Message[]> {
|
export async function getMessages(params: { chat_id?: string; task_id?: string; limit?: number; offset?: number }): Promise<Message[]> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.chat_id) qs.set("chat_id", params.chat_id);
|
if (params.chat_id) qs.set("chat_id", params.chat_id);
|
||||||
if (params.task_id) qs.set("task_id", params.task_id);
|
if (params.task_id) qs.set("task_id", params.task_id);
|
||||||
if (params.limit) qs.set("limit", String(params.limit));
|
if (params.limit) qs.set("limit", String(params.limit));
|
||||||
|
if (params.offset) qs.set("offset", String(params.offset));
|
||||||
return request(`/api/v1/messages?${qs}`);
|
return request(`/api/v1/messages?${qs}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user