From 21031ef1c433c231b0a27f6aed461f3a3021b50f Mon Sep 17 00:00:00 2001 From: Shad Date: Sun, 14 Jun 2026 20:38:32 +0000 Subject: [PATCH] feat(canvas): Agent + Hubs + view-as personas (#1) --- src/App.tsx | 10 ++ src/data/personas.ts | 50 ++++++ src/index.css | 335 +++++++++++++++++++++++++++++++++++++++++ src/lib/agentMemory.ts | 85 +++++++++++ src/scenes/Agent.tsx | 285 +++++++++++++++++++++++++++++++++++ src/scenes/Hubs.tsx | 111 ++++++++++++++ src/state/store.ts | 19 ++- 7 files changed, 894 insertions(+), 1 deletion(-) create mode 100644 src/data/personas.ts create mode 100644 src/lib/agentMemory.ts create mode 100644 src/scenes/Agent.tsx create mode 100644 src/scenes/Hubs.tsx diff --git a/src/App.tsx b/src/App.tsx index adcb8a0..5e1a1ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { + + @@ -149,6 +157,8 @@ export default function App() { {scene === "mission" && } {scene === "history" && } {scene === "studio" && } + {scene === "hubs" && } + {scene === "agent" && } {scene === "settings" && } diff --git a/src/data/personas.ts b/src/data/personas.ts new file mode 100644 index 0000000..36f363f --- /dev/null +++ b/src/data/personas.ts @@ -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); +} diff --git a/src/index.css b/src/index.css index 8b51093..f1c27c5 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } diff --git a/src/lib/agentMemory.ts b/src/lib/agentMemory.ts new file mode 100644 index 0000000..17cac6e --- /dev/null +++ b/src/lib/agentMemory.ts @@ -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 */ + } + }, +}; diff --git a/src/scenes/Agent.tsx b/src/scenes/Agent.tsx new file mode 100644 index 0000000..1c06eea --- /dev/null +++ b/src/scenes/Agent.tsx @@ -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(() => { + 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(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 = { + 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 ( +
+ + +
+
+ {messages.map((m) => ( +
+
+ {m.role === "agent" ? : } +
+
+
{m.text}
+ {m.facts && m.facts.length > 0 && ( +
    + {m.facts.map((f) => ( +
  • + {f.kind} + {f.content} +
  • + ))} +
+ )} + {m.action && ( + + )} +
+
+ ))} +
+
+ +
{ + e.preventDefault(); + send(); + }} + > + setInput(e.target.value)} + autoFocus + /> + +
+
+
+ ); +} diff --git a/src/scenes/Hubs.tsx b/src/scenes/Hubs.tsx new file mode 100644 index 0000000..1e73cdf --- /dev/null +++ b/src/scenes/Hubs.tsx @@ -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 ( +
+
+
+
Hubs
+

Pick your hub

+

Hubs are department-shaped lenses on the same EA2 process catalog. Pick a hub to focus the queue, agents, and processes for that team.

+
+ +
+ +
+ {HUBS.map((h) => { + const matches = scenarios.filter((s) => h.scenarioPrefixes.some((p) => s.id.startsWith(p))); + return ( +
+
+
+
+

{h.label}

+

{h.blurb}

+
+
+
    + {h.highlights.map((hi) => ( +
  • {hi}
  • + ))} +
+
+ + {matches.length} process{matches.length === 1 ? "" : "es"} + + +
+
+ ); + })} +
+ +
+

View-as personas

+

Switch persona from the Agent panel to see Mission Control from a department head's seat.

+ +
+
+ ); +} diff --git a/src/state/store.ts b/src/state/store.ts index f393b6e..89cdad8 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -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 { @@ -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) => Promise; startInstance: (defKey: string, businessSubject?: string) => Promise; @@ -172,6 +182,8 @@ export const useApp = create((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((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) {