Massive overhaul that turns the demo from presenter-mode into a
fully-functional FlowMaster operator surface.
NEW: real backend mutations
- api.executeAction(txId, actionId, actor, values) → POST /api/runtime/transactions/{tx}/actions/{actionId}
- api.startTransaction(defKey, business_subject) → POST /api/runtime/transactions
- api.createProcess(payload) → POST /api/ea2/flow
- store.executeAction / startInstance with toast feedback + auto-refresh
- Inspector Overview action buttons fire real backend calls (Submit/Save Draft/etc)
- LeftRail Confirm/Reject buttons fire real backend calls
- LeftRail 'Start new instance' button starts a real tx for live procurement
- CommandBar 'Real actions' group with 'Start new instance' + 'Execute action on headline tx'
NEW: Process Studio (src/scenes/Studio.tsx)
- in-UI process designer: name + display + hub + description + node list + edge list
- live JSON preview of the EA2 payload
- Publish button calls api.createProcess against demo.flow-master.ai
- Validates locally before publishing
- Auto-refreshes scenarios after publish so the new process shows up
NEW: Settings (src/scenes/Settings.tsx)
- Identity: sign in as any email (loginAs), see actor + display name
- Quick-user buttons for common demo identities
- Backend URL + clear-token diagnostic
- Polling cadence (2-120s)
- Dark/light theme toggle (CSS data-theme attribute)
- Show-console default toggle
- All persisted to localStorage (LS_KEY = fm.mc.prefs.v1)
NEW: Live API console (src/components/Console.tsx)
- Right-side drawer triggered from topbar
- Every fetch (GET/POST/etc) streams in real time with status + duration
- Click any entry to expand request + response JSON
- Filter: all / writes / errors
- Replaces the old guided-tour overlay entirely
NEW: live polling
- store.startPolling()/stopPolling() with setInterval guarded for SSR
- Auto-refresh while in LIVE mode at configurable cadence
REMOVED: Tour.tsx, all startTour() store actions and tour references
- Landing CTA now reads 'Enter Mission Control' / 'Design a process' / 'Open live console'
ALSO:
- api.ts: instrumentedFetch with observer pattern → store.apiLog
- Topbar: user identity chip linking to Settings, Console toggle with badge
- Light theme: minimal CSS data-theme override (text + surfaces only)
- localStorage persistence for mode, scenarioId, email, theme, pollEverySec, consoleOpen, recents
- 24/24 vitest, smoke quick run shows 0 console errors + 7 API calls captured
Confidence: high
Scope-risk: broad (~14 files)
Not-tested: actual end-to-end backend mutation roundtrip (requires LIVE + sign-in; structure proven via probe scripts)
238 lines
9.6 KiB
TypeScript
238 lines
9.6 KiB
TypeScript
// Studio scene — design a new FlowMaster process and publish it to EA2.
|
|
import { useState } from "react";
|
|
import { useApp } from "../state/store";
|
|
import { api } from "../lib/api";
|
|
import { Branch, Play, Check, Close, Bot, User, Cog } from "../components/icons";
|
|
|
|
type NodeKind = "start" | "human_task" | "agent_task" | "service_task" | "end";
|
|
|
|
interface DraftNode {
|
|
id: string;
|
|
type: NodeKind;
|
|
label: string;
|
|
}
|
|
|
|
interface DraftEdge {
|
|
id: string;
|
|
source: string;
|
|
target: string;
|
|
}
|
|
|
|
const DEFAULT_NODES: DraftNode[] = [
|
|
{ id: "start", type: "start", label: "Request received" },
|
|
{ id: "review", type: "human_task", label: "Manager review" },
|
|
{ id: "approved", type: "end", label: "Approved" },
|
|
];
|
|
const DEFAULT_EDGES: DraftEdge[] = [
|
|
{ id: "e1", source: "start", target: "review" },
|
|
{ id: "e2", source: "review", target: "approved" },
|
|
];
|
|
|
|
const KIND_ICON: Record<NodeKind, (p: { size?: number }) => React.ReactElement> = {
|
|
start: Branch,
|
|
end: Branch,
|
|
human_task: User,
|
|
agent_task: Bot,
|
|
service_task: Cog,
|
|
};
|
|
|
|
const ORG_ID = "a0000000-0000-0000-0000-000000000010";
|
|
|
|
export default function Studio() {
|
|
const mode = useApp((s) => s.mode);
|
|
const pushToast = useApp((s) => s.pushToast);
|
|
const refreshLive = useApp((s) => s.refreshLive);
|
|
const actor = useApp((s) => s.actor);
|
|
const setScene = useApp((s) => s.setScene);
|
|
|
|
const [name, setName] = useState("my-process-" + Math.random().toString(36).slice(2, 8));
|
|
const [displayName, setDisplayName] = useState("My Process");
|
|
const [hub, setHub] = useState("procurement");
|
|
const [description, setDescription] = useState("Demo process designed in the Studio.");
|
|
const [nodes, setNodes] = useState<DraftNode[]>(DEFAULT_NODES);
|
|
const [edges, setEdges] = useState<DraftEdge[]>(DEFAULT_EDGES);
|
|
const [publishing, setPublishing] = useState(false);
|
|
const [lastResult, setLastResult] = useState<{ ok: boolean; key?: string; err?: string } | null>(null);
|
|
|
|
const canPublish = mode === "live" && !!actor?.user_id;
|
|
|
|
const addNode = () => {
|
|
const idx = nodes.length;
|
|
setNodes([...nodes, { id: `n${idx}`, type: "human_task", label: `New step ${idx}` }]);
|
|
};
|
|
|
|
const removeNode = (id: string) => {
|
|
setNodes(nodes.filter((n) => n.id !== id));
|
|
setEdges(edges.filter((e) => e.source !== id && e.target !== id));
|
|
};
|
|
|
|
const updateNode = (id: string, patch: Partial<DraftNode>) => {
|
|
setNodes(nodes.map((n) => (n.id === id ? { ...n, ...patch } : n)));
|
|
};
|
|
|
|
const addEdge = () => {
|
|
if (nodes.length < 2) return;
|
|
const idx = edges.length;
|
|
setEdges([...edges, { id: `e${idx + 1}`, source: nodes[0].id, target: nodes[nodes.length - 1].id }]);
|
|
};
|
|
|
|
const removeEdge = (id: string) => setEdges(edges.filter((e) => e.id !== id));
|
|
|
|
const updateEdge = (id: string, patch: Partial<DraftEdge>) => {
|
|
setEdges(edges.map((e) => (e.id === id ? { ...e, ...patch } : e)));
|
|
};
|
|
|
|
const publish = async () => {
|
|
if (!canPublish) {
|
|
pushToast("warn", "Switch to LIVE mode + sign in to publish a process.");
|
|
return;
|
|
}
|
|
setPublishing(true);
|
|
setLastResult(null);
|
|
try {
|
|
const r = await api.createProcess({
|
|
name,
|
|
display_name: displayName,
|
|
label: displayName,
|
|
description,
|
|
hub,
|
|
config: {
|
|
org_id: ORG_ID,
|
|
executable: true,
|
|
nodes: nodes.map((n) => ({ id: n.id, type: n.type, label: n.label })),
|
|
edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target })),
|
|
},
|
|
});
|
|
pushToast("ok", `Published ${r.name} → ${r._key.slice(0, 8)}`);
|
|
setLastResult({ ok: true, key: r._key });
|
|
await refreshLive();
|
|
} catch (e) {
|
|
pushToast("err", `Publish failed: ${(e as Error).message.slice(0, 140)}`);
|
|
setLastResult({ ok: false, err: (e as Error).message });
|
|
} finally {
|
|
setPublishing(false);
|
|
}
|
|
};
|
|
|
|
const valid = nodes.length >= 2 && edges.length >= 1 && nodes.every((n) => n.label.trim().length > 0);
|
|
|
|
return (
|
|
<div className="studio">
|
|
<header className="studio-head">
|
|
<div>
|
|
<div className="mc-hero-eyebrow"><Branch size={12} /> Process Studio</div>
|
|
<h2 className="mc-hero-title">Design a new process</h2>
|
|
<div className="mc-hero-sub">Hand-craft a typed FlowMaster process and publish it straight to EA2 on demo.flow-master.ai.</div>
|
|
</div>
|
|
<button className="btn btn-primary btn-lg" onClick={publish} disabled={!valid || publishing}>
|
|
{publishing ? <span className="spin" /> : <Play size={13} />} Publish to EA2
|
|
{!canPublish && <span className="preview-marker">live + sign-in required</span>}
|
|
</button>
|
|
</header>
|
|
|
|
<div className="studio-grid">
|
|
<section className="studio-panel">
|
|
<h3 className="panel-h">Definition</h3>
|
|
<label className="studio-field">
|
|
<span>name (unique slug)</span>
|
|
<input className="studio-input mono" value={name} onChange={(e) => setName(e.target.value)} />
|
|
</label>
|
|
<label className="studio-field">
|
|
<span>display name</span>
|
|
<input className="studio-input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
|
</label>
|
|
<label className="studio-field">
|
|
<span>hub</span>
|
|
<select className="studio-input" value={hub} onChange={(e) => setHub(e.target.value)}>
|
|
<option value="procurement">procurement</option>
|
|
<option value="finance">finance</option>
|
|
<option value="people">people</option>
|
|
<option value="service">service</option>
|
|
</select>
|
|
</label>
|
|
<label className="studio-field">
|
|
<span>description</span>
|
|
<textarea className="studio-input" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
|
</label>
|
|
</section>
|
|
|
|
<section className="studio-panel">
|
|
<h3 className="panel-h">
|
|
Nodes
|
|
<span className="panel-count">{nodes.length}</span>
|
|
<button className="link-btn" style={{ marginLeft: "auto" }} onClick={addNode}>+ node</button>
|
|
</h3>
|
|
<div className="studio-rows">
|
|
{nodes.map((n) => {
|
|
const Icon = KIND_ICON[n.type] ?? Cog;
|
|
return (
|
|
<div key={n.id} className="studio-row">
|
|
<span className="studio-row-id mono">{n.id}</span>
|
|
<Icon size={13} />
|
|
<select className="studio-input" value={n.type} onChange={(e) => updateNode(n.id, { type: e.target.value as NodeKind })}>
|
|
<option value="start">start</option>
|
|
<option value="human_task">human_task</option>
|
|
<option value="agent_task">agent_task</option>
|
|
<option value="service_task">service_task</option>
|
|
<option value="end">end</option>
|
|
</select>
|
|
<input className="studio-input" value={n.label} onChange={(e) => updateNode(n.id, { label: e.target.value })} />
|
|
<button className="link-btn" onClick={() => removeNode(n.id)} title="Remove"><Close size={11} /></button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="studio-panel">
|
|
<h3 className="panel-h">
|
|
Edges
|
|
<span className="panel-count">{edges.length}</span>
|
|
<button className="link-btn" style={{ marginLeft: "auto" }} onClick={addEdge}>+ edge</button>
|
|
</h3>
|
|
<div className="studio-rows">
|
|
{edges.map((e) => (
|
|
<div key={e.id} className="studio-row">
|
|
<span className="studio-row-id mono">{e.id}</span>
|
|
<select className="studio-input" value={e.source} onChange={(ev) => updateEdge(e.id, { source: ev.target.value })}>
|
|
{nodes.map((n) => <option key={n.id} value={n.id}>{n.id}</option>)}
|
|
</select>
|
|
<span className="mono" style={{ color: "var(--text-3)" }}>→</span>
|
|
<select className="studio-input" value={e.target} onChange={(ev) => updateEdge(e.id, { target: ev.target.value })}>
|
|
{nodes.map((n) => <option key={n.id} value={n.id}>{n.id}</option>)}
|
|
</select>
|
|
<button className="link-btn" onClick={() => removeEdge(e.id)} title="Remove"><Close size={11} /></button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="studio-panel">
|
|
<h3 className="panel-h">JSON preview</h3>
|
|
<pre className="raw-json"><code>{JSON.stringify({
|
|
name, display_name: displayName, hub, description,
|
|
kind: "definition", status: "published", version: 1,
|
|
config: {
|
|
org_id: ORG_ID, executable: true,
|
|
nodes: nodes.map((n) => ({ id: n.id, type: n.type, label: n.label })),
|
|
edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target })),
|
|
},
|
|
}, null, 2)}</code></pre>
|
|
{lastResult && (
|
|
<div className={`studio-result ${lastResult.ok ? "ok" : "err"}`}>
|
|
{lastResult.ok ? (
|
|
<>
|
|
<Check size={12} /> Published as <span className="mono">{lastResult.key?.slice(0, 12)}</span>
|
|
<button className="link-btn" onClick={() => setScene("mission")} style={{ marginLeft: "auto" }}>Open in Mission Control</button>
|
|
</>
|
|
) : (
|
|
<><Close size={12} /> <span className="mono">{lastResult.err?.slice(0, 200)}</span></>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|