feat(canvas): add Agent + Hubs + personas
build-and-publish / test (pull_request) Has been cancelled
build-and-publish / image (pull_request) Has been cancelled

- New Agent scene (src/scenes/Agent.tsx): chat surface backed by per-tenant
  local memory (src/lib/agentMemory.ts). Recognises navigation intents
  ("take me to ..."), retain intents ("remember: ..."), and process-start
  intents ("start a ...") which pre-fill the Studio Intake step.
- New Hubs scene (src/scenes/Hubs.tsx): department lenses over the same
  EA2 catalog (People, Finance, Procurement, IT). Each hub funnels into
  Mission Control on its matching scenario.
- Three view-as personas in src/data/personas.ts (CEO, Head of People,
  Head of IT). Persona switcher lives in the Agent panel; flipping persona
  re-signs in via dev-login under that email.
- Store: SceneId extended with 'agent' + 'hubs'; persisted hub + personaId.
- App shell: two new top-bar tabs (Hubs, Agent) routed to the new scenes.
- CSS: Agent shell, persona cards, Hubs grid, hub mark variants.
- Type-check + production build both pass.
This commit is contained in:
canvas-bot
2026-06-15 00:37:58 +04:00
parent 89fca578d9
commit 80d6c0ba02
7 changed files with 894 additions and 1 deletions
+10
View File
@@ -8,6 +8,8 @@ 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 Agent from "./scenes/Agent";
import Hubs from "./scenes/Hubs";
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";
@@ -71,6 +73,12 @@ 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 === "hubs"} className={`tab${scene === "hubs" ? " tab-sel" : ""}`} onClick={() => setScene("hubs")}>
<Layers size={13} /> Hubs
</button>
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
<Cmd size={13} /> Agent
</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 +157,8 @@ export default function App() {
{scene === "mission" && <MissionControl />} {scene === "mission" && <MissionControl />}
{scene === "history" && <RunHistory />} {scene === "history" && <RunHistory />}
{scene === "studio" && <Wizard />} {scene === "studio" && <Wizard />}
{scene === "hubs" && <Hubs />}
{scene === "agent" && <Agent />}
{scene === "settings" && <Settings />} {scene === "settings" && <Settings />}
</div> </div>
+50
View File
@@ -0,0 +1,50 @@
// Three built-in persona profiles. These are not seeded into the backend —
// they are operator-facing "view-as" presets so the live demo can show how
// the same EA2 surface looks from different roles. Switching persona only
// changes the email used for dev-login and a UI-side role tag.
export type PersonaId = "hr-head" | "it-head" | "ceo";
export interface Persona {
id: PersonaId;
name: string;
title: string;
email: string;
initials: string;
scope: string;
defaultHub: "hr" | "it" | "finance" | "procurement";
}
export const personas: Persona[] = [
{
id: "ceo",
name: "Sara Park",
title: "Chief Executive Officer",
email: "sara.park@flow-master.ai",
initials: "SP",
scope: "Org-wide. Sees every process, every hub, every escalation.",
defaultHub: "finance",
},
{
id: "hr-head",
name: "Maya Okafor",
title: "Head of People",
email: "maya.okafor@flow-master.ai",
initials: "MO",
scope: "HR hub. Hiring, onboarding, leave, performance, geo-attendance.",
defaultHub: "hr",
},
{
id: "it-head",
name: "Daniel Reyes",
title: "Head of IT",
email: "daniel.reyes@flow-master.ai",
initials: "DR",
scope: "IT hub. Procurement (laptops, software), access, incidents.",
defaultHub: "it",
},
];
export function personaById(id: PersonaId | string | null | undefined): Persona | undefined {
return personas.find((p) => p.id === id);
}
+335
View File
@@ -1790,3 +1790,338 @@ 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; }
/* ===== Agent + Hubs + Personas ===== */
.agent-shell {
display: grid;
grid-template-columns: 280px 1fr;
gap: var(--space-md);
height: calc(100vh - 64px);
padding: var(--space-md);
}
.agent-side {
border: 1px solid var(--bp-line);
background: var(--bg-1);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-md);
overflow-y: auto;
}
.agent-side-h {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-2);
padding-bottom: var(--space-sm);
border-bottom: 1px solid var(--bp-line);
}
.agent-persona-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-3);
margin-bottom: 6px;
}
.agent-persona-card {
display: grid;
grid-template-columns: 36px 1fr;
gap: 10px;
align-items: start;
padding: 10px;
border: 1px solid var(--bp-line);
background: var(--bg-2);
margin-bottom: var(--space-sm);
}
.agent-persona-avatar {
width: 36px;
height: 36px;
display: grid;
place-items: center;
background: var(--bp-accent, #d97a00);
color: #fff;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
}
.agent-persona-avatar.sm { width: 26px; height: 26px; font-size: 11px; }
.agent-persona-name { display: block; font-size: 12px; color: var(--text-1); font-weight: 600; }
.agent-persona-title { display: block; font-size: 11px; color: var(--text-2); }
.agent-persona-scope { font-size: 10.5px; color: var(--text-3); margin-top: 4px; line-height: 1.35; }
.agent-persona-empty { font-size: 11px; color: var(--text-3); padding: 8px; }
.agent-persona-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.agent-persona-btn {
display: grid;
grid-template-columns: 26px 1fr;
gap: 8px;
align-items: center;
padding: 6px 8px;
border: 1px solid var(--bp-line);
background: transparent;
text-align: left;
cursor: pointer;
color: var(--text-1);
}
.agent-persona-btn:hover { background: var(--bg-2); }
.agent-persona-btn.is-on { border-color: var(--bp-accent); background: var(--bg-2); }
.agent-mem {
border-top: 1px solid var(--bp-line);
padding-top: var(--space-md);
}
.agent-mem-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: none;
color: var(--text-1);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
padding: 0;
}
.agent-mem-body { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
.agent-mem-empty { font-size: 11px; color: var(--text-3); }
.agent-mem-row {
display: grid;
grid-template-columns: 60px 1fr;
gap: 6px;
font-size: 11px;
align-items: start;
padding: 4px 0;
border-bottom: 1px dashed var(--bp-line);
}
.agent-mem-kind {
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-2);
background: var(--bg-2);
padding: 2px 6px;
text-align: center;
}
.agent-mem-world { color: #1a6b2e; }
.agent-mem-experience { color: #1a3a6b; }
.agent-mem-opinion { color: #6b2e1a; }
.agent-mem-text { color: var(--text-1); line-height: 1.4; }
.agent-main {
display: grid;
grid-template-rows: 1fr auto;
border: 1px solid var(--bp-line);
background: var(--bg-1);
overflow: hidden;
}
.agent-thread {
overflow-y: auto;
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.agent-msg {
display: grid;
grid-template-columns: 28px 1fr;
gap: 10px;
max-width: 720px;
}
.agent-msg-user { justify-self: end; }
.agent-msg-user .agent-msg-body { background: var(--bg-2); }
.agent-msg-avatar {
width: 28px;
height: 28px;
display: grid;
place-items: center;
background: var(--bg-2);
border: 1px solid var(--bp-line);
}
.agent-msg-body {
padding: 10px 12px;
border: 1px solid var(--bp-line);
background: var(--bg-1);
font-size: 12px;
line-height: 1.5;
color: var(--text-1);
}
.agent-msg-text { white-space: pre-wrap; }
.agent-msg-facts {
margin: 8px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.agent-msg-facts li {
display: grid;
grid-template-columns: 70px 1fr;
gap: 6px;
font-size: 11px;
color: var(--text-2);
}
.agent-composer {
display: grid;
grid-template-columns: 1fr auto;
gap: 6px;
padding: var(--space-sm);
border-top: 1px solid var(--bp-line);
background: var(--bg-2);
}
.agent-input {
border: 1px solid var(--bp-line);
background: var(--bg-1);
color: var(--text-1);
padding: 10px 12px;
font-size: 12px;
font-family: inherit;
}
.agent-input:focus { outline: 1px solid var(--bp-accent); }
.btn-sm { padding: 4px 8px; font-size: 11px; }
/* ===== Hubs ===== */
.hubs-page {
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
max-width: 1280px;
margin: 0 auto;
}
.hubs-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-md);
}
.hubs-sub { font-size: 12px; color: var(--text-2); max-width: 60ch; line-height: 1.55; margin-top: 6px; }
.hubs-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-md);
}
.hub-card {
border: 1px solid var(--bp-line);
background: var(--bg-1);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.hub-card-h {
display: grid;
grid-template-columns: 28px 1fr;
gap: 10px;
align-items: start;
}
.hub-card-mark {
width: 24px;
height: 24px;
background: var(--bp-accent);
}
.hub-card-mark.hub-hr { background: #6b3aa0; }
.hub-card-mark.hub-finance { background: #1a6b3a; }
.hub-card-mark.hub-procurement { background: #b06a00; }
.hub-card-mark.hub-it { background: #1a3a6b; }
.hub-card-title { font-size: 14px; margin: 0; color: var(--text-1); font-weight: 600; }
.hub-card-blurb { font-size: 11.5px; color: var(--text-2); margin: 4px 0 0; line-height: 1.45; }
.hub-card-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.hub-card-list li {
font-size: 11.5px;
color: var(--text-2);
padding-left: 14px;
position: relative;
}
.hub-card-list li::before {
content: "";
position: absolute;
left: 0;
top: 7px;
width: 6px;
height: 1px;
background: var(--bp-accent);
}
.hub-card-foot {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--bp-line);
padding-top: var(--space-sm);
margin-top: auto;
}
.hub-card-meta {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10.5px;
color: var(--text-3);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.hubs-pers {
border-top: 1px solid var(--bp-line);
padding-top: var(--space-md);
}
.warning-text { font-size: 11px; color: #b06a00; margin-top: 8px; }
+85
View File
@@ -0,0 +1,85 @@
// Per-tenant agent memory (mini-hindsight). Stored in localStorage, keyed by
// tenant + actor.user_id. Three fact kinds: world, experience, opinion.
// Survives reloads; cleared on Settings → "Forget all".
export type FactKind = "world" | "experience" | "opinion";
export interface Fact {
id: string;
kind: FactKind;
content: string;
tags: string[];
createdAt: number;
}
const NS = "fm.mc.agent.mem.v1";
function key(tenant: string, userId: string): string {
return `${NS}:${tenant}:${userId}`;
}
function load(tenant: string, userId: string): Fact[] {
if (typeof localStorage === "undefined") return [];
try {
const raw = localStorage.getItem(key(tenant, userId));
if (!raw) return [];
return JSON.parse(raw) as Fact[];
} catch {
return [];
}
}
function save(tenant: string, userId: string, facts: Fact[]): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(key(tenant, userId), JSON.stringify(facts.slice(-500)));
} catch {
/* quota: drop silently */
}
}
export const agentMemory = {
list(tenant: string, userId: string): Fact[] {
return load(tenant, userId);
},
retain(tenant: string, userId: string, kind: FactKind, content: string, tags: string[] = []): Fact {
const facts = load(tenant, userId);
const fact: Fact = {
id: `f_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
kind,
content: content.trim(),
tags,
createdAt: Date.now(),
};
facts.push(fact);
save(tenant, userId, facts);
return fact;
},
recall(tenant: string, userId: string, query: string, limit = 8): Fact[] {
const facts = load(tenant, userId);
if (!query.trim()) return facts.slice(-limit).reverse();
const q = query.toLowerCase();
const tokens = q.split(/\s+/).filter(Boolean);
const scored = facts.map((f) => {
const hay = `${f.content} ${f.tags.join(" ")}`.toLowerCase();
let score = 0;
for (const t of tokens) {
if (hay.includes(t)) score += 1;
}
return { f, score };
});
return scored
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score || b.f.createdAt - a.f.createdAt)
.slice(0, limit)
.map((s) => s.f);
},
forget(tenant: string, userId: string): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.removeItem(key(tenant, userId));
} catch {
/* swallow */
}
},
};
+285
View File
@@ -0,0 +1,285 @@
// Agent scene — chat interface backed by per-tenant memory.
// The agent can: answer using recalled facts, retain new facts the user shares,
// jump to scenes ("take me to procurement"), and propose starting a process.
// It is intentionally small (no LLM round-trip) so it works offline and stays
// honest: every action it takes is visible and reversible.
import { useEffect, useRef, useState } from "react";
import { useApp } from "../state/store";
import { agentMemory, type Fact } from "../lib/agentMemory";
import { personas, personaById } from "../data/personas";
import { Bot, User, Sparkles, Arrow, Close } from "../components/icons";
interface Message {
id: number;
role: "user" | "agent";
text: string;
facts?: Fact[];
action?: { label: string; run: () => void };
}
const TENANT = "default";
let msgSeq = 0;
function detectIntent(
text: string,
): { kind: "goto"; scene: "mission" | "history" | "studio" | "settings" | "hubs" } |
{ kind: "remember"; content: string } |
{ kind: "start"; topic: string } |
{ kind: "ask" } {
const t = text.toLowerCase().trim();
if (/^(take me to|go to|open|show me) (mission|control)/.test(t)) return { kind: "goto", scene: "mission" };
if (/^(take me to|go to|open|show me) (runs|history)/.test(t)) return { kind: "goto", scene: "history" };
if (/^(take me to|go to|open|show me) (studio|wizard|process studio|process creation)/.test(t)) return { kind: "goto", scene: "studio" };
if (/^(take me to|go to|open|show me) (settings|preferences)/.test(t)) return { kind: "goto", scene: "settings" };
if (/^(take me to|go to|open|show me) (hubs|hub)/.test(t)) return { kind: "goto", scene: "hubs" };
if (/^(remember|note|save):? /.test(t)) {
return { kind: "remember", content: text.replace(/^(remember|note|save):? /i, "").trim() };
}
if (/^(start|kick off|launch|run) /.test(t)) {
return { kind: "start", topic: text.replace(/^(start|kick off|launch|run) /i, "").trim() };
}
return { kind: "ask" };
}
export default function Agent() {
const setScene = useApp((s) => s.setScene);
const actor = useApp((s) => s.actor);
const userEmail = useApp((s) => s.userEmail);
const personaId = useApp((s) => s.personaId);
const setPersonaId = useApp((s) => s.setPersonaId);
const loginAs = useApp((s) => s.loginAs);
const pushToast = useApp((s) => s.pushToast);
const userId = actor?.user_id ?? userEmail ?? "anon";
const [messages, setMessages] = useState<Message[]>(() => {
const persona = personaById(personaId);
return [{
id: ++msgSeq,
role: "agent",
text: persona
? `Hi ${persona.name.split(" ")[0]}. I'm the FlowMaster agent. I can take you anywhere in Mission Control, remember things for you, and propose processes to start. Try: "take me to the HR hub", "remember: budget cap is 5,000 USD", or "start a laptop procurement".`
: `Hi. I'm the FlowMaster agent. I work alongside you in Mission Control. Try: "take me to mission", "remember: I prefer Dell laptops", or "start a new process".`,
}];
});
const [input, setInput] = useState("");
const [memOpen, setMemOpen] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);
const send = () => {
const text = input.trim();
if (!text) return;
const userMsg: Message = { id: ++msgSeq, role: "user", text };
const intent = detectIntent(text);
let reply: Message;
switch (intent.kind) {
case "goto": {
const sceneLabel: Record<string, string> = {
mission: "Mission Control",
history: "Run History",
studio: "Process Studio",
settings: "Settings",
hubs: "Hubs index",
};
reply = {
id: ++msgSeq,
role: "agent",
text: `Taking you to ${sceneLabel[intent.scene]}.`,
action: { label: `Open ${sceneLabel[intent.scene]}`, run: () => setScene(intent.scene as never) },
};
// auto-navigate after a short beat so the user sees the response
window.setTimeout(() => setScene(intent.scene as never), 350);
break;
}
case "remember": {
const fact = agentMemory.retain(TENANT, userId, "world", intent.content, []);
reply = {
id: ++msgSeq,
role: "agent",
text: `Got it. I'll remember: "${fact.content}".`,
facts: [fact],
};
break;
}
case "start": {
reply = {
id: ++msgSeq,
role: "agent",
text: `I can draft a "${intent.topic}" process. The Process Studio walks you through intake → structure → data → rules → publish. Want me to open it pre-filled?`,
action: {
label: "Open Process Studio",
run: () => {
try { localStorage.setItem("fm.mc.wizard.draft", JSON.stringify({ step: "Intake", name: intent.topic, description: `When someone needs ${intent.topic}, run the right approval and procurement steps.`, nodes: [], edges: [], fields: [], rules: [] })); } catch { /* ignore */ }
setScene("studio");
},
},
};
break;
}
case "ask":
default: {
const hits = agentMemory.recall(TENANT, userId, text, 5);
if (hits.length === 0) {
reply = {
id: ++msgSeq,
role: "agent",
text: `I don't have anything stored on that yet. If you want me to remember something, start your message with "remember:". I can also navigate ("take me to ...") or kick off a process ("start a ...").`,
};
} else {
reply = {
id: ++msgSeq,
role: "agent",
text: `Here's what I remember that matches:`,
facts: hits,
};
}
break;
}
}
setMessages((m) => [...m, userMsg, reply]);
setInput("");
};
const switchPersona = async (id: typeof personas[number]["id"]) => {
const p = personaById(id);
if (!p) return;
setPersonaId(id);
try {
await loginAs(p.email);
pushToast("ok", `Now viewing as ${p.name} · ${p.title}`);
} catch {
pushToast("warn", `Persona set locally; backend sign-in unavailable.`);
}
};
const persona = personaById(personaId);
const mem = agentMemory.list(TENANT, userId);
return (
<div className="agent-shell">
<aside className="agent-side">
<div className="agent-side-h">
<Bot size={14} /> <span>Agent</span>
</div>
<div className="agent-persona">
<div className="agent-persona-label">Acting as</div>
{persona ? (
<div className="agent-persona-card">
<div className="agent-persona-avatar">{persona.initials}</div>
<div>
<div className="agent-persona-name">{persona.name}</div>
<div className="agent-persona-title">{persona.title}</div>
<div className="agent-persona-scope">{persona.scope}</div>
</div>
</div>
) : (
<div className="agent-persona-empty">No persona selected</div>
)}
<div className="agent-persona-list">
{personas.map((p) => (
<button
key={p.id}
className={`agent-persona-btn${personaId === p.id ? " is-on" : ""}`}
onClick={() => switchPersona(p.id)}
title={p.scope}
>
<span className="agent-persona-avatar sm">{p.initials}</span>
<span>
<span className="agent-persona-name">{p.name}</span>
<span className="agent-persona-title">{p.title}</span>
</span>
</button>
))}
</div>
</div>
<div className="agent-mem">
<button className="agent-mem-toggle" onClick={() => setMemOpen((v) => !v)}>
<Sparkles size={12} /> Memory · {mem.length}
</button>
{memOpen && (
<div className="agent-mem-body">
{mem.length === 0 && <div className="agent-mem-empty">Nothing remembered yet.</div>}
{mem.slice(-12).reverse().map((f) => (
<div key={f.id} className="agent-mem-row">
<span className={`agent-mem-kind agent-mem-${f.kind}`}>{f.kind}</span>
<span className="agent-mem-text">{f.content}</span>
</div>
))}
{mem.length > 0 && (
<button
className="link-btn"
onClick={() => {
agentMemory.forget(TENANT, userId);
pushToast("ok", "Agent memory cleared.");
setMessages([{ id: ++msgSeq, role: "agent", text: "Memory cleared. Starting fresh." }]);
}}
>
<Close size={12} /> Forget all
</button>
)}
</div>
)}
</div>
</aside>
<section className="agent-main">
<div className="agent-thread" role="log" aria-live="polite">
{messages.map((m) => (
<div key={m.id} className={`agent-msg agent-msg-${m.role}`}>
<div className="agent-msg-avatar">
{m.role === "agent" ? <Bot size={14} /> : <User size={14} />}
</div>
<div className="agent-msg-body">
<div className="agent-msg-text">{m.text}</div>
{m.facts && m.facts.length > 0 && (
<ul className="agent-msg-facts">
{m.facts.map((f) => (
<li key={f.id}>
<span className={`agent-mem-kind agent-mem-${f.kind}`}>{f.kind}</span>
<span>{f.content}</span>
</li>
))}
</ul>
)}
{m.action && (
<button className="btn btn-ghost btn-sm" onClick={m.action.run}>
<Arrow size={12} /> {m.action.label}
</button>
)}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
<form
className="agent-composer"
onSubmit={(e) => {
e.preventDefault();
send();
}}
>
<input
className="agent-input"
placeholder="Ask the agent. e.g. take me to the HR hub · remember: budget cap 5000 · start laptop procurement"
value={input}
onChange={(e) => setInput(e.target.value)}
autoFocus
/>
<button className="btn btn-primary" type="submit" disabled={!input.trim()}>
Send
</button>
</form>
</section>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
// Hubs index — operational landing for each department.
// Each hub is a curated lens onto the same EA2 scenarios with a chat shortcut.
import { useApp } from "../state/store";
import { Bot, Arrow, Layers, User, Cog, Inbox } from "../components/icons";
interface Hub {
id: "hr" | "it" | "finance" | "procurement";
label: string;
blurb: string;
scenarioPrefixes: string[];
highlights: string[];
}
const HUBS: Hub[] = [
{
id: "hr",
label: "People (HR)",
blurb: "Hiring, onboarding, leave, performance, attendance.",
scenarioPrefixes: ["hcm"],
highlights: ["New-hire onboarding", "Leave request approval", "Geo-attendance check-in"],
},
{
id: "finance",
label: "Finance",
blurb: "AR, AP, period close, refunds, supplier queries.",
scenarioPrefixes: ["ar", "gl"],
highlights: ["Customer refund approval", "Period-end close", "AP invoice triage"],
},
{
id: "procurement",
label: "Procurement",
blurb: "Purchase requisitions, vendor approval, three-way match.",
scenarioPrefixes: ["procurement", "extra"],
highlights: ["PR → PO approval", "Vendor onboarding", "Match invoice to PO"],
},
{
id: "it",
label: "IT",
blurb: "Asset procurement, access requests, incidents, service ops.",
scenarioPrefixes: ["service", "procurement"],
highlights: ["Laptop procurement (employee → manager → IT)", "Access request", "Customer incident triage"],
},
];
export default function Hubs() {
const setScene = useApp((s) => s.setScene);
const setScenarioId = useApp((s) => s.setScenarioId);
const scenarios = useApp((s) => s.scenarios);
const setHub = useApp((s) => s.setHub);
return (
<div className="hubs-page">
<header className="hubs-head">
<div>
<div className="mc-hero-eyebrow"><Layers size={12} /> Hubs</div>
<h2 className="mc-hero-title">Pick your hub</h2>
<p className="hubs-sub">Hubs are department-shaped lenses on the same EA2 process catalog. Pick a hub to focus the queue, agents, and processes for that team.</p>
</div>
<button className="btn btn-ghost" onClick={() => setScene("agent")}>
<Bot size={13} /> Ask the agent
</button>
</header>
<div className="hubs-grid">
{HUBS.map((h) => {
const matches = scenarios.filter((s) => h.scenarioPrefixes.some((p) => s.id.startsWith(p)));
return (
<article key={h.id} className="hub-card">
<header className="hub-card-h">
<div className={`hub-card-mark hub-${h.id}`} />
<div>
<h3 className="hub-card-title">{h.label}</h3>
<p className="hub-card-blurb">{h.blurb}</p>
</div>
</header>
<ul className="hub-card-list">
{h.highlights.map((hi) => (
<li key={hi}>{hi}</li>
))}
</ul>
<footer className="hub-card-foot">
<span className="hub-card-meta">
<Inbox size={11} /> {matches.length} process{matches.length === 1 ? "" : "es"}
</span>
<button
className="btn btn-primary btn-sm"
onClick={() => {
setHub(h.id);
if (matches[0]) setScenarioId(matches[0].id);
setScene("mission");
}}
>
Enter <Arrow size={11} />
</button>
</footer>
</article>
);
})}
</div>
<section className="hubs-pers">
<h3 className="panel-h"><User size={12} /> View-as personas</h3>
<p className="hubs-sub">Switch persona from the Agent panel to see Mission Control from a department head's seat.</p>
<button className="btn btn-ghost" onClick={() => setScene("agent")}>
<Cog size={12} /> Open agent + persona switcher
</button>
</section>
</div>
);
}
+18 -1
View File
@@ -6,9 +6,11 @@ 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" | "agent" | "hubs";
export type DataMode = "snapshot" | "live"; export type DataMode = "snapshot" | "live";
export type Theme = "dark" | "light"; export type Theme = "dark" | "light";
export type HubId = "hr" | "it" | "finance" | "procurement" | null;
export type PersonaId = "hr-head" | "it-head" | "ceo" | null;
export interface Toast { export interface Toast {
id: number; id: number;
@@ -26,6 +28,8 @@ interface Prefs {
pollEverySec: number; pollEverySec: number;
consoleOpen: boolean; consoleOpen: boolean;
recents: string[]; recents: string[];
hub: HubId;
personaId: PersonaId;
} }
function loadPrefs(): Partial<Prefs> { function loadPrefs(): Partial<Prefs> {
@@ -105,6 +109,12 @@ interface AppState {
theme: Theme; theme: Theme;
setTheme: (t: Theme) => void; setTheme: (t: Theme) => void;
/** Hub / persona view-as */
hub: HubId;
setHub: (h: HubId) => void;
personaId: PersonaId;
setPersonaId: (p: PersonaId) => void;
/** Action handlers (real backend mutations). */ /** Action handlers (real backend mutations). */
executeAction: (txId: string, actionId: string, values?: Record<string, unknown>) => Promise<void>; executeAction: (txId: string, actionId: string, values?: Record<string, unknown>) => Promise<void>;
startInstance: (defKey: string, businessSubject?: string) => Promise<string | null>; startInstance: (defKey: string, businessSubject?: string) => Promise<string | null>;
@@ -172,6 +182,8 @@ export const useApp = create<AppState>((set, get) => {
pollEverySec: s.pollEverySec, pollEverySec: s.pollEverySec,
consoleOpen: s.consoleOpen, consoleOpen: s.consoleOpen,
recents: s.recents, recents: s.recents,
hub: s.hub,
personaId: s.personaId,
}); });
}; };
@@ -336,6 +348,11 @@ export const useApp = create<AppState>((set, get) => {
persist(); persist();
}, },
hub: prefs.hub ?? null,
setHub: (hub) => { set({ hub }); persist(); },
personaId: prefs.personaId ?? null,
setPersonaId: (personaId) => { set({ personaId }); persist(); },
executeAction: async (txId, actionId, values = {}) => { executeAction: async (txId, actionId, values = {}) => {
const a = get().actor; const a = get().actor;
if (!a?.user_id) { if (!a?.user_id) {