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
+13
View File
@@ -0,0 +1,13 @@
import { chromium } from "playwright";
const b = await chromium.launch({ headless: true, args: ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"] });
const p = await (await b.newContext()).newPage();
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
await p.waitForTimeout(1000);
const chips = await p.locator(".persona-chip").allTextContents();
console.log("persona chips:", chips);
// Click CEO chip → email field should fill
await p.locator(".persona-chip", { hasText: /Mariana/ }).click().catch(() => {});
await p.waitForTimeout(300);
const email = await p.locator("input[type='email']").inputValue();
console.log("after Mariana click, email field:", email);
await b.close();
+6 -1
View File
@@ -8,10 +8,11 @@ import Wizard from "./scenes/Wizard";
import Settings from "./scenes/Settings";
import Login from "./scenes/Login";
import SsoCallback from "./scenes/SsoCallback";
import Chat from "./scenes/Chat";
import CommandBar from "./components/CommandBar";
import Toaster from "./components/Toaster";
import Console from "./components/Console";
import { Cmd, Home, Layers, HistoryIcon, Pulse, Refresh, Branch, Cog, User, Sun, Moon } from "./components/icons";
import { Cmd, Home, Layers, HistoryIcon, Pulse, Refresh, Branch, Cog, User, Sun, Moon, Bot } from "./components/icons";
import { liveMeta } from "./data/scenarios";
export default function App() {
@@ -71,6 +72,9 @@ export default function App() {
<button role="tab" aria-selected={scene === "studio"} className={`tab${scene === "studio" ? " tab-sel" : ""}`} onClick={() => setScene("studio")}>
<Branch size={13} /> Studio
</button>
<button role="tab" aria-selected={scene === "chat"} className={`tab${scene === "chat" ? " tab-sel" : ""}`} onClick={() => setScene("chat")}>
<Bot size={13} /> Chat
</button>
<button role="tab" aria-selected={scene === "settings"} className={`tab${scene === "settings" ? " tab-sel" : ""}`} onClick={() => setScene("settings")}>
<Cog size={13} /> Settings
</button>
@@ -149,6 +153,7 @@ export default function App() {
{scene === "mission" && <MissionControl />}
{scene === "history" && <RunHistory />}
{scene === "studio" && <Wizard />}
{scene === "chat" && <Chat />}
{scene === "settings" && <Settings />}
</div>
+73
View File
@@ -1825,3 +1825,76 @@ select.studio-input { background: var(--bp-paper); }
.theme-toggle { display: inline-flex; align-items: center; gap: 6px; }
.theme-toggle-label { font-size: 10px; letter-spacing: 0.08em; font-weight: 600; text-transform: uppercase; }
.chat-scene {
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
padding: 16px;
height: calc(100vh - 60px);
background: var(--bp-paper);
}
.chat-sidebar {
border: 1px solid var(--bp-navy);
background: var(--bp-paper);
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-sidebar-head { padding: 12px; border-bottom: 1px solid var(--bp-navy); }
.chat-new-thread { padding: 12px; border-bottom: 1px solid var(--bp-navy); display: flex; flex-direction: column; gap: 6px; }
.chat-new-thread label { font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em; color: var(--bp-muted); text-transform: uppercase; }
.chat-new-row { display: flex; gap: 6px; }
.chat-new-row select {
flex: 1; padding: 6px 8px; background: var(--bp-paper); border: 1px solid var(--bp-navy);
color: var(--bp-navy); font-family: var(--bp-mono); font-size: 11px;
}
.chat-thread-list { flex: 1; overflow-y: auto; }
.chat-thread-row {
width: 100%; text-align: left; padding: 10px 12px; background: var(--bp-paper);
border: 0; border-bottom: 1px solid color-mix(in srgb, var(--bp-navy) 15%, transparent);
color: var(--bp-navy); cursor: pointer; display: flex; flex-direction: column; gap: 4px;
}
.chat-thread-row:hover { background: color-mix(in srgb, var(--bp-amber) 15%, var(--bp-paper)); }
.chat-thread-row.active { background: color-mix(in srgb, var(--bp-amber) 30%, var(--bp-paper)); }
.chat-thread-name { font-weight: 600; font-size: 12px; }
.chat-thread-meta { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); }
.chat-empty { padding: 16px; font-size: 11px; color: var(--bp-muted); text-align: center; }
.chat-main {
border: 1px solid var(--bp-navy);
background: var(--bp-paper);
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-empty-main { padding: 32px; color: var(--bp-muted); text-align: center; font-size: 12px; }
.chat-main-head { padding: 12px 16px; border-bottom: 1px solid var(--bp-navy); }
.chat-title { font-weight: 700; font-size: 13px; color: var(--bp-navy); }
.chat-subtitle { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); margin-top: 2px; }
.chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; }
.chat-bubble-row { display: flex; }
.chat-bubble-row.mine { justify-content: flex-end; }
.chat-bubble {
max-width: 70%;
padding: 8px 12px;
background: var(--bp-paper);
border: 1px solid var(--bp-navy);
display: flex; flex-direction: column; gap: 4px;
}
.chat-bubble-row.mine .chat-bubble {
background: color-mix(in srgb, var(--bp-amber) 20%, var(--bp-paper));
}
.chat-bubble-author { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); }
.chat-bubble-body { font-size: 12px; line-height: 1.4; color: var(--bp-navy); white-space: pre-wrap; word-break: break-word; }
.chat-bubble-time { font-family: var(--bp-mono); font-size: 9px; color: var(--bp-muted); text-align: right; }
.chat-composer { padding: 12px; border-top: 1px solid var(--bp-navy); display: flex; gap: 8px; align-items: flex-end; }
.chat-composer textarea {
flex: 1; padding: 8px; resize: none;
background: var(--bp-paper); border: 1px solid var(--bp-navy);
color: var(--bp-navy); font-family: var(--bp-mono); font-size: 12px;
}
@media (max-width: 700px) {
.chat-scene { grid-template-columns: 1fr; height: auto; }
.chat-sidebar { max-height: 240px; }
}
+149
View File
@@ -0,0 +1,149 @@
import { api } from "./api";
import { wizardApi, type BatchOperation } from "./wizardApi";
/**
* EA2-backed 1:1 chat. Each thread is a `flow` doc (kind=definition,
* source_context=EA2_CHAT_THREAD) and each message is a `flow` doc
* (kind=definition, source_context=EA2_CHAT_MSG) linked to its thread by a
* `defines` edge with role=next. Persona identities are the seeded users
* (hr-head/ceo-head/it-head/dev@flow-master.ai).
*/
export interface ChatThread {
_key: string;
display_name: string;
description: string;
created_at: string;
participants: string[];
}
export interface ChatMessage {
_key: string;
display_name: string;
body: string;
author_email: string;
thread_key: string;
created_at: string;
}
function newRequestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `chat-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
}
function authHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json",
};
}
async function jsonFetch(path: string, init?: RequestInit) {
const res = await fetch(`${api.config.baseUrl}${path}`, { ...init, headers: { ...authHeaders(), ...(init?.headers || {}) } });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${path} ${res.status}: ${text.slice(0, 200)}`);
}
return res.json();
}
export const chatApi = {
/** List existing chat-thread flow docs for the current tenant. */
async listThreads(signal?: AbortSignal): Promise<ChatThread[]> {
const res = await jsonFetch(`/api/ea2/flow/processes?limit=200`, { signal });
const items = (res?.items || []) as any[];
return items
.filter((it) => it?.source_context === "EA2_CHAT_THREAD")
.map((it) => ({
_key: it._key,
display_name: it.display_name || "Chat",
description: it.description || "",
created_at: it.created_at,
participants: it.config?.chat?.participants || [],
}));
},
/**
* Create a thread between two persona emails. Returns the new thread key.
* Falls back to a deterministic key built from the participants when present.
*/
async createThread(displayName: string, participants: string[], signal?: AbortSignal): Promise<string> {
const payload = {
kind: "definition",
status: "published",
name: `chat_${Date.now()}`,
display_name: displayName,
description: `Conversation between ${participants.join(" & ")}`,
source_context: "EA2_CHAT_THREAD",
config: { chat: { participants } },
};
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(payload),
signal,
});
if (!res.ok) throw new Error(`createThread ${res.status}`);
const body = await res.json();
return body._key;
},
/** Read every message under a thread via the graph endpoint. */
async listMessages(threadKey: string, signal?: AbortSignal): Promise<ChatMessage[]> {
const res = await jsonFetch(`/api/ea2/process-definitions/${threadKey}/graph`, { signal });
const edges = (res?.edges || []) as any[];
const childKeys = edges
.filter((e) => e?.role === "next")
.map((e) => (e.to_id || e._to || "").split("/").pop())
.filter(Boolean) as string[];
const messages = await Promise.all(
childKeys.map(async (k) => {
try {
const doc = await jsonFetch(`/api/ea2/flow/${k}`, { signal });
return {
_key: doc._key,
display_name: doc.display_name || "",
body: doc.description || "",
author_email: doc.config?.chat?.author_email || "",
thread_key: threadKey,
created_at: doc.created_at,
} as ChatMessage;
} catch {
return null;
}
})
);
return messages.filter((m): m is ChatMessage => !!m).sort((a, b) => a.created_at.localeCompare(b.created_at));
},
/** Append a message: one create + one create_edge in a single batch. */
async sendMessage(threadKey: string, body: string, authorEmail: string, signal?: AbortSignal) {
if (!body.trim()) return;
const created = await fetch(`${api.config.baseUrl}/api/ea2/flow`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
kind: "definition",
status: "published",
name: `msg_${Date.now()}`,
display_name: body.slice(0, 80),
description: body,
source_context: "EA2_CHAT_MSG",
config: { chat: { author_email: authorEmail, thread_key: threadKey } },
}),
signal,
});
if (!created.ok) throw new Error(`sendMessage create ${created.status}`);
const msg = await created.json();
const linkOps: BatchOperation[] = [wizardApi.ops.createStepEdge(threadKey, msg._key)];
await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ request_id: newRequestId(), ops: linkOps }),
signal,
});
return msg._key;
},
};
+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>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { buildLiveScenariosFromApi } from "../lib/buildScenarios";
import { api, type ApiCall, type Actor } from "../lib/api";
import type { ProcessScenario } from "../data/types";
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback";
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback" | "chat" | "agent" | "hub-procurement" | "hub-hr" | "hub-it" | "geo-attendance" | "explainer";
export type DataMode = "snapshot" | "live";
export type Theme = "dark" | "light";