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,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
@@ -8,10 +8,11 @@ import Wizard from "./scenes/Wizard";
|
|||||||
import Settings from "./scenes/Settings";
|
import Settings from "./scenes/Settings";
|
||||||
import Login from "./scenes/Login";
|
import Login from "./scenes/Login";
|
||||||
import SsoCallback from "./scenes/SsoCallback";
|
import SsoCallback from "./scenes/SsoCallback";
|
||||||
|
import Chat from "./scenes/Chat";
|
||||||
import CommandBar from "./components/CommandBar";
|
import CommandBar from "./components/CommandBar";
|
||||||
import Toaster from "./components/Toaster";
|
import Toaster from "./components/Toaster";
|
||||||
import Console from "./components/Console";
|
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";
|
import { liveMeta } from "./data/scenarios";
|
||||||
|
|
||||||
export default function App() {
|
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")}>
|
<button role="tab" aria-selected={scene === "studio"} className={`tab${scene === "studio" ? " tab-sel" : ""}`} onClick={() => setScene("studio")}>
|
||||||
<Branch size={13} /> Studio
|
<Branch size={13} /> Studio
|
||||||
</button>
|
</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")}>
|
<button role="tab" aria-selected={scene === "settings"} className={`tab${scene === "settings" ? " tab-sel" : ""}`} onClick={() => setScene("settings")}>
|
||||||
<Cog size={13} /> Settings
|
<Cog size={13} /> Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -149,6 +153,7 @@ export default function App() {
|
|||||||
{scene === "mission" && <MissionControl />}
|
{scene === "mission" && <MissionControl />}
|
||||||
{scene === "history" && <RunHistory />}
|
{scene === "history" && <RunHistory />}
|
||||||
{scene === "studio" && <Wizard />}
|
{scene === "studio" && <Wizard />}
|
||||||
|
{scene === "chat" && <Chat />}
|
||||||
{scene === "settings" && <Settings />}
|
{scene === "settings" && <Settings />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1825,3 +1825,76 @@ select.studio-input { background: var(--bp-paper); }
|
|||||||
|
|
||||||
.theme-toggle { display: inline-flex; align-items: center; gap: 6px; }
|
.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; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
@@ -6,7 +6,7 @@ import { buildLiveScenariosFromApi } from "../lib/buildScenarios";
|
|||||||
import { api, type ApiCall, type Actor } from "../lib/api";
|
import { api, type ApiCall, type Actor } from "../lib/api";
|
||||||
import type { ProcessScenario } from "../data/types";
|
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 DataMode = "snapshot" | "live";
|
||||||
export type Theme = "dark" | "light";
|
export type Theme = "dark" | "light";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user