diff --git a/application/frontend/src/pages/chatbot/chatbot.scss b/application/frontend/src/pages/chatbot/chatbot.scss index 7e745c4e..70e49cd9 100644 --- a/application/frontend/src/pages/chatbot/chatbot.scss +++ b/application/frontend/src/pages/chatbot/chatbot.scss @@ -1,19 +1,102 @@ +/* ========================= + Chat container & layout + ========================= */ + .chat-container { - margin-top: 1.25rem; - max-width: 960px; margin: 3rem auto; + margin-top: 1.5rem; + max-width: 960px; + display: flex; + flex-direction: column; + position: relative; +} +.chat-container.chat-active { + height: calc(100vh - 179px); + overflow: hidden; +} +@media (max-width: 768px) { + .chat-container { + padding: 0 1rem; + } } +@media (max-width: 360px) { + .chat-container { + padding: 0 0.75rem; + } +} + +/* ========================= + Chat messages wrapper + ========================= */ + .chat-messages { display: flex; flex-direction: column; gap: 1.25rem; + flex: 1; + overflow-y: auto; + padding-bottom: 1rem; + scroll-behavior: smooth; + + overscroll-behavior: contain; } -h1.ui.header { + +@media (max-width: 768px) { + .chat-messages { + gap: 0.75rem; + } +} + +/* ========================= + Header + ========================= */ + +/* ========================= + Chatbot title + ========================= */ + +h1.ui.header.chatbot-title { + font-weight: 600; + line-height: 1.25; + + /* Fluid scaling */ + font-size: clamp(1.4rem, 3vw, 2.2rem); + + /* Desktop spacing */ + margin-top: 2rem; margin-bottom: 1rem !important; } + +/* Tablet & below */ +@media (max-width: 768px) { + h1.ui.header.chatbot-title { + margin-top: 2.75rem; + margin-bottom: 0.75rem !important; + font-size: clamp(1.35rem, 4vw, 1.8rem); + } +} + +/* Very small phones */ +@media (max-width: 360px) { + h1.ui.header.chatbot-title { + margin-top: 3rem; /* extra breathing room */ + } +} + +/* ========================= + Message rows + ========================= */ + .chat-message { display: flex; + gap: 1.25rem; +} + +@media (max-width: 768px) { + .chat-message { + gap: 0.75rem; + } } .chat-message.user { @@ -24,24 +107,56 @@ h1.ui.header { justify-content: flex-start; } +/* ========================= + Message cards + ========================= */ + .message-card { max-width: 65%; background: #ffffff; border-radius: 16px; padding: 1rem 1.25rem; box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08); - line-height: 1.6; + text-align: left; + line-height: 1.6 consideration; animation: fadeInUp 0.25s ease-out; } +/* Tablets */ +@media (max-width: 1024px) { + .message-card { + max-width: 75%; + } +} + +/* Mobile */ +@media (max-width: 768px) { + .message-card { + max-width: 88%; + } +} + +/* Very small devices */ +@media (max-width: 360px) { + .message-card { + max-width: 92%; + } +} + .chat-message.user .message-card { - background: #e3f2fd; + background: #eaf4ff; + border-left: 4px solid #2185d0; } .chat-message.assistant .message-card { - background: #f1f8e9; + background: #f9fafb; + border-left: 4px solid #21ba45; } +/* ========================= + Message header & body + ========================= */ + .message-header { display: flex; justify-content: space-between; @@ -61,6 +176,7 @@ h1.ui.header { .message-body { font-size: 0.95rem; + text-align: left; p { margin: 0.5rem 0; @@ -78,6 +194,10 @@ h1.ui.header { } } +/* ========================= + References & warnings + ========================= */ + .references { margin-top: 0.75rem; border-top: 1px solid #e0e0e0; @@ -116,16 +236,34 @@ h1.ui.header { color: #b71c1c; } +/* ========================= + Chat input + ========================= */ + .chat-input { - margin-top: 1.5rem; - background-color: #d3ead4; - padding: 1rem; + position: sticky; + bottom: 0; + z-index: 5; + + margin-top: 1rem; + background-color: #daebdb; + // background: transparent; + border: 1px solid rgb(190, 214, 232); + padding: 0.75rem; border-radius: 12px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); } + .chat-input .ui.input input { + height: 40px; + line-height: 40px; border-radius: 10px !important; } + +/* ========================= + Disclaimer + ========================= */ + .chatbot-disclaimer { margin-top: 2.5rem; font-size: 1.01rem; @@ -136,7 +274,10 @@ h1.ui.header { line-height: 1.6; } -/* AI typing indicator bubble */ +/* ========================= + Typing indicator + ========================= */ + .typing-indicator { display: flex; gap: 0.4rem; @@ -144,6 +285,7 @@ h1.ui.header { min-height: 32px; padding: 0.75rem 1rem; } + .typing-indicator .dot { width: 8px; height: 8px; @@ -159,15 +301,109 @@ h1.ui.header { .typing-indicator .dot:nth-child(3) { animation-delay: 0.4s; } -.chat-message.user .message-card { - background: #eaf4ff; - border-left: 4px solid #2185d0; + +/* ========================= + Page layout overrides + ========================= */ + +.chatbot-layout { + min-height: 100vh; } -.chat-message.assistant .message-card { - background: #f9fafb; - border-left: 4px solid #21ba45; +@media (max-width: 768px) { + .chatbot-layout { + min-height: auto; + padding-top: 2rem; + } +} + +@media (max-width: 768px) { + .chatbot-layout.ui.grid { + align-items: flex-start !important; + } +} + +/* ========================= + Scroll to bottom button + ========================= */ + +.scroll-to-bottom { + position: absolute; + bottom: 160px; + left: 50%; + transform: translateX(-50%); + z-index: 10; + width: 34px; + height: 34px; + border-radius: 50%; + border: none; + background: rgb(47, 128, 189); + color: #c5f0c9; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: #1976d2; + opacity: 1; + backdrop-filter: none; + transition: transform 0.2s ease; + animation: fadeIn 0.15s ease-out; +} + +.scroll-to-bottom:hover { + opacity: 1; + transform: translateX(-50%) translateY(-2px); +} +.scroll-icon { + margin: 0 !important; + display: flex !important; + align-items: center; + justify-content: center; +} + +@media (max-width: 768px) { + .scroll-to-bottom { + bottom: 160px; + } +} +.chat-surface { + display: flex; + flex-direction: column; + height: 100%; + + background: #fcfcfa; // same palette as input + backdrop-filter: blur(6px); + + border-radius: 18px; + border: 1px solid rgba(0, 0, 0, 0.05); + + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.4); + + padding: 1.25rem; } +.chat-landing-state { + text-align: center; + margin: auto; + padding: 3rem 1rem; + + h2 { + font-size: 1.6rem; + font-weight: 600; + margin-bottom: 0.5rem; + } + + p { + font-size: 0.95rem; + color: #555; + max-width: 480px; + margin: 0 auto; + } +} +/* ========================= + Animations + ========================= */ + @keyframes typingBounce { 0%, 80%, @@ -191,3 +427,13 @@ h1.ui.header { transform: translateY(0); } } +@keyframes fadeIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(6px); + } + to { + opacity: 0.85; + transform: translateX(-50%) translateY(0); + } +} diff --git a/application/frontend/src/pages/chatbot/chatbot.tsx b/application/frontend/src/pages/chatbot/chatbot.tsx index 1f305b1e..61a06300 100644 --- a/application/frontend/src/pages/chatbot/chatbot.tsx +++ b/application/frontend/src/pages/chatbot/chatbot.tsx @@ -2,7 +2,7 @@ import './chatbot.scss'; import DOMPurify, { sanitize } from 'dompurify'; import { marked } from 'marked'; -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { Button, Container, Form, GridRow, Header, Icon } from 'semantic-ui-react'; @@ -34,6 +34,38 @@ export const Chatbot = () => { const [error, setError] = useState(''); const [chat, setChat] = useState(DEFAULT_CHAT_STATE); const [user, setUser] = useState(''); + const hasMessages = chatMessages.length > 0; + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const shouldForceScrollRef = useRef(false); + useEffect(() => { + const container = messagesContainerRef.current; + if (!container) return; + + const handleScroll = () => { + const threshold = 64; // px from bottom + const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold; + + setShowScrollToBottom(!isNearBottom); + }; + + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + }, []); + + useEffect(() => { + const container = messagesContainerRef.current; + if (!container) return; + + const threshold = 120; + const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold; + + if (shouldForceScrollRef.current || isNearBottom) { + container.scrollTop = container.scrollHeight; + shouldForceScrollRef.current = false; // reset after use + } + }, [chatMessages]); function login() { fetch(`${apiUrl}/user`, { method: 'GET' }) @@ -77,8 +109,9 @@ export const Chatbot = () => { return res; } - function onSubmit() { + async function onSubmit() { if (!chat.term.trim()) return; + shouldForceScrollRef.current = true; const currentTerm = chat.term; setChat({ ...chat, term: '' }); @@ -98,7 +131,7 @@ export const Chatbot = () => { fetch(`${apiUrl}/completion`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt: currentTerm }), // ✅ use captured term + body: JSON.stringify({ prompt: currentTerm }), }) .then((response) => response.json()) .then((data) => { @@ -144,70 +177,89 @@ export const Chatbot = () => { <> {user !== '' ? null : login()} - {/* */} - - + -
OWASP OpenCRE Chat
+
+ OWASP OpenCRE Chat +
-
- {error && ( -
-
Document could not be loaded
-
- )} -
- {chatMessages.map((m, idx) => ( -
-
-
- {m.role} - {m.timestamp} -
+
+
+ {' '} + {error && ( +
+
Document could not be loaded
+
+ )} +
+ {chatMessages.map((m, idx) => ( +
+
+
+ {m.role} + {m.timestamp} +
-
{processResponse(m.message)}
+
{processResponse(m.message)}
- {m.data && m.data.length > 0 && ( -
-
References
- {m.data.map((d, i) => ( - {displayDocument(d)} - ))} -
- )} + {m.data && m.data.length > 0 && ( +
+
References
+ {m.data.map((d, i) => ( + {displayDocument(d)} + ))} +
+ )} - {!m.accurate && ( -
- This answer could not be fully verified against OpenCRE sources. Please validate - independently. -
- )} + {!m.accurate && ( +
+ This answer could not be fully verified against OpenCRE sources. Please validate + independently. +
+ )} +
-
- ))} - {loading && ( -
-
- - - + ))} + {loading && ( +
+
+ + + +
-
+ )} +
+
+ {showScrollToBottom && ( + )} +
+ setChat({ ...chat, term: e.target.value })} + placeholder="Type your infosec question here…" + /> + +
- -
- setChat({ ...chat, term: e.target.value })} - placeholder="Type your infosec question here…" - /> - -