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:
2026-06-14 12:33:43 +04:00
parent 4dad78aa86
commit fd8c4853df
6 changed files with 443 additions and 2 deletions
+201
View File
@@ -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>
);
}