Merge pull request #302 from RecentRunner/web-features

fix web chat features
This commit is contained in:
2026-04-15 01:25:47 -06:00
committed by GitHub
2 changed files with 378 additions and 30 deletions

View File

@@ -123,6 +123,22 @@ function AiChatPage() {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
try {
for (const msg of messages) {
const prefix = msg.role === "assistant" ? "[AI Assistant] " : "[Customer] ";
await fetch(`${API_BASE}/api/v1/chat/conversations/${conv.id}/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ content: prefix + msg.content }),
});
}
} catch {
// Navigate anyway — agent has partial context
}
setHumanRequested(true); setHumanRequested(true);
router.push(`/chat?id=${conv.id}`); router.push(`/chat?id=${conv.id}`);
} catch { } catch {

View File

@@ -20,12 +20,17 @@ function ChatPage() {
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [loadingConv, setLoadingConv] = useState(true); const [loadingConv, setLoadingConv] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [conversations, setConversations] = useState([]);
const [convsLoading, setConvsLoading] = useState(false);
const [showSidebar, setShowSidebar] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const messagesAreaRef = useRef(null); const messagesAreaRef = useRef(null);
const inputRef = useRef(null); const inputRef = useRef(null);
const pollRef = useRef(null); const pollRef = useRef(null);
const lastMessageIdRef = useRef(null); const lastMessageIdRef = useRef(null);
const fileInputRef = useRef(null);
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
@@ -88,6 +93,23 @@ function ChatPage() {
} }
}, [token]); }, [token]);
const fetchConversations = useCallback(async () => {
if (!token) return;
setConvsLoading(true);
try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return;
const data = await res.json();
setConversations(Array.isArray(data) ? data : (data.content ?? []));
} catch {
// silent
} finally {
setConvsLoading(false);
}
}, [token]);
const startPolling = useCallback((convId) => { const startPolling = useCallback((convId) => {
if (pollRef.current) clearInterval(pollRef.current); if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => { pollRef.current = setInterval(async () => {
@@ -168,11 +190,22 @@ function ChatPage() {
}; };
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling]); }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling]);
useEffect(() => {
if (showSidebar && token) fetchConversations();
}, [showSidebar, token, fetchConversations]);
async function handleSend(e) { async function handleSend(e) {
e?.preventDefault(); e?.preventDefault();
const text = input.trim(); const text = input.trim();
if (!text || sending || !conversation) return; if ((!text && !selectedFile) || sending || !conversation) return;
if (selectedFile) {
await handleSendAttachment(text);
} else {
await handleSendText(text);
}
}
async function handleSendText(text) {
setInput(""); setInput("");
setSending(true); setSending(true);
setError(null); setError(null);
@@ -214,6 +247,59 @@ function ChatPage() {
} }
} }
async function handleSendAttachment(optionalText) {
setSending(true);
setError(null);
const file = selectedFile;
setSelectedFile(null);
setInput("");
try {
const formData = new FormData();
formData.append("file", file);
if (optionalText) formData.append("content", optionalText);
const res = await fetch(
`${API_BASE}/api/v1/chat/conversations/${conversation.id}/attachments`,
{
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
}
);
if (res.status === 401) {
router.push("/login?next=" + encodeURIComponent("/chat"));
return;
}
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.message || "Failed to send attachment.");
setSelectedFile(file);
setInput(optionalText);
return;
}
const msg = await res.json();
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
lastMessageIdRef.current = msg.id;
} catch {
setError("Network error. Please try again.");
setSelectedFile(file);
setInput(optionalText);
} finally {
setSending(false);
inputRef.current?.focus();
}
}
function handleFileChange(e) {
const file = e.target.files?.[0];
if (file) setSelectedFile(file);
e.target.value = "";
}
function handleKeyDown(e) { function handleKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -258,6 +344,19 @@ function ChatPage() {
} }
} }
async function switchConversation(convId) {
if (pollRef.current) clearInterval(pollRef.current);
setMessages([]);
setError(null);
setLoadingConv(true);
await fetchConversation(convId);
await fetchMessages(convId);
setLoadingConv(false);
startPolling(convId);
router.replace(`/chat?id=${convId}`, { scroll: false });
setShowSidebar(false);
}
if (authLoading || loadingConv) { if (authLoading || loadingConv) {
return ( return (
<main style={s.page}> <main style={s.page}>
@@ -292,6 +391,43 @@ function ChatPage() {
</section> </section>
<section style={s.chatSection}> <section style={s.chatSection}>
<div style={{ display: "flex", gap: "1rem", alignItems: "flex-start" }}>
{showSidebar && (
<div style={s.sidebar}>
<div style={s.sidebarHeader}>
<span style={s.sidebarTitle}>All Conversations</span>
<button style={s.sidebarClose} onClick={() => setShowSidebar(false)}></button>
</div>
{convsLoading && <p style={s.sidebarEmpty}>Loading...</p>}
{!convsLoading && conversations.length === 0 && (
<p style={s.sidebarEmpty}>No conversations yet.</p>
)}
<div style={{ overflowY: "auto", flex: 1 }}>
{conversations.map((conv) => (
<button
key={conv.id}
style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
onClick={() => switchConversation(conv.id)}
>
<div style={s.convItemTop}>
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.convStatusBadge, ...(conv.status === "OPEN" ? s.convStatusOpen : s.convStatusClosed) }}>
{conv.status}
</span>
</div>
<div style={s.convItemBottom}>
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
<span style={s.convItemDate}>{conv.createdAt ? new Date(conv.createdAt).toLocaleDateString() : ""}</span>
</div>
</button>
))}
</div>
<button style={s.newConvSidebarBtn} onClick={() => { setShowSidebar(false); handleNewConversation(); }}>
+ New Conversation
</button>
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
{!conversation ? ( {!conversation ? (
<div style={s.noConvCard}> <div style={s.noConvCard}>
<div style={s.noConvIcon}>💬</div> <div style={s.noConvIcon}>💬</div>
@@ -301,6 +437,9 @@ function ChatPage() {
<button style={s.startBtn} onClick={handleNewConversation}> <button style={s.startBtn} onClick={handleNewConversation}>
Start a Conversation Start a Conversation
</button> </button>
<button style={s.backBtn} onClick={() => setShowSidebar(true)}>
View Past Conversations
</button>
<button style={s.backBtn} onClick={() => router.push("/ai-chat")}> <button style={s.backBtn} onClick={() => router.push("/ai-chat")}>
Back to AI Assistant Back to AI Assistant
</button> </button>
@@ -320,13 +459,21 @@ function ChatPage() {
</div> </div>
</div> </div>
</div> </div>
<button <div style={{ display: "flex", gap: "0.5rem" }}>
style={s.aiBtn} <button
onClick={() => router.push("/ai-chat")} style={{ ...s.historyBtn, ...(showSidebar ? s.historyBtnActive : {}) }}
title="Back to AI Assistant" onClick={() => setShowSidebar((v) => !v)}
> >
AI Assistant History
</button> </button>
<button
style={s.aiBtn}
onClick={() => router.push("/ai-chat")}
title="Back to AI Assistant"
>
AI Assistant
</button>
</div>
</div> </div>
{!hasStaff && !hasStaffMessage && !isClosed && ( {!hasStaff && !hasStaffMessage && !isClosed && (
@@ -413,31 +560,62 @@ function ChatPage() {
</div> </div>
) : ( ) : (
<form style={s.inputArea} onSubmit={handleSend}> <form style={s.inputArea} onSubmit={handleSend}>
<textarea <input
ref={inputRef} type="file"
style={s.textarea} ref={fileInputRef}
value={input} style={{ display: "none" }}
onChange={(e) => setInput(e.target.value)} onChange={handleFileChange}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
rows={1}
disabled={sending}
maxLength={2000}
/> />
<button {selectedFile && (
type="submit" <div style={s.filePreview}>
style={{ <span style={s.filePreviewName}>📎 {selectedFile.name}</span>
...s.sendBtn, <button
...((!input.trim() || sending) ? s.sendBtnDisabled : {}), type="button"
}} style={s.filePreviewRemove}
disabled={!input.trim() || sending} onClick={() => setSelectedFile(null)}
> >
{sending ? "..." : "Send"}
</button> </button>
</div>
)}
<div style={s.inputRow}>
<button
type="button"
style={s.attachBtn}
onClick={() => fileInputRef.current?.click()}
disabled={sending}
title="Attach a file"
>
📎
</button>
<textarea
ref={inputRef}
style={s.textarea}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={selectedFile ? "Add a caption (optional)..." : "Type a message..."}
rows={1}
disabled={sending}
maxLength={2000}
/>
<button
type="submit"
style={{
...s.sendBtn,
...((!input.trim() && !selectedFile) || sending ? s.sendBtnDisabled : {}),
}}
disabled={(!input.trim() && !selectedFile) || sending}
>
{sending ? "..." : "Send"}
</button>
</div>
</form> </form>
)} )}
</div> </div>
)} )}
</div>
</div>
</section> </section>
</main> </main>
); );
@@ -480,7 +658,7 @@ const s = {
margin: "1rem auto 0", margin: "1rem auto 0",
}, },
chatSection: { chatSection: {
maxWidth: 800, maxWidth: 1060,
margin: "0 auto", margin: "0 auto",
padding: "1.5rem 1rem 2rem", padding: "1.5rem 1rem 2rem",
}, },
@@ -737,11 +915,16 @@ const s = {
}, },
inputArea: { inputArea: {
display: "flex", display: "flex",
gap: "0.6rem", flexDirection: "column",
gap: "0.4rem",
padding: "0.85rem 1.25rem", padding: "0.85rem 1.25rem",
borderTop: "1px solid #f0f0f0", borderTop: "1px solid #f0f0f0",
background: "#fff", background: "#fff",
flexShrink: 0, flexShrink: 0,
},
inputRow: {
display: "flex",
gap: "0.6rem",
alignItems: "flex-end", alignItems: "flex-end",
}, },
textarea: { textarea: {
@@ -772,6 +955,155 @@ const s = {
background: "#aaa", background: "#aaa",
cursor: "not-allowed", cursor: "not-allowed",
}, },
attachBtn: {
background: "none",
border: "1.5px solid #e0e0e0",
borderRadius: 8,
padding: "0.5rem 0.6rem",
fontSize: "1rem",
cursor: "pointer",
flexShrink: 0,
color: "#666",
lineHeight: 1,
},
filePreview: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
background: "#f8f8f8",
border: "1px solid #e8e8e8",
borderRadius: 8,
padding: "0.35rem 0.75rem",
},
filePreviewName: {
fontSize: "0.82rem",
color: "#555",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
filePreviewRemove: {
background: "none",
border: "none",
cursor: "pointer",
color: "#999",
fontSize: "0.8rem",
padding: "0 0.15rem",
flexShrink: 0,
},
historyBtn: {
background: "white",
border: "2px solid #555",
color: "#555",
borderRadius: 8,
padding: "0.45rem 0.9rem",
fontSize: "0.82rem",
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
},
historyBtnActive: {
background: "#555",
color: "white",
},
sidebar: {
width: 230,
flexShrink: 0,
background: "white",
borderRadius: 16,
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
maxHeight: "calc(100vh - 220px)",
minHeight: 300,
},
sidebarHeader: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.85rem 1rem",
borderBottom: "1px solid #f0f0f0",
flexShrink: 0,
},
sidebarTitle: { fontWeight: 700, fontSize: "0.88rem", color: "#333" },
sidebarClose: {
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.85rem",
color: "#999",
padding: "0.1rem 0.25rem",
},
sidebarEmpty: {
color: "#aaa",
fontSize: "0.82rem",
padding: "1rem",
textAlign: "center",
margin: 0,
},
convItem: {
display: "flex",
flexDirection: "column",
gap: "0.2rem",
padding: "0.65rem 1rem",
borderBottom: "1px solid #f0f0f0",
background: "white",
border: "none",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: "#f0f0f0",
textAlign: "left",
cursor: "pointer",
width: "100%",
},
convItemActive: { background: "#f8f8f8" },
convItemTop: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.4rem",
},
convItemBottom: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
},
convItemSubject: {
fontSize: "0.82rem",
fontWeight: 600,
color: "#222",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
},
convItemMode: { fontSize: "0.7rem", color: "#999" },
convItemDate: { fontSize: "0.68rem", color: "#bbb" },
convStatusBadge: {
fontSize: "0.62rem",
fontWeight: 700,
borderRadius: 20,
padding: "0.1rem 0.45rem",
flexShrink: 0,
textTransform: "uppercase",
letterSpacing: "0.04em",
},
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
convStatusClosed: { background: "#f0f0f0", color: "#888" },
newConvSidebarBtn: {
margin: "0.65rem 1rem",
background: "#333",
color: "white",
border: "none",
borderRadius: 8,
padding: "0.5rem 0.75rem",
fontSize: "0.8rem",
fontWeight: 600,
cursor: "pointer",
flexShrink: 0,
},
}; };
export default dynamic(() => Promise.resolve(ChatPage), { ssr: false }); export default dynamic(() => Promise.resolve(ChatPage), { ssr: false });