refactor: remove compact/collapsible chat mode — ChatPanel is fullscreen only

This commit is contained in:
Markov 2026-02-24 13:22:46 +01:00
parent a01bfbfc40
commit 44884d937c
2 changed files with 55 additions and 129 deletions

View File

@ -13,84 +13,71 @@ const AUTHOR_ICON: Record<string, string> = {
interface Props { interface Props {
chatId: string; chatId: string;
fullscreen?: boolean;
} }
export default function ChatPanel({ chatId, fullscreen = false }: Props) { export default function ChatPanel({ chatId }: Props) {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [open, setOpen] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const bottomRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const initialLoad = useRef(true); const bottomRef = useRef<HTMLDivElement>(null);
const isInitial = useRef(true);
// Initial load — last N messages // Load initial messages
useEffect(() => { useEffect(() => {
if (!chatId) return; if (!chatId) return;
initialLoad.current = true; isInitial.current = true;
setHasMore(true); setHasMore(true);
getMessages({ chat_id: chatId, limit: PAGE_SIZE }) getMessages({ chat_id: chatId, limit: PAGE_SIZE })
.then((msgs) => { .then((msgs) => {
setMessages(msgs); setMessages(msgs);
setHasMore(msgs.length >= PAGE_SIZE); setHasMore(msgs.length >= PAGE_SIZE);
// Scroll to bottom on initial load
setTimeout(() => bottomRef.current?.scrollIntoView(), 50); setTimeout(() => bottomRef.current?.scrollIntoView(), 50);
}) })
.catch(() => {}); .catch(() => {});
}, [chatId]); }, [chatId]);
// WS real-time messages // WS: new messages
useEffect(() => { useEffect(() => {
const unsub = wsClient.on("message.new", (data: any) => { const unsub = wsClient.on("message.new", (msg: Message) => {
if (data.chat_id === chatId) { if (msg.chat_id !== chatId) return;
setMessages((prev) => { setMessages((prev) => {
if (prev.some((m) => m.id === data.id)) return prev; if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, data as Message]; return [...prev, msg];
}); });
}
}); });
return () => { unsub?.(); }; return () => { unsub?.(); };
}, [chatId]); }, [chatId]);
// Auto-scroll to bottom on new messages (only if already near bottom) // Auto-scroll on new messages (only if near bottom)
useEffect(() => { useEffect(() => {
if (initialLoad.current) { if (isInitial.current) {
initialLoad.current = false; isInitial.current = false;
return; return;
} }
const el = scrollRef.current; const el = scrollRef.current;
if (!el) return; if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100; if (el.scrollHeight - el.scrollTop - el.clientHeight < 100) {
if (nearBottom) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); bottomRef.current?.scrollIntoView({ behavior: "smooth" });
} }
}, [messages]); }, [messages]);
// Load older messages on scroll to top // Load older messages
const loadOlder = useCallback(async () => { const loadOlder = useCallback(async () => {
if (loadingOlder || !hasMore || !chatId || messages.length === 0) return; if (loadingOlder || !hasMore || !chatId || messages.length === 0) return;
setLoadingOlder(true); setLoadingOlder(true);
const el = scrollRef.current; const el = scrollRef.current;
const prevHeight = el?.scrollHeight || 0; const prevHeight = el?.scrollHeight || 0;
try { try {
const older = await getMessages({ const older = await getMessages({ chat_id: chatId, limit: PAGE_SIZE, offset: messages.length });
chat_id: chatId,
limit: PAGE_SIZE,
offset: messages.length,
});
if (older.length < PAGE_SIZE) setHasMore(false); if (older.length < PAGE_SIZE) setHasMore(false);
if (older.length > 0) { if (older.length > 0) {
setMessages((prev) => { setMessages((prev) => {
// Dedup const ids = new Set(prev.map((m) => m.id));
const existingIds = new Set(prev.map((m) => m.id)); return [...older.filter((m) => !ids.has(m.id)), ...prev];
const unique = older.filter((m) => !existingIds.has(m.id));
return [...unique, ...prev];
}); });
// Maintain scroll position
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (el) el.scrollTop = el.scrollHeight - prevHeight; if (el) el.scrollTop = el.scrollHeight - prevHeight;
}); });
@ -104,10 +91,7 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) {
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = scrollRef.current; const el = scrollRef.current;
if (!el) return; if (el && el.scrollTop < 50 && hasMore && !loadingOlder) loadOlder();
if (el.scrollTop < 50 && hasMore && !loadingOlder) {
loadOlder();
}
}, [loadOlder, hasMore, loadingOlder]); }, [loadOlder, hasMore, loadingOlder]);
const handleSend = async () => { const handleSend = async () => {
@ -150,7 +134,6 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) {
); );
}; };
if (fullscreen) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div <div
@ -188,61 +171,4 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) {
</div> </div>
</div> </div>
); );
}
// Compact mode (collapsible)
return (
<div className={`border-b border-[var(--border)] transition-all ${open ? "h-64" : "h-10"}`}>
<button
onClick={() => setOpen(!open)}
className="w-full h-10 px-4 flex items-center text-sm text-[var(--muted)] hover:text-[var(--fg)]"
>
💬 Чат {open ? "▼" : "▲"}
</button>
{open && (
<div className="flex flex-col h-[calc(100%-2.5rem)]">
<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) => {
const bgStyle =
msg.author_type === "system"
? { backgroundColor: "rgba(161, 120, 0, 0.2)", borderRadius: 6, padding: "4px 8px" }
: msg.author_type === "agent"
? { backgroundColor: "rgba(0, 120, 60, 0.2)", borderRadius: 6, padding: "4px 8px" }
: undefined;
return (
<div key={msg.id} className="text-sm" style={bgStyle}>
<span className="text-xs text-[var(--muted)]">
{AUTHOR_ICON[msg.author_type] || "👤"} {msg.author_slug}
</span>
<span className="ml-2">{msg.content}</span>
</div>
);
})}
{messages.length === 0 && (
<div className="text-sm text-[var(--muted)] italic py-4 text-center">Нет сообщений</div>
)}
<div ref={bottomRef} />
</div>
<div className="px-4 py-2 border-t border-[var(--border)] flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
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)]"
/>
<button onClick={handleSend} disabled={sending} className="text-sm text-[var(--accent)] px-2 disabled:opacity-50"></button>
</div>
</div>
)}
</div>
);
} }

View File

@ -84,7 +84,7 @@ export default function ProjectPage() {
<KanbanBoard projectId={project.id} projectSlug={project.slug} /> <KanbanBoard projectId={project.id} projectSlug={project.slug} />
)} )}
{activeTab === "chat" && project.chat_id && ( {activeTab === "chat" && project.chat_id && (
<ChatPanel chatId={project.chat_id} fullscreen /> <ChatPanel chatId={project.chat_id} />
)} )}
{activeTab === "chat" && !project.chat_id && ( {activeTab === "chat" && !project.chat_id && (
<div className="flex items-center justify-center h-full text-[var(--muted)] text-sm"> <div className="flex items-center justify-center h-full text-[var(--muted)] text-sm">