feat(canvas): add Agent + Hubs + personas
- 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:
+10
@@ -8,6 +8,8 @@ import Wizard from "./scenes/Wizard";
|
||||
import Settings from "./scenes/Settings";
|
||||
import Login from "./scenes/Login";
|
||||
import SsoCallback from "./scenes/SsoCallback";
|
||||
import Agent from "./scenes/Agent";
|
||||
import Hubs from "./scenes/Hubs";
|
||||
import CommandBar from "./components/CommandBar";
|
||||
import Toaster from "./components/Toaster";
|
||||
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")}>
|
||||
<Branch size={13} /> Studio
|
||||
</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")}>
|
||||
<Cog size={13} /> Settings
|
||||
</button>
|
||||
@@ -149,6 +157,8 @@ export default function App() {
|
||||
{scene === "mission" && <MissionControl />}
|
||||
{scene === "history" && <RunHistory />}
|
||||
{scene === "studio" && <Wizard />}
|
||||
{scene === "hubs" && <Hubs />}
|
||||
{scene === "agent" && <Agent />}
|
||||
{scene === "settings" && <Settings />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
@@ -1790,3 +1790,338 @@ 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; }
|
||||
|
||||
/* ===== 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; }
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -6,9 +6,11 @@ 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" | "agent" | "hubs";
|
||||
export type DataMode = "snapshot" | "live";
|
||||
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 {
|
||||
id: number;
|
||||
@@ -26,6 +28,8 @@ interface Prefs {
|
||||
pollEverySec: number;
|
||||
consoleOpen: boolean;
|
||||
recents: string[];
|
||||
hub: HubId;
|
||||
personaId: PersonaId;
|
||||
}
|
||||
|
||||
function loadPrefs(): Partial<Prefs> {
|
||||
@@ -105,6 +109,12 @@ interface AppState {
|
||||
theme: Theme;
|
||||
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). */
|
||||
executeAction: (txId: string, actionId: string, values?: Record<string, unknown>) => Promise<void>;
|
||||
startInstance: (defKey: string, businessSubject?: string) => Promise<string | null>;
|
||||
@@ -172,6 +182,8 @@ export const useApp = create<AppState>((set, get) => {
|
||||
pollEverySec: s.pollEverySec,
|
||||
consoleOpen: s.consoleOpen,
|
||||
recents: s.recents,
|
||||
hub: s.hub,
|
||||
personaId: s.personaId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -336,6 +348,11 @@ export const useApp = create<AppState>((set, get) => {
|
||||
persist();
|
||||
},
|
||||
|
||||
hub: prefs.hub ?? null,
|
||||
setHub: (hub) => { set({ hub }); persist(); },
|
||||
personaId: prefs.personaId ?? null,
|
||||
setPersonaId: (personaId) => { set({ personaId }); persist(); },
|
||||
|
||||
executeAction: async (txId, actionId, values = {}) => {
|
||||
const a = get().actor;
|
||||
if (!a?.user_id) {
|
||||
|
||||
Reference in New Issue
Block a user