refactor: remove compact/collapsible chat mode — ChatPanel is fullscreen only
This commit is contained in:
parent
a01bfbfc40
commit
44884d937c
@ -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,99 +134,41 @@ export default function ChatPanel({ chatId, fullscreen = false }: Props) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fullscreen) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
className="flex-1 overflow-y-auto px-6 py-4 space-y-3"
|
|
||||||
>
|
|
||||||
{loadingOlder && (
|
|
||||||
<div className="text-center text-xs text-[var(--muted)] py-2">Загрузка...</div>
|
|
||||||
)}
|
|
||||||
{!hasMore && messages.length > 0 && (
|
|
||||||
<div className="text-center text-xs text-[var(--muted)] py-2">— начало истории —</div>
|
|
||||||
)}
|
|
||||||
{messages.map(renderMessage)}
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<div className="text-sm text-[var(--muted)] italic py-8 text-center">Нет сообщений</div>
|
|
||||||
)}
|
|
||||||
<div ref={bottomRef} />
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-3 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-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={sending}
|
|
||||||
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
↵
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compact mode (collapsible)
|
|
||||||
return (
|
return (
|
||||||
<div className={`border-b border-[var(--border)] transition-all ${open ? "h-64" : "h-10"}`}>
|
<div className="flex flex-col h-full">
|
||||||
<button
|
<div
|
||||||
onClick={() => setOpen(!open)}
|
ref={scrollRef}
|
||||||
className="w-full h-10 px-4 flex items-center text-sm text-[var(--muted)] hover:text-[var(--fg)]"
|
onScroll={handleScroll}
|
||||||
|
className="flex-1 overflow-y-auto px-6 py-4 space-y-3"
|
||||||
>
|
>
|
||||||
💬 Чат {open ? "▼" : "▲"}
|
{loadingOlder && (
|
||||||
</button>
|
<div className="text-center text-xs text-[var(--muted)] py-2">Загрузка...</div>
|
||||||
|
)}
|
||||||
{open && (
|
{!hasMore && messages.length > 0 && (
|
||||||
<div className="flex flex-col h-[calc(100%-2.5rem)]">
|
<div className="text-center text-xs text-[var(--muted)] py-2">— начало истории —</div>
|
||||||
<div
|
)}
|
||||||
ref={!fullscreen ? scrollRef : undefined}
|
{messages.map(renderMessage)}
|
||||||
onScroll={!fullscreen ? handleScroll : undefined}
|
{messages.length === 0 && (
|
||||||
className="flex-1 overflow-y-auto px-4 py-2 space-y-2"
|
<div className="text-sm text-[var(--muted)] italic py-8 text-center">Нет сообщений</div>
|
||||||
>
|
)}
|
||||||
{loadingOlder && (
|
<div ref={bottomRef} />
|
||||||
<div className="text-center text-xs text-[var(--muted)] py-1">Загрузка...</div>
|
</div>
|
||||||
)}
|
<div className="px-6 py-3 border-t border-[var(--border)] flex gap-2">
|
||||||
{messages.map((msg) => {
|
<input
|
||||||
const bgStyle =
|
value={input}
|
||||||
msg.author_type === "system"
|
onChange={(e) => setInput(e.target.value)}
|
||||||
? { backgroundColor: "rgba(161, 120, 0, 0.2)", borderRadius: 6, padding: "4px 8px" }
|
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||||
: msg.author_type === "agent"
|
placeholder="Сообщение..."
|
||||||
? { backgroundColor: "rgba(0, 120, 60, 0.2)", borderRadius: 6, padding: "4px 8px" }
|
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)]"
|
||||||
: undefined;
|
/>
|
||||||
return (
|
<button
|
||||||
<div key={msg.id} className="text-sm" style={bgStyle}>
|
onClick={handleSend}
|
||||||
<span className="text-xs text-[var(--muted)]">
|
disabled={sending}
|
||||||
{AUTHOR_ICON[msg.author_type] || "👤"} {msg.author_slug}
|
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
|
||||||
</span>
|
>
|
||||||
<span className="ml-2">{msg.content}</span>
|
↵
|
||||||
</div>
|
</button>
|
||||||
);
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user