feat(chat): EA2-backed 1:1 messaging surface
New Chat scene at the Chat top-bar tab. Threads and messages are stored as flow docs (kind=definition, source_context=EA2_CHAT_THREAD / _MSG) linked by defines edges with role=next — same proven contract as wizard steps. Persona directory lets the signed-in operator open a thread with the CEO / HR / IT personas. - src/lib/chatApi.ts: listThreads / createThread / listMessages / sendMessage - src/scenes/Chat.tsx: sidebar (threads + new-thread picker) + main pane (header, bubbles, composer with Enter-to-send / Shift-Enter newline) - src/index.css: chat-scene grid + bubble + composer styles - src/App.tsx: Chat tab + scene route
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useApp } from "../state/store";
|
||||
import { chatApi, type ChatThread, type ChatMessage } from "../lib/chatApi";
|
||||
import { Bot } from "../components/icons";
|
||||
|
||||
const PERSONA_DIRECTORY: { email: string; label: string }[] = [
|
||||
{ email: "ceo-head@flow-master.ai", label: "Mariana Cole · CEO" },
|
||||
{ email: "hr-head@flow-master.ai", label: "Aisha Khan · HR" },
|
||||
{ email: "it-head@flow-master.ai", label: "Rohan Patel · IT" },
|
||||
{ email: "dev@flow-master.ai", label: "Developer" },
|
||||
];
|
||||
|
||||
function personaLabel(email: string): string {
|
||||
return PERSONA_DIRECTORY.find((p) => p.email === email)?.label || email;
|
||||
}
|
||||
|
||||
export default function Chat() {
|
||||
const userEmail = useApp((s) => s.userEmail);
|
||||
const pushToast = useApp((s) => s.pushToast);
|
||||
const me = userEmail || "dev@flow-master.ai";
|
||||
|
||||
const [threads, setThreads] = useState<ChatThread[]>([]);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [loadingThreads, setLoadingThreads] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [recipient, setRecipient] = useState<string>("hr-head@flow-master.ai");
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingThreads(true);
|
||||
chatApi
|
||||
.listThreads()
|
||||
.then((rows) => {
|
||||
if (cancelled) return;
|
||||
setThreads(rows);
|
||||
if (!activeKey && rows.length > 0) setActiveKey(rows[0]._key);
|
||||
})
|
||||
.catch((err) => pushToast("err", `Threads failed: ${err.message}`))
|
||||
.finally(() => !cancelled && setLoadingThreads(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeKey) return;
|
||||
let cancelled = false;
|
||||
setLoadingMessages(true);
|
||||
chatApi
|
||||
.listMessages(activeKey)
|
||||
.then((rows) => {
|
||||
if (cancelled) return;
|
||||
setMessages(rows);
|
||||
setTimeout(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }), 50);
|
||||
})
|
||||
.catch((err) => pushToast("err", `Messages failed: ${err.message}`))
|
||||
.finally(() => !cancelled && setLoadingMessages(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeKey]);
|
||||
|
||||
const handleNewThread = async () => {
|
||||
if (!recipient || recipient === me) {
|
||||
pushToast("err", "Pick a recipient that's not yourself");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const otherLabel = personaLabel(recipient);
|
||||
const key = await chatApi.createThread(`${personaLabel(me)} ↔ ${otherLabel}`, [me, recipient]);
|
||||
const next = await chatApi.listThreads();
|
||||
setThreads(next);
|
||||
setActiveKey(key);
|
||||
pushToast("ok", `Thread opened with ${otherLabel}`);
|
||||
} catch (err: any) {
|
||||
pushToast("err", `New thread failed: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!activeKey || !draft.trim()) return;
|
||||
setSending(true);
|
||||
const body = draft.trim();
|
||||
const optimistic: ChatMessage = {
|
||||
_key: `pending-${Date.now()}`,
|
||||
display_name: body.slice(0, 80),
|
||||
body,
|
||||
author_email: me,
|
||||
thread_key: activeKey,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, optimistic]);
|
||||
setDraft("");
|
||||
try {
|
||||
await chatApi.sendMessage(activeKey, body, me);
|
||||
const fresh = await chatApi.listMessages(activeKey);
|
||||
setMessages(fresh);
|
||||
setTimeout(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }), 50);
|
||||
} catch (err: any) {
|
||||
setMessages((prev) => prev.filter((m) => m._key !== optimistic._key));
|
||||
pushToast("err", `Send failed: ${err.message}`);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const active = threads.find((t) => t._key === activeKey);
|
||||
|
||||
return (
|
||||
<div className="chat-scene">
|
||||
<aside className="chat-sidebar">
|
||||
<header className="chat-sidebar-head">
|
||||
<div className="mc-hero-eyebrow"><Bot size={12} /> Conversations</div>
|
||||
<h2 className="mc-hero-title">Talk to your team</h2>
|
||||
</header>
|
||||
<div className="chat-new-thread">
|
||||
<label>Start a new thread</label>
|
||||
<div className="chat-new-row">
|
||||
<select value={recipient} onChange={(e) => setRecipient(e.target.value)}>
|
||||
{PERSONA_DIRECTORY.filter((p) => p.email !== me).map((p) => (
|
||||
<option key={p.email} value={p.email}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-secondary" onClick={handleNewThread}>Open</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-thread-list">
|
||||
{loadingThreads && <div className="chat-empty">Loading threads…</div>}
|
||||
{!loadingThreads && threads.length === 0 && <div className="chat-empty">No conversations yet. Start one above.</div>}
|
||||
{threads.map((t) => (
|
||||
<button
|
||||
key={t._key}
|
||||
className={`chat-thread-row ${t._key === activeKey ? "active" : ""}`}
|
||||
onClick={() => setActiveKey(t._key)}
|
||||
>
|
||||
<div className="chat-thread-name">{t.display_name}</div>
|
||||
<div className="chat-thread-meta">{t.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="chat-main">
|
||||
{!active && (
|
||||
<div className="chat-empty-main">
|
||||
Select a thread to read messages, or start a new one with a teammate.
|
||||
</div>
|
||||
)}
|
||||
{active && (
|
||||
<>
|
||||
<header className="chat-main-head">
|
||||
<div className="chat-title">{active.display_name}</div>
|
||||
<div className="chat-subtitle">{active.description}</div>
|
||||
</header>
|
||||
<div className="chat-messages" ref={scrollRef}>
|
||||
{loadingMessages && <div className="chat-empty">Loading…</div>}
|
||||
{!loadingMessages && messages.length === 0 && (
|
||||
<div className="chat-empty">No messages yet. Say hi.</div>
|
||||
)}
|
||||
{messages.map((m) => {
|
||||
const mine = m.author_email === me;
|
||||
return (
|
||||
<div key={m._key} className={`chat-bubble-row ${mine ? "mine" : "theirs"}`}>
|
||||
<div className="chat-bubble">
|
||||
<div className="chat-bubble-author">{personaLabel(m.author_email)}</div>
|
||||
<div className="chat-bubble-body">{m.body}</div>
|
||||
<div className="chat-bubble-time">{new Date(m.created_at).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<footer className="chat-composer">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Type a message — Enter to send, Shift+Enter for newline"
|
||||
rows={2}
|
||||
disabled={sending}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleSend} disabled={!draft.trim() || sending}>
|
||||
{sending ? "Sending…" : "Send"}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user