diff --git a/qa/probe_personas.mjs b/qa/probe_personas.mjs new file mode 100644 index 0000000..ae0787c --- /dev/null +++ b/qa/probe_personas.mjs @@ -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(); diff --git a/src/App.tsx b/src/App.tsx index adcb8a0..eaa517c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { + @@ -149,6 +153,7 @@ export default function App() { {scene === "mission" && } {scene === "history" && } {scene === "studio" && } + {scene === "chat" && } {scene === "settings" && } diff --git a/src/index.css b/src/index.css index a6be18e..b43dd3a 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } +} diff --git a/src/lib/chatApi.ts b/src/lib/chatApi.ts new file mode 100644 index 0000000..e3b46a3 --- /dev/null +++ b/src/lib/chatApi.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + }, +}; diff --git a/src/scenes/Chat.tsx b/src/scenes/Chat.tsx new file mode 100644 index 0000000..8e74fb1 --- /dev/null +++ b/src/scenes/Chat.tsx @@ -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([]); + const [activeKey, setActiveKey] = useState(null); + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(""); + const [loadingThreads, setLoadingThreads] = useState(false); + const [loadingMessages, setLoadingMessages] = useState(false); + const [sending, setSending] = useState(false); + const [recipient, setRecipient] = useState("hr-head@flow-master.ai"); + + const scrollRef = useRef(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 ( +
+ + +
+ {!active && ( +
+ Select a thread to read messages, or start a new one with a teammate. +
+ )} + {active && ( + <> +
+
{active.display_name}
+
{active.description}
+
+
+ {loadingMessages &&
Loading…
} + {!loadingMessages && messages.length === 0 && ( +
No messages yet. Say hi.
+ )} + {messages.map((m) => { + const mine = m.author_email === me; + return ( +
+
+
{personaLabel(m.author_email)}
+
{m.body}
+
{new Date(m.created_at).toLocaleTimeString()}
+
+
+ ); + })} +
+