Compare commits
10
Commits
4bc7ae228a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1df3122eef | ||
|
|
3df8dd3e36 | ||
|
|
21031ef1c4 | ||
|
|
89fca578d9 | ||
|
|
f1324b7539 | ||
|
|
6f8832d222 | ||
|
|
b26ea1ee9c | ||
|
|
66dafc085d | ||
|
|
553c681ede | ||
|
|
5d6898ee00 |
@@ -1,10 +1,8 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
|
||||||
qa/screenshots
|
qa/screenshots
|
||||||
.git
|
.git
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
src/index.css.bak
|
|
||||||
*.bak*
|
*.bak*
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY dist/ /usr/share/nginx/html/
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
RUN sed -i '/listen.*80/a\ server_name _;' /etc/nginx/conf.d/default.conf || true
|
||||||
|
EXPOSE 80
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||||
|
CMD wget -q --spider http://127.0.0.1/ || exit 1
|
||||||
@@ -1,168 +1,61 @@
|
|||||||
# FlowMaster — Mission Control
|
# FlowMaster — Canvas
|
||||||
|
|
||||||
**Live at https://canvas.flow-master.ai** · [source on Gitea](https://gitea.flow-master.ai/shad/flowmaster-mission-control-demo)
|
**Live at https://canvas.flow-master.ai** · operations cockpit for every process the company runs.
|
||||||
|
|
||||||
The proper FlowMaster frontend: an operations cockpit for every process the
|
Canvas is the FlowMaster operator surface. Industrial-blueprint doctrine:
|
||||||
company runs. Industrial-blueprint doctrine — paper canvas, navy frame,
|
paper canvas, navy frame, amber accent, 1px hairlines, square edges,
|
||||||
amber accent, 1px hairlines, square edges, monospace operational density.
|
monospace operational density. People, Finance, Procurement, and IT in
|
||||||
|
one frame.
|
||||||
|
|
||||||
## What this is (and isn't)
|
## What it is
|
||||||
|
|
||||||
**Is:**
|
- React 19 single-page app (Vite + ReactFlow + cmdk + framer-motion +
|
||||||
|
zustand + dagre).
|
||||||
- A single-page React 19 app (Vite + ReactFlow + cmdk + framer-motion + zustand
|
- Connected directly to EA2 in the browser via the API client at
|
||||||
+ dagre).
|
`src/lib/api.ts`. There is one data source: live EA2. No bundled
|
||||||
- Polished command-center for any FlowMaster process: graph, queue, inspector,
|
snapshot, no synthetic blueprints.
|
||||||
tour, command palette, live-mode toggle, run history.
|
- Scenes: Landing, Mission Control, Run History, Process Studio, Hubs,
|
||||||
- Two data modes:
|
Agent, Settings, Login, SSO callback.
|
||||||
- **SNAPSHOT** (default): bundled `src/scenarios.json`, captured from
|
- Agent scene has a chat surface with per-tenant local memory (`src/lib/agentMemory.ts`)
|
||||||
`demo.flow-master.ai`. Fast, offline, deterministic.
|
and three intents: navigate, retain a fact, start a process draft.
|
||||||
- **LIVE**: in-browser fetch from the same backend via the API client at
|
- Hubs surface departmental lenses (People, Finance, Procurement, IT).
|
||||||
`src/lib/api.ts` (dev-login → bearer → `/api/ea2/work-items` →
|
- Three view-as personas (CEO, Head of People, Head of IT) re-sign in
|
||||||
`/api/ea2/process-definitions/{k}/graph` → `/api/runtime/transactions/{id}`).
|
under the persona's email so the operator UI reflects the seat.
|
||||||
Loading and error states are wired through `src/scenes/MissionControl.tsx`.
|
|
||||||
|
|
||||||
**Isn't:**
|
|
||||||
|
|
||||||
- Not a replacement for the existing demo at https://demo.flow-master.ai
|
|
||||||
(Next.js fm-shell). This is a separate command-center experience.
|
|
||||||
- Not multi-tenant. No auth UI, no tenancy switcher, no settings.
|
|
||||||
- Not wired to actually mutate state. Every action button (start runtime,
|
|
||||||
dispatch agent, approve, decline, confirm) is **preview-only** and fires a
|
|
||||||
toast naming the endpoint that would make it real.
|
|
||||||
|
|
||||||
## Scenarios in the catalog
|
|
||||||
|
|
||||||
| id | mode | source |
|
|
||||||
|-------------|-----------|-------------------------------------------------------------------|
|
|
||||||
| procurement | live | Purchase Requisition → PO (`pr_to_po_def` on demo.flow-master.ai) |
|
|
||||||
| extra-1 | live | Atlas F1 Fresh (procurement variant) |
|
|
||||||
| extra-2 | live | Atlas F1 Fresh (procurement variant) |
|
|
||||||
| ar | blueprint | AR · Customer Refund Approval |
|
|
||||||
| hcm | blueprint | HCM · New Hire Onboarding |
|
|
||||||
| gl | blueprint | GL · Period-End Close |
|
|
||||||
| service | blueprint | Service Ops · Customer Incident |
|
|
||||||
|
|
||||||
**Live** = backed by a real EA2 process definition currently in the demo
|
|
||||||
backend, with real runtime transactions and a real work-item queue.
|
|
||||||
|
|
||||||
**Blueprint** = hand-modelled in the same typed format. Identical UI surface;
|
|
||||||
the backend just doesn't have a runnable definition for it yet. Internal
|
|
||||||
metadata carries `isSynthetic: true` for provenance audits.
|
|
||||||
|
|
||||||
## How honesty is enforced in this code
|
|
||||||
|
|
||||||
This pass came out of an Oracle review that called out theatrical bits in the
|
|
||||||
prior version. Safeguards now in place:
|
|
||||||
|
|
||||||
- **No invented numbers.** `src/components/Telemetry.tsx` derives every value
|
|
||||||
from the active scenarios (`SLA = 1 - errored / cases`, agent acceptance =
|
|
||||||
fraction of agent runs not in `proposed`). The throughput sparkline plots
|
|
||||||
the actual `running` rollup over time, not a sine wave. The "ui tick" dot
|
|
||||||
is labelled as a UI heartbeat and tooltip-named as such.
|
|
||||||
- **No fake buttons.** Every preview-only action fires a toast that names the
|
|
||||||
endpoint that would make it real, and carries a `preview` marker.
|
|
||||||
- **No misleading tour copy.** Blueprint tours frame the scenario as an
|
|
||||||
industry blueprint, not "we don't have this yet".
|
|
||||||
- **Mode is always visible.** Topbar has a `SNAPSHOT`/`LIVE` pill, last-fetch
|
|
||||||
age, and a refresh button when live.
|
|
||||||
|
|
||||||
## Live-mode mechanics (the CORS gotcha)
|
|
||||||
|
|
||||||
`demo.flow-master.ai` does not advertise CORS headers for arbitrary origins.
|
|
||||||
`src/lib/api.ts` therefore uses an **empty `baseUrl` everywhere** (both dev
|
|
||||||
and prod). All `/api/*` requests are same-origin from the browser's
|
|
||||||
perspective; whatever is serving the page is responsible for proxying them
|
|
||||||
to the backend.
|
|
||||||
|
|
||||||
- **Dev** (`pnpm dev`): `vite.config.ts` proxies `/api/*` to
|
|
||||||
`${VITE_FM_BASE:-https://demo.flow-master.ai}`.
|
|
||||||
- **Prod** (Docker image): the bundled `nginx.conf` reverse-proxies `/api/*`
|
|
||||||
to `https://demo.flow-master.ai`. The image is intended to sit behind the
|
|
||||||
`canvas.flow-master.ai` ingress (see `FM06/flowmaster-ops` overlay).
|
|
||||||
- **Anywhere else**: set `VITE_FM_BASE=https://your-backend` at build time
|
|
||||||
and accept that browsers will reject the cross-origin call. Live mode then
|
|
||||||
fails gracefully — `setMode("live")` catches the error, raises an
|
|
||||||
`mc-banner-err` banner + error toast, and falls back to snapshot.
|
|
||||||
|
|
||||||
## Run, test, build
|
## Run, test, build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# refresh the bundled snapshot from demo.flow-master.ai
|
|
||||||
pnpm fetch:scenarios
|
|
||||||
|
|
||||||
# dev (with backend proxy for live mode)
|
# dev (with backend proxy for live mode)
|
||||||
pnpm dev # → http://127.0.0.1:5173
|
pnpm dev # → http://127.0.0.1:5173
|
||||||
|
|
||||||
# tests
|
# tests
|
||||||
pnpm test # vitest, 22 tests across api, live,
|
pnpm test # vitest
|
||||||
# synthetic, layout, store
|
|
||||||
|
|
||||||
# build
|
# build
|
||||||
pnpm build # tsc + vite, single chunk ~225 KB gz
|
pnpm build # tsc + vite, single chunk ~234 KB gz
|
||||||
|
|
||||||
# end-to-end smoke + screenshots
|
|
||||||
pnpm qa:smoke # playwright headless, 28 assertions
|
|
||||||
|
|
||||||
# DOM layout audit
|
|
||||||
pnpm qa:layout
|
|
||||||
```
|
|
||||||
|
|
||||||
## File map
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── data/ # ProcessScenario domain + snapshot + blueprint catalog
|
|
||||||
├── lib/ # API client + live-scenario builder (+ tests)
|
|
||||||
├── state/store.ts # zustand: scene, mode, scenario, tour, recents, toasts
|
|
||||||
├── graph/layout.ts # dagre LR auto-layout (+ tests)
|
|
||||||
├── components/ # ProcessGraph, Inspector, LeftRail, CommandBar, Tour,
|
|
||||||
│ # Telemetry, Toaster, icons
|
|
||||||
├── scenes/ # Landing, MissionControl, RunHistory
|
|
||||||
├── App.tsx # shell: topbar (mode pill / refresh / tour / ⌘K)
|
|
||||||
├── index.css # design system (~700 lines)
|
|
||||||
├── main.tsx
|
|
||||||
└── scenarios.json # cached snapshot of demo.flow-master.ai
|
|
||||||
|
|
||||||
qa/
|
|
||||||
├── smoke.mjs # Playwright e2e + 9 screenshots
|
|
||||||
└── layout_audit.mjs # programmatic clipping/overlap check
|
|
||||||
|
|
||||||
vite.config.ts # /api → demo.flow-master.ai proxy for dev
|
|
||||||
fetch_scenarios.mjs # Node script to refresh src/scenarios.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Production deployment is tracked in
|
Deployment is tracked in `FM06/flowmaster-ops`, overlay
|
||||||
[FM06/flowmaster-ops PR #1164](https://gitea.flow-master.ai/FM06/flowmaster-ops/pulls/1164)
|
`manifests/overlays/demo/`:
|
||||||
(merged), which added three resources to `manifests/overlays/demo/`:
|
|
||||||
|
|
||||||
- `mc-deployment.yaml` — 2-replica nginx Deployment in the `demo` namespace
|
- `mc-deployment.yaml` — replicated nginx Deployment
|
||||||
- `mc-service.yaml` — ClusterIP service on port 80
|
- `mc-service.yaml` — ClusterIP service on port 80
|
||||||
- `mc-ingress.yaml` — Traefik ingress at `canvas.flow-master.ai` with cert-manager DNS-01 cert
|
- `mc-ingress.yaml` — Traefik ingress at `canvas.flow-master.ai` with
|
||||||
|
cert-manager DNS-01 cert
|
||||||
|
|
||||||
Cloudflare A record `canvas.flow-master.ai → 65.21.71.186` was added at the
|
nginx in the image reverse-proxies `/api/*` to `https://demo.flow-master.ai`
|
||||||
same time. The cluster certifies with cert-manager (Let's Encrypt DNS-01)
|
so the SPA is same-origin (no CORS).
|
||||||
and Traefik fronts the nginx pods. nginx reverse-proxies `/api/*` to
|
|
||||||
`https://demo.flow-master.ai` so the SPA is same-origin (no CORS).
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm build # builds dist/
|
pnpm build
|
||||||
docker build -t gitea.flow-master.ai/shad/mission-control-demo:sha-<git> .
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-f Dockerfile.runtime \
|
||||||
|
-t gitea.flow-master.ai/shad/mission-control-demo:sha-<git> \
|
||||||
|
--load .
|
||||||
docker push gitea.flow-master.ai/shad/mission-control-demo:sha-<git>
|
docker push gitea.flow-master.ai/shad/mission-control-demo:sha-<git>
|
||||||
# Then bump the image pin in manifests/overlays/demo/mc-deployment.yaml.
|
# Then bump the image pin in manifests/overlays/demo/mc-deployment.yaml.
|
||||||
```
|
```
|
||||||
|
|
||||||
## What's intentionally not here
|
|
||||||
|
|
||||||
- Authentication UI (dev-login is used because this is a demo lane).
|
|
||||||
- Mutation endpoints (every action is preview-only and the toast names the
|
|
||||||
endpoint).
|
|
||||||
- Mobile layout (1440px-and-up demo surface).
|
|
||||||
- Route persistence / deep linking (scene + scenario live in memory).
|
|
||||||
- i18n (English only).
|
|
||||||
|
|
||||||
Small, well-scoped follow-ups — not architectural changes.
|
|
||||||
|
|||||||
+5
-2
@@ -4,8 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>FlowMaster · Mission Control</title>
|
<title>FlowMaster — Mission Control</title>
|
||||||
<meta name="description" content="FlowMaster Mission Control — operations cockpit for the company's processes." />
|
<meta name="description" content="FlowMaster Mission Control — operations cockpit for every process the company runs. People, finance, procurement, and IT in one frame." />
|
||||||
|
<meta name="robots" content="noindex,nofollow" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<link rel="preconnect" href="https://canvas.flow-master.ai" />
|
||||||
<meta name="theme-color" content="#1a2740" />
|
<meta name="theme-color" content="#1a2740" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "flowmaster-mission-control-demo",
|
"name": "flowmaster-canvas",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+116
-173
@@ -1,7 +1,7 @@
|
|||||||
// Real human-style QA against https://canvas.flow-master.ai.
|
// Real human-style QA against https://canvas.flow-master.ai.
|
||||||
// Drives the live site through every flow a person would touch and
|
// Drives the live site through login → mission → real actions exactly
|
||||||
// captures: console errors, network errors, real action round-trips,
|
// as a person would. Captures: console errors, network errors, real
|
||||||
// identity switching, dark/light, mobile width, screenshot evidence.
|
// round-trips, identity switching, dark/light, mobile, security headers.
|
||||||
//
|
//
|
||||||
// Bypasses local DNS cache by mapping to the LB IP directly.
|
// Bypasses local DNS cache by mapping to the LB IP directly.
|
||||||
import { chromium, devices } from "playwright";
|
import { chromium, devices } from "playwright";
|
||||||
@@ -23,7 +23,6 @@ async function attach(page, label) {
|
|||||||
page.on("pageerror", (e) => note("pageerror", label, e.message.slice(0, 200)));
|
page.on("pageerror", (e) => note("pageerror", label, e.message.slice(0, 200)));
|
||||||
page.on("console", (m) => {
|
page.on("console", (m) => {
|
||||||
if (m.type() === "error") note("console.error", label, m.text().slice(0, 200));
|
if (m.type() === "error") note("console.error", label, m.text().slice(0, 200));
|
||||||
if (m.type() === "warning") note("console.warning", label, m.text().slice(0, 200));
|
|
||||||
});
|
});
|
||||||
page.on("requestfailed", (r) => note("requestfailed", label, `${r.url()} :: ${r.failure()?.errorText}`));
|
page.on("requestfailed", (r) => note("requestfailed", label, `${r.url()} :: ${r.failure()?.errorText}`));
|
||||||
page.on("response", (r) => {
|
page.on("response", (r) => {
|
||||||
@@ -31,6 +30,23 @@ async function attach(page, label) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function devLogin(page) {
|
||||||
|
const emailInput = page.locator("input[type='email'], input[name='email']").first();
|
||||||
|
if (await emailInput.count() === 0) {
|
||||||
|
note("flow", "login", "no email input visible on /login");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await emailInput.fill("dev@flow-master.ai");
|
||||||
|
const devBtn = page.locator("button, a", { hasText: /dev[- ]?login|developer/i }).first();
|
||||||
|
if (await devBtn.count() === 0) {
|
||||||
|
note("flow", "login", "no dev-login button visible — dev-login flag may be off in production");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await devBtn.click();
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
args: [
|
args: [
|
||||||
@@ -39,210 +55,137 @@ const browser = await chromium.launch({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// FLOW 1 — desktop landing → mission → real action round-trip
|
|
||||||
// =========================================================================
|
|
||||||
{
|
{
|
||||||
console.log("\n=== FLOW 1: desktop full walkthrough ===");
|
console.log("\n=== FLOW 0: auth-guard redirect ===");
|
||||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true });
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true });
|
||||||
const p = await ctx.newPage();
|
const p = await ctx.newPage();
|
||||||
await attach(p, "F1");
|
await attach(p, "F0");
|
||||||
const tApiCalls = [];
|
|
||||||
p.on("request", (req) => { if (req.url().includes("/api/")) tApiCalls.push({ url: req.url(), at: Date.now() }); });
|
|
||||||
|
|
||||||
await p.goto(URL, { waitUntil: "networkidle" });
|
await p.goto(URL, { waitUntil: "networkidle" });
|
||||||
await p.screenshot({ path: `${OUT}/f1-01-landing.png` });
|
await p.waitForTimeout(800);
|
||||||
|
await p.screenshot({ path: `${OUT}/f0-01-unauthed.png` });
|
||||||
// R1.S6: scan the page for the word 'demo'
|
|
||||||
const visibleText = await p.evaluate(() => document.body.innerText);
|
const visibleText = await p.evaluate(() => document.body.innerText);
|
||||||
|
const hasLogin = /sign in|log in|continue with microsoft|dev[- ]?login|developer|email/i.test(visibleText);
|
||||||
|
const hasLanding = /every process|mission control overview|enter mission|business-as-code/i.test(visibleText);
|
||||||
|
if (!hasLogin && hasLanding) note("auth-guard", "/", "landing rendered without login — guard not enforced");
|
||||||
|
if (!hasLogin) note("auth-guard", "/", "no login UI visible after redirect");
|
||||||
|
if (hasLogin) note("ok", "auth-guard", "login UI present — guard works");
|
||||||
const demoHits = (visibleText.match(/\bdemo\b/gi) || []).length;
|
const demoHits = (visibleText.match(/\bdemo\b/gi) || []).length;
|
||||||
if (demoHits > 0) note("de-demo", "landing", `'demo' appears ${demoHits} times in visible text`);
|
note(demoHits === 0 ? "ok" : "de-demo", "/", `'demo' visible ${demoHits} times in unauthed view`);
|
||||||
|
|
||||||
// F1.a click first scenario card
|
|
||||||
await p.locator(".sc-card").first().click();
|
|
||||||
await p.waitForSelector(".mc"); await p.waitForTimeout(800);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-02-mission.png` });
|
|
||||||
|
|
||||||
// F1.b switch to LIVE mode
|
|
||||||
await p.locator(".mode-toggle").first().click();
|
|
||||||
const liveOk = await p.waitForFunction(
|
|
||||||
() => Array.from(document.querySelectorAll(".mode-pill")).some((el) => el.textContent?.trim() === "LIVE"),
|
|
||||||
{ timeout: 15000 },
|
|
||||||
).then(() => true).catch(() => false);
|
|
||||||
if (!liveOk) note("flow", "mission", "LIVE toggle did not flip");
|
|
||||||
await p.waitForTimeout(2500);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-03-live.png` });
|
|
||||||
|
|
||||||
// F1.c open console drawer
|
|
||||||
await p.locator(".link-btn", { hasText: /console/i }).first().click();
|
|
||||||
await p.waitForSelector(".console"); await p.waitForTimeout(400);
|
|
||||||
const callsInConsole = await p.locator(".call").count();
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-04-console.png` });
|
|
||||||
if (callsInConsole === 0) note("flow", "console", "console drawer renders but shows 0 API calls");
|
|
||||||
|
|
||||||
// R1.S5: GET-STORM test — sit idle on mission for 60s, count /api calls
|
|
||||||
console.log("idle 60s to measure GET storm…");
|
|
||||||
const idleStart = Date.now();
|
|
||||||
const beforeIdle = tApiCalls.length;
|
|
||||||
await p.waitForTimeout(60000);
|
|
||||||
const afterIdle = tApiCalls.length;
|
|
||||||
const idleCalls = afterIdle - beforeIdle;
|
|
||||||
note("perf", "mission-idle-60s", `${idleCalls} /api calls fired during 60s idle (target <= 8 — i.e. one poll-cycle worth)`);
|
|
||||||
|
|
||||||
// F1.d sign in via Settings quick-user (current build path)
|
|
||||||
await p.locator(".tab", { hasText: /settings/i }).click();
|
|
||||||
await p.waitForSelector(".settings"); await p.waitForTimeout(300);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-05-settings.png` });
|
|
||||||
const quickUserBtns = p.locator(".quick-users .link-btn");
|
|
||||||
const quickUserCount = await quickUserBtns.count();
|
|
||||||
if (quickUserCount === 0) note("flow", "settings", "no quick-user buttons present");
|
|
||||||
if (quickUserCount > 0) {
|
|
||||||
await quickUserBtns.first().click();
|
|
||||||
await p.waitForTimeout(2500);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-06-signed-in.png` });
|
|
||||||
}
|
|
||||||
|
|
||||||
// F1.e try to start a real instance from Mission → LeftRail
|
|
||||||
await p.locator(".tab", { hasText: /mission/i }).click();
|
|
||||||
await p.waitForSelector(".mc"); await p.waitForTimeout(600);
|
|
||||||
const startBtn = p.locator(".start-btn");
|
|
||||||
const startBtnExists = await startBtn.count();
|
|
||||||
if (startBtnExists > 0) {
|
|
||||||
const beforeStart = tApiCalls.filter((c) => c.url.includes("/api/runtime/transactions") && !c.url.includes("/actions/")).length;
|
|
||||||
await startBtn.first().click();
|
|
||||||
await p.waitForTimeout(3000);
|
|
||||||
const afterStart = tApiCalls.filter((c) => c.url.includes("/api/runtime/transactions") && !c.url.includes("/actions/")).length;
|
|
||||||
if (afterStart === beforeStart) {
|
|
||||||
note("flow", "start-instance", "click did not fire POST /api/runtime/transactions");
|
|
||||||
} else {
|
|
||||||
note("ok", "start-instance", `POST /api/runtime/transactions fired ${afterStart - beforeStart} time(s)`);
|
|
||||||
}
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-07-after-start.png` });
|
|
||||||
}
|
|
||||||
|
|
||||||
// F1.f try to fire Submit on the inspector overview
|
|
||||||
await p.locator(".qcard").first().click().catch(() => {});
|
|
||||||
await p.waitForTimeout(400);
|
|
||||||
await p.locator(".itab", { hasText: /overview/i }).click().catch(() => {});
|
|
||||||
await p.waitForTimeout(200);
|
|
||||||
const inspectorBtns = await p.locator(".i-actions .btn").count();
|
|
||||||
if (inspectorBtns > 0) {
|
|
||||||
await p.locator(".i-actions .btn").first().click();
|
|
||||||
await p.waitForTimeout(2000);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-08-after-submit.png` });
|
|
||||||
} else {
|
|
||||||
note("flow", "inspector", "no action buttons in Inspector Overview for selected step");
|
|
||||||
}
|
|
||||||
|
|
||||||
// F1.g try the Process Studio publish path
|
|
||||||
await p.locator(".tab", { hasText: /studio/i }).click();
|
|
||||||
await p.waitForSelector(".studio"); await p.waitForTimeout(500);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-09-studio.png` });
|
|
||||||
const publishBtn = p.locator(".studio-head .btn", { hasText: /publish/i });
|
|
||||||
if (await publishBtn.count() > 0) {
|
|
||||||
const beforePublish = tApiCalls.filter((c) => c.url.includes("/api/ea2/flow")).length;
|
|
||||||
await publishBtn.click();
|
|
||||||
await p.waitForTimeout(3000);
|
|
||||||
const afterPublish = tApiCalls.filter((c) => c.url.includes("/api/ea2/flow")).length;
|
|
||||||
if (afterPublish === beforePublish) {
|
|
||||||
note("flow", "studio-publish", "Publish click did not fire POST /api/ea2/flow");
|
|
||||||
} else {
|
|
||||||
note("ok", "studio-publish", `POST /api/ea2/flow fired ${afterPublish - beforePublish} time(s)`);
|
|
||||||
}
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-10-after-publish.png` });
|
|
||||||
}
|
|
||||||
|
|
||||||
// F1.h check Run History
|
|
||||||
await p.locator(".tab", { hasText: /runs/i }).click();
|
|
||||||
await p.waitForSelector(".rh"); await p.waitForTimeout(300);
|
|
||||||
const rhRows = await p.locator(".rh-row").count();
|
|
||||||
if (rhRows === 0) note("flow", "run-history", "RunHistory shows 0 rows even in live mode");
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-11-runs.png` });
|
|
||||||
|
|
||||||
// F1.i ⌘K palette
|
|
||||||
await p.keyboard.press("Meta+k");
|
|
||||||
await p.waitForSelector(".cmd").catch(() => note("flow", "cmd-palette", "⌘K did not open palette"));
|
|
||||||
await p.waitForTimeout(200);
|
|
||||||
await p.screenshot({ path: `${OUT}/f1-12-palette.png` });
|
|
||||||
await p.keyboard.press("Escape");
|
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// FLOW 2 — mobile viewport (regional store manager on a phone)
|
|
||||||
// =========================================================================
|
|
||||||
{
|
{
|
||||||
console.log("\n=== FLOW 2: mobile (iPhone 14 Pro) ===");
|
console.log("\n=== FLOW 1: dev-login → mission walkthrough ===");
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true });
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
await attach(p, "F1");
|
||||||
|
const apiCalls = [];
|
||||||
|
p.on("request", (req) => { if (req.url().includes("/api/")) apiCalls.push({ url: req.url(), method: req.method(), at: Date.now() }); });
|
||||||
|
|
||||||
|
await p.goto(URL, { waitUntil: "networkidle" });
|
||||||
|
await p.screenshot({ path: `${OUT}/f1-01-login.png` });
|
||||||
|
|
||||||
|
const loggedIn = await devLogin(p);
|
||||||
|
if (!loggedIn) {
|
||||||
|
note("flow", "login", "dev-login flow not available; remaining tests skipped");
|
||||||
|
await ctx.close();
|
||||||
|
} else {
|
||||||
|
await p.screenshot({ path: `${OUT}/f1-02-after-login.png` });
|
||||||
|
const cards = await p.locator(".sc-card").count();
|
||||||
|
if (cards > 0) {
|
||||||
|
await p.locator(".sc-card").first().click();
|
||||||
|
await p.waitForSelector(".mc", { timeout: 8000 }).catch(() => {});
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
await p.screenshot({ path: `${OUT}/f1-03-mission.png` });
|
||||||
|
} else {
|
||||||
|
note("flow", "post-login", "no scenario cards after login");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("idle 30s to measure poll frequency…");
|
||||||
|
const beforeIdle = apiCalls.length;
|
||||||
|
await p.waitForTimeout(30000);
|
||||||
|
const idleCount = apiCalls.length - beforeIdle;
|
||||||
|
note(idleCount <= 6 ? "ok" : "perf", "mission-idle-30s", `${idleCount} /api calls in 30s idle (target ≤6)`);
|
||||||
|
|
||||||
|
// Console drawer test
|
||||||
|
const consoleBtn = p.locator(".link-btn", { hasText: /console/i }).first();
|
||||||
|
if (await consoleBtn.count() > 0) {
|
||||||
|
await consoleBtn.click();
|
||||||
|
await p.waitForSelector(".console").catch(() => {});
|
||||||
|
await p.waitForTimeout(600);
|
||||||
|
await p.screenshot({ path: `${OUT}/f1-04-console.png` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme toggle (in topbar or settings)
|
||||||
|
const themeBtn = p.locator(".theme-toggle, .link-btn[aria-label*='theme' i]").first();
|
||||||
|
if (await themeBtn.count() > 0) {
|
||||||
|
try {
|
||||||
|
const before = await p.evaluate(() => document.documentElement.dataset.theme);
|
||||||
|
await themeBtn.click({ timeout: 5000 });
|
||||||
|
await p.waitForTimeout(500);
|
||||||
|
const after = await p.evaluate(() => document.documentElement.dataset.theme);
|
||||||
|
if (before === after) note("flow", "theme-toggle", `clicked but theme stayed at ${after}`);
|
||||||
|
else note("ok", "theme-toggle", `flipped ${before} → ${after}`);
|
||||||
|
await p.screenshot({ path: `${OUT}/f1-05-after-theme-toggle.png` });
|
||||||
|
} catch (e) {
|
||||||
|
note("flow", "theme-toggle", `click failed: ${(e).message.slice(0, 80)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
note("missing", "theme-toggle", "no visible theme toggle button");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wizard tab
|
||||||
|
const wizardTab = p.locator(".tab", { hasText: /wizard|studio|build/i }).first();
|
||||||
|
if (await wizardTab.count() > 0) {
|
||||||
|
await wizardTab.click();
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
await p.screenshot({ path: `${OUT}/f1-06-wizard.png` });
|
||||||
|
const wizardLanded = await p.evaluate(() => /wizard|build|create.*process|describe/i.test(document.body.innerText));
|
||||||
|
note(wizardLanded ? "ok" : "flow", "wizard", wizardLanded ? "wizard scene visible" : "wizard tab did not navigate");
|
||||||
|
} else {
|
||||||
|
note("missing", "wizard-tab", "no Wizard/Studio tab in topbar");
|
||||||
|
}
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
console.log("\n=== FLOW 2: mobile ===");
|
||||||
const ctx = await browser.newContext({ ...devices["iPhone 14 Pro"], ignoreHTTPSErrors: true });
|
const ctx = await browser.newContext({ ...devices["iPhone 14 Pro"], ignoreHTTPSErrors: true });
|
||||||
const p = await ctx.newPage();
|
const p = await ctx.newPage();
|
||||||
await attach(p, "F2-mobile");
|
await attach(p, "F2-mobile");
|
||||||
await p.goto(URL, { waitUntil: "networkidle" });
|
await p.goto(URL, { waitUntil: "networkidle" });
|
||||||
await p.screenshot({ path: `${OUT}/f2-01-mobile-landing.png` });
|
await p.waitForTimeout(800);
|
||||||
const horizontalScroll = await p.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 2);
|
await p.screenshot({ path: `${OUT}/f2-01-mobile.png` });
|
||||||
if (horizontalScroll) note("mobile", "landing", "horizontal scroll present on mobile — layout overflow");
|
const overflowH = await p.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 2);
|
||||||
const cards = await p.locator(".sc-card").count();
|
note(overflowH ? "mobile" : "ok", "/", overflowH ? "horizontal overflow on mobile" : "no horizontal overflow on mobile");
|
||||||
if (cards === 0) note("mobile", "landing", "0 scenario cards visible on mobile");
|
|
||||||
if (cards > 0) {
|
|
||||||
await p.locator(".sc-card").first().click();
|
|
||||||
await p.waitForTimeout(800);
|
|
||||||
await p.screenshot({ path: `${OUT}/f2-02-mobile-mission.png` });
|
|
||||||
const mcVisible = await p.locator(".mc-body").isVisible();
|
|
||||||
if (!mcVisible) note("mobile", "mission", "MC body not visible on mobile viewport");
|
|
||||||
const leftRailVisible = await p.locator(".left-rail").isVisible();
|
|
||||||
if (!leftRailVisible) note("mobile", "mission", "left rail invisible at mobile width (acceptable but worth noting)");
|
|
||||||
}
|
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// FLOW 3 — reload + deep state check (do we lose mode/scenario across reload?)
|
|
||||||
// =========================================================================
|
|
||||||
{
|
{
|
||||||
console.log("\n=== FLOW 3: persistence across reload ===");
|
console.log("\n=== FLOW 3: security headers ===");
|
||||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true });
|
|
||||||
const p = await ctx.newPage();
|
|
||||||
await attach(p, "F3");
|
|
||||||
await p.goto(URL, { waitUntil: "networkidle" });
|
|
||||||
await p.locator(".sc-card").nth(3).click();
|
|
||||||
await p.waitForSelector(".mc"); await p.waitForTimeout(500);
|
|
||||||
const titleBefore = await p.locator(".mc-hero-title").innerText();
|
|
||||||
await p.reload({ waitUntil: "networkidle" });
|
|
||||||
await p.waitForTimeout(500);
|
|
||||||
// The shell defaults to Landing on reload — is that intentional?
|
|
||||||
const landingBack = await p.locator(".landing").count();
|
|
||||||
if (landingBack > 0) note("persistence", "reload", `reloading from /mission landed on Landing again (scene not persisted); title before was '${titleBefore}'`);
|
|
||||||
await ctx.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// FLOW 4 — security headers (curl-equivalent via fetch)
|
|
||||||
// =========================================================================
|
|
||||||
{
|
|
||||||
console.log("\n=== FLOW 4: security headers ===");
|
|
||||||
const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
|
const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||||
const p = await ctx.newPage();
|
const p = await ctx.newPage();
|
||||||
const resp = await p.goto(URL);
|
const resp = await p.goto(URL);
|
||||||
const h = resp?.headers() ?? {};
|
const h = resp?.headers() ?? {};
|
||||||
const required = [
|
for (const k of [
|
||||||
"strict-transport-security",
|
"strict-transport-security",
|
||||||
"x-content-type-options",
|
"x-content-type-options",
|
||||||
"x-frame-options",
|
"x-frame-options",
|
||||||
"referrer-policy",
|
"referrer-policy",
|
||||||
"content-security-policy",
|
"content-security-policy",
|
||||||
];
|
"permissions-policy",
|
||||||
for (const k of required) {
|
]) {
|
||||||
if (!h[k]) note("security", "headers", `missing ${k}`);
|
if (!h[k]) note("security", "headers", `missing ${k}`);
|
||||||
else note("ok", "headers", `${k}: ${h[k].slice(0, 80)}`);
|
else note("ok", "headers", `${k} present`);
|
||||||
}
|
}
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
// Write the findings dump
|
|
||||||
writeFileSync(`${OUT}/findings.json`, JSON.stringify(findings, null, 2));
|
writeFileSync(`${OUT}/findings.json`, JSON.stringify(findings, null, 2));
|
||||||
const byKind = findings.reduce((acc, f) => { acc[f.kind] = (acc[f.kind] || 0) + 1; return acc; }, {});
|
const byKind = findings.reduce((acc, f) => { acc[f.kind] = (acc[f.kind] || 0) + 1; return acc; }, {});
|
||||||
console.log("\n=== SUMMARY ===");
|
console.log("\n=== SUMMARY ===");
|
||||||
console.log(JSON.stringify(byKind, null, 2));
|
console.log(JSON.stringify(byKind, null, 2));
|
||||||
console.log(`\n→ ${findings.length} findings written to ${OUT}/findings.json`);
|
console.log(`\n→ ${findings.length} findings; screenshots in ${OUT}/`);
|
||||||
console.log(`→ screenshots in ${OUT}/`);
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const DOCTRINE = new Set([
|
|||||||
"#4a5b80", "#7a8aa8",
|
"#4a5b80", "#7a8aa8",
|
||||||
"#c46a14", "#3d6a2c", "#a6342a", "#1d6f82",
|
"#c46a14", "#3d6a2c", "#a6342a", "#1d6f82",
|
||||||
"#0c1322", "#e6edf7",
|
"#0c1322", "#e6edf7",
|
||||||
|
// Microsoft brand square logo (we render this in the SSO button).
|
||||||
|
// These are non-negotiable third-party brand colors; the doctrine
|
||||||
|
// permits third-party brand marks rendered faithfully.
|
||||||
|
"#f25022", "#7fba00", "#00a4ef", "#ffb900",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function fail(msg) { console.log("✗", msg); process.exitCode = 1; }
|
function fail(msg) { console.log("✗", msg); process.exitCode = 1; }
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
const b = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"],
|
||||||
|
});
|
||||||
|
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
await p.locator("input[type='email']").fill("dev@flow-master.ai");
|
||||||
|
const devBtn = p.locator("button", { hasText: /developer|dev-login/i });
|
||||||
|
if (await devBtn.count() > 0) await devBtn.click();
|
||||||
|
await p.waitForTimeout(3000);
|
||||||
|
|
||||||
|
console.log("=== ALL .link-btn buttons ===");
|
||||||
|
const buttons = await p.$$eval(".link-btn", (els) => els.map((e) => ({
|
||||||
|
class: e.className,
|
||||||
|
text: (e.innerText || "").slice(0, 50),
|
||||||
|
aria: e.getAttribute("aria-label"),
|
||||||
|
title: e.getAttribute("title"),
|
||||||
|
})));
|
||||||
|
buttons.forEach(b => console.log(JSON.stringify(b)));
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
const b = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
"--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186",
|
||||||
|
"--ignore-certificate-errors",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const p = await b.newPage({ viewport: { width: 1440, height: 900 } });
|
||||||
|
p.on("console", (m) => console.log("console", m.type(), m.text().slice(0, 200)));
|
||||||
|
p.on("pageerror", (e) => console.log("pageerror", e.message));
|
||||||
|
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(2000);
|
||||||
|
console.log("--- DOM ---");
|
||||||
|
console.log(await p.evaluate(() => document.body.innerHTML.slice(0, 3000)));
|
||||||
|
console.log("--- visible text ---");
|
||||||
|
console.log(await p.evaluate(() => document.body.innerText));
|
||||||
|
console.log("--- buttons ---");
|
||||||
|
const buttons = await p.$$eval("button, a", (els) => els.map((e) => `${e.tagName} disabled=${e.disabled} "${(e.innerText || e.textContent || "").slice(0, 60)}"`));
|
||||||
|
buttons.forEach((b) => console.log(b));
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
const b = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"],
|
||||||
|
});
|
||||||
|
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Sign in first
|
||||||
|
await p.locator("input[type='email']").fill("dev@flow-master.ai");
|
||||||
|
const devBtn = p.locator("button", { hasText: /developer|dev-login/i });
|
||||||
|
if (await devBtn.count() > 0) await devBtn.click();
|
||||||
|
await p.waitForTimeout(2500);
|
||||||
|
|
||||||
|
console.log("after login theme:", await p.evaluate(() => document.documentElement.dataset.theme));
|
||||||
|
console.log("after login body bg:", await p.evaluate(() => getComputedStyle(document.body).backgroundColor));
|
||||||
|
|
||||||
|
const toggle = p.locator(".theme-toggle, .link-btn.theme-toggle, button[aria-label*='theme']").first();
|
||||||
|
const count = await toggle.count();
|
||||||
|
console.log("toggle count:", count);
|
||||||
|
if (count > 0) {
|
||||||
|
await toggle.click();
|
||||||
|
await p.waitForTimeout(800);
|
||||||
|
console.log("after click theme:", await p.evaluate(() => document.documentElement.dataset.theme));
|
||||||
|
console.log("after click body bg:", await p.evaluate(() => getComputedStyle(document.body).backgroundColor));
|
||||||
|
}
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
const b = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"],
|
||||||
|
});
|
||||||
|
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
// Get the computed --bp-paper at root
|
||||||
|
console.log("html dataset.theme:", await p.evaluate(() => document.documentElement.dataset.theme));
|
||||||
|
console.log("--bp-paper (root):", await p.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--bp-paper")));
|
||||||
|
console.log("body background:", await p.evaluate(() => getComputedStyle(document.body).backgroundColor));
|
||||||
|
|
||||||
|
// Force toggle data-theme via JS
|
||||||
|
await p.evaluate(() => document.documentElement.dataset.theme = "dark");
|
||||||
|
await p.waitForTimeout(200);
|
||||||
|
console.log("after force dark:");
|
||||||
|
console.log(" --bp-paper:", await p.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--bp-paper")));
|
||||||
|
console.log(" body bg:", await p.evaluate(() => getComputedStyle(document.body).backgroundColor));
|
||||||
|
|
||||||
|
await p.evaluate(() => document.documentElement.dataset.theme = "light");
|
||||||
|
await p.waitForTimeout(200);
|
||||||
|
console.log("after force light:");
|
||||||
|
console.log(" --bp-paper:", await p.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--bp-paper")));
|
||||||
|
console.log(" body bg:", await p.evaluate(() => getComputedStyle(document.body).backgroundColor));
|
||||||
|
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
const b = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"],
|
||||||
|
});
|
||||||
|
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "domcontentloaded" });
|
||||||
|
await p.waitForLoadState("networkidle");
|
||||||
|
await p.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Read the data-theme attribute repeatedly
|
||||||
|
console.log("docElement.dataset.theme:", await p.evaluate(() => document.documentElement.dataset.theme));
|
||||||
|
console.log("docElement attributes:", await p.evaluate(() => document.documentElement.getAttributeNames().join(",")));
|
||||||
|
console.log("html outerHTML head:", await p.evaluate(() => document.documentElement.outerHTML.slice(0, 200)));
|
||||||
|
console.log("computed --bp-paper:", await p.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--bp-paper")));
|
||||||
|
|
||||||
|
// Try setting dark via JS and reading
|
||||||
|
await p.evaluate(() => {
|
||||||
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
|
});
|
||||||
|
await p.waitForTimeout(500);
|
||||||
|
console.log("\nafter setAttribute('data-theme','dark'):");
|
||||||
|
console.log("dataset.theme:", await p.evaluate(() => document.documentElement.dataset.theme));
|
||||||
|
console.log("--bp-paper:", await p.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--bp-paper")));
|
||||||
|
console.log("body bg:", await p.evaluate(() => getComputedStyle(document.body).backgroundColor));
|
||||||
|
|
||||||
|
await b.close();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
const b = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"],
|
||||||
|
});
|
||||||
|
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
await p.locator("input[type='email']").fill("dev@flow-master.ai");
|
||||||
|
const devBtn = p.locator("button", { hasText: /developer|dev-login/i });
|
||||||
|
if (await devBtn.count() > 0) await devBtn.click();
|
||||||
|
await p.waitForTimeout(3000);
|
||||||
|
console.log("topbar exists:", await p.locator(".topbar").count());
|
||||||
|
console.log("topbar HTML:");
|
||||||
|
console.log(await p.evaluate(() => document.querySelector(".topbar")?.outerHTML?.slice(0, 2000)));
|
||||||
|
await b.close();
|
||||||
+36
-14
@@ -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>
|
||||||
@@ -110,21 +118,17 @@ export default function App() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className={`link-btn mode-toggle mode-${mode}`}
|
className={`link-btn mode-toggle mode-${mode}`}
|
||||||
onClick={() => setMode(mode === "live" ? "snapshot" : "live")}
|
onClick={() => setMode("live")}
|
||||||
disabled={liveLoading}
|
disabled={liveLoading}
|
||||||
title={mode === "live"
|
title={`Connected to EA2. Fetched ${liveAge ?? "—"}. Click to refresh.`}
|
||||||
? `Live mode is on. Fetched ${liveAge}. Click to drop back to snapshot.`
|
|
||||||
: "Click to fetch live scenarios from demo.flow-master.ai in the browser."}
|
|
||||||
>
|
>
|
||||||
{liveLoading ? <span className="spin" /> : <Pulse size={12} />}
|
{liveLoading ? <span className="spin" /> : <Pulse size={12} />}
|
||||||
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
|
<span className={`mode-pill mode-${mode}`}>EA2</span>
|
||||||
{mode === "live" && liveAge && <span className="topbar-age">{liveAge}</span>}
|
{liveAge && <span className="topbar-age">{liveAge}</span>}
|
||||||
|
</button>
|
||||||
|
<button className="link-btn" onClick={refreshLive} disabled={liveLoading} title="Re-fetch processes from EA2">
|
||||||
|
<Refresh size={12} /> Refresh
|
||||||
</button>
|
</button>
|
||||||
{mode === "live" && (
|
|
||||||
<button className="link-btn" onClick={refreshLive} disabled={liveLoading} title="Re-fetch live scenarios">
|
|
||||||
<Refresh size={12} /> Refresh
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className={`link-btn${consoleOpen ? " is-on" : ""}`}
|
className={`link-btn${consoleOpen ? " is-on" : ""}`}
|
||||||
onClick={() => setConsoleOpen(!consoleOpen)}
|
onClick={() => setConsoleOpen(!consoleOpen)}
|
||||||
@@ -133,9 +137,8 @@ export default function App() {
|
|||||||
<Layers size={12} /> Console
|
<Layers size={12} /> Console
|
||||||
{apiLogCount > 0 && <span className="badge mono">{apiLogCount}</span>}
|
{apiLogCount > 0 && <span className="badge mono">{apiLogCount}</span>}
|
||||||
</button>
|
</button>
|
||||||
<button className="link-btn" onClick={() => useApp.getState().setTheme(useApp.getState().theme === "dark" ? "light" : "dark")} title="Toggle theme">
|
<ThemeToggle />
|
||||||
{useApp.getState().theme === "dark" ? <Sun size={13} /> : <Moon size={13} />}
|
|
||||||
</button>
|
|
||||||
<button className="link-btn" onClick={() => setCmdOpen(true)}>
|
<button className="link-btn" onClick={() => setCmdOpen(true)}>
|
||||||
<Cmd size={13} /> <kbd>⌘K</kbd>
|
<Cmd size={13} /> <kbd>⌘K</kbd>
|
||||||
</button>
|
</button>
|
||||||
@@ -150,6 +153,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>
|
||||||
|
|
||||||
@@ -159,3 +164,20 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThemeToggle() {
|
||||||
|
const theme = useApp((s) => s.theme);
|
||||||
|
const setTheme = useApp((s) => s.setTheme);
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="link-btn theme-toggle"
|
||||||
|
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||||
|
title={isDark ? "Switch to light theme" : "Switch to dark theme"}
|
||||||
|
aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun size={13} /> : <Moon size={13} />}
|
||||||
|
<span className="theme-toggle-label">{isDark ? "LIGHT" : "DARK"}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,18 +92,14 @@ export default function CommandBar() {
|
|||||||
{mode === "live" ? (
|
{mode === "live" ? (
|
||||||
<Command.Item onSelect={() => { refreshLive(); close(); }}>
|
<Command.Item onSelect={() => { refreshLive(); close(); }}>
|
||||||
<Refresh size={13} /> Refresh live scenarios
|
<Refresh size={13} /> Refresh live scenarios
|
||||||
<span className="cmd-hint">re-fetch demo.flow-master.ai</span>
|
<span className="cmd-hint">re-fetch from EA2</span>
|
||||||
</Command.Item>
|
</Command.Item>
|
||||||
) : (
|
) : (
|
||||||
<Command.Item onSelect={() => { setMode("live"); close(); }}>
|
<Command.Item onSelect={() => { setMode("live"); close(); }}>
|
||||||
<Refresh size={13} /> Switch to LIVE mode (fetch demo.flow-master.ai)
|
<Refresh size={13} /> Connect to EA2
|
||||||
<span className="cmd-hint">in-browser</span>
|
<span className="cmd-hint">in-browser</span>
|
||||||
</Command.Item>
|
</Command.Item>
|
||||||
)}
|
)}
|
||||||
<Command.Item onSelect={() => { setMode("snapshot"); close(); }}>
|
|
||||||
<Layers size={13} /> Switch to SNAPSHOT mode
|
|
||||||
<span className="cmd-hint">bundled JSON</span>
|
|
||||||
</Command.Item>
|
|
||||||
<Command.Item onSelect={() => { setConsoleOpen(true); close(); }}>
|
<Command.Item onSelect={() => { setConsoleOpen(true); close(); }}>
|
||||||
<Layers size={13} /> Open live API console
|
<Layers size={13} /> Open live API console
|
||||||
<span className="cmd-hint">stream every fetch</span>
|
<span className="cmd-hint">stream every fetch</span>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
// Snapshot-mode catalog (bundled scenarios.json + synthetic blueprints).
|
// Live-only scenario catalog. EA2 is the sole source of truth at runtime;
|
||||||
// Components should read the active catalog from the store via
|
// liveMeta is a build-time fingerprint of the bundled scenarios.json,
|
||||||
// `useApp((s) => s.scenarios)` — that way live mode can hot-swap.
|
// used only for cosmetic labels (host name, last-fetched timestamp).
|
||||||
import { liveScenarios, liveMeta } from "./live";
|
import { liveMeta } from "./live";
|
||||||
import { syntheticScenarios } from "./synthetic";
|
|
||||||
import type { ProcessScenario } from "./types";
|
import type { ProcessScenario } from "./types";
|
||||||
|
|
||||||
export const snapshotScenarios: ProcessScenario[] = [...liveScenarios, ...syntheticScenarios];
|
export const snapshotScenarios: ProcessScenario[] = [];
|
||||||
export const snapshotDefaultId = snapshotScenarios[0]?.id ?? "";
|
export const snapshotDefaultId = "";
|
||||||
|
|
||||||
export { liveMeta, liveScenarios, syntheticScenarios };
|
export { liveMeta };
|
||||||
export type { ProcessScenario } from "./types";
|
export type { ProcessScenario } from "./types";
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { syntheticScenarios } from "./synthetic";
|
|
||||||
|
|
||||||
describe("syntheticScenarios", () => {
|
|
||||||
it("has exactly 4 families: ar, hcm, gl, service", () => {
|
|
||||||
expect(syntheticScenarios.map((s) => s.id).sort()).toEqual(["ar", "gl", "hcm", "service"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("each scenario is marked live=false and has a representative running step", () => {
|
|
||||||
for (const s of syntheticScenarios) {
|
|
||||||
expect(s.live).toBe(false);
|
|
||||||
expect(s.steps.some((st) => st.state === "running")).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("edges only reference existing step ids", () => {
|
|
||||||
for (const s of syntheticScenarios) {
|
|
||||||
const ids = new Set(s.steps.map((st) => st.id));
|
|
||||||
for (const e of s.edges) {
|
|
||||||
expect(ids.has(e.source)).toBe(true);
|
|
||||||
expect(ids.has(e.target)).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("queue items reference real step ids", () => {
|
|
||||||
for (const s of syntheticScenarios) {
|
|
||||||
const ids = new Set(s.steps.map((st) => st.id));
|
|
||||||
for (const q of s.queue) expect(ids.has(q.stepId)).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
// Hand-crafted scenarios for families not yet running on the live demo backend.
|
|
||||||
// Each scenario is clearly marked live=false; the UI surfaces a "Synthetic"
|
|
||||||
// badge so viewers know it's a designed example, not a live read.
|
|
||||||
import type {
|
|
||||||
AgentRun, FlowEdge, ProcessScenario, ProcessStep, QueueItem,
|
|
||||||
Rule, RuntimeState, RunSummary, StepKind, TourStep,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
interface SyntheticNode {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
kind: StepKind;
|
|
||||||
owner: "human" | "agent" | "system";
|
|
||||||
state: RuntimeState;
|
|
||||||
governs?: string[];
|
|
||||||
actions?: { id: string; label: string; kind: "complete" | "approve" | "decline" | "fork" }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function build(
|
|
||||||
id: string,
|
|
||||||
family: ProcessScenario["family"],
|
|
||||||
defKey: string,
|
|
||||||
defName: string,
|
|
||||||
tagline: string,
|
|
||||||
nodes: SyntheticNode[],
|
|
||||||
edges: { id: string; source: string; target: string; label?: string }[],
|
|
||||||
rules: Rule[],
|
|
||||||
evidenceSeeds: { stepId: string; at: string; actor: string; summary: string }[],
|
|
||||||
queue: Omit<QueueItem, "id">[],
|
|
||||||
agentRuns: Omit<AgentRun, "id">[],
|
|
||||||
runs: RunSummary[],
|
|
||||||
kpis: ProcessScenario["kpis"],
|
|
||||||
tour: TourStep[],
|
|
||||||
): ProcessScenario {
|
|
||||||
const steps: ProcessStep[] = nodes.map((n) => ({
|
|
||||||
id: n.id,
|
|
||||||
name: n.name,
|
|
||||||
kind: n.kind,
|
|
||||||
owner: n.owner,
|
|
||||||
governs: n.governs ?? [],
|
|
||||||
state: n.state,
|
|
||||||
actions: n.actions ?? [],
|
|
||||||
}));
|
|
||||||
const stepIndex = new Map(steps.map((s, i) => [s.id, i]));
|
|
||||||
const runningIdx = steps.findIndex((s) => s.state === "running");
|
|
||||||
const builtEdges: FlowEdge[] = edges.map((e) => {
|
|
||||||
const srcIdx = stepIndex.get(e.source) ?? -1;
|
|
||||||
return {
|
|
||||||
id: e.id,
|
|
||||||
source: e.source,
|
|
||||||
target: e.target,
|
|
||||||
label: e.label,
|
|
||||||
traversed: runningIdx >= 0 ? srcIdx < runningIdx : false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
family,
|
|
||||||
live: false,
|
|
||||||
defKey,
|
|
||||||
defName,
|
|
||||||
version: "v1",
|
|
||||||
headlineTx: null,
|
|
||||||
tagline,
|
|
||||||
steps,
|
|
||||||
edges: builtEdges,
|
|
||||||
rules,
|
|
||||||
evidence: evidenceSeeds.map((e, i) => ({ id: `ev-${i}`, isSynthetic: true, ...e })),
|
|
||||||
queue: queue.map((q, i) => ({ id: `q-${i}`, ...q })),
|
|
||||||
agentRuns: agentRuns.map((a, i) => ({ id: `a-${i}`, isSynthetic: true, ...a })),
|
|
||||||
runs,
|
|
||||||
kpis,
|
|
||||||
defaultStepId: steps.find((s) => s.state === "running")?.id || steps[0]?.id || "",
|
|
||||||
tour,
|
|
||||||
raw: { synthetic: true, nodes, edges },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function baseTour(familyId: string): TourStep[] {
|
|
||||||
return [
|
|
||||||
{ id: "t1", anchor: "graph", title: `${familyId} industry blueprint`, body: `This is the canonical ${familyId} process modelled in FlowMaster's typed format — start, agent/service/human steps, decision branches, rules, evidence. Every node is a real EA2 step kind; this same graph would execute end-to-end once your ${familyId} hub is connected.` },
|
|
||||||
{ id: "t2", anchor: "queue", title: "Realistic case load", body: `These queue cards mirror what an active ${familyId} ops team sees daily — running approvals, agent runs, errored handoffs. Switch to a live scenario at the top to see the procurement queue pulled straight from the runtime API.` },
|
|
||||||
{ id: "t3", anchor: "inspector", title: "Same shape for every process", body: "The right rail is identical across live and blueprint scenarios — typed fields, governing rules, evidence trail, runs, raw payload. That's the point: one inspector, every process family." },
|
|
||||||
{ id: "t4", anchor: "command", title: "Drive Mission Control with ⌘K", body: "Press ⌘K (or Ctrl+K) to switch scenarios, jump to a step, toggle live mode, or start a tour. Everything is one keystroke away." },
|
|
||||||
{ id: "t5", anchor: "telemetry", title: "Cross-family rollup", body: "The bottom strip rolls running, errored, and SLA across every scenario in the catalog — blueprint and live — so an operations lead sees one number for the whole company." },
|
|
||||||
{ id: "t6", anchor: "graph", title: "You're in control", body: "That's the loop. Try another scenario, open the command palette, or flip LIVE mode in the topbar to fetch fresh data from demo.flow-master.ai right in the browser." },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- AR · Customer Refund Approval ----------
|
|
||||||
export const arRefund = build(
|
|
||||||
"ar",
|
|
||||||
{ id: "ar", label: "Accounts Receivable", subtitle: "Refunds, credits & collections", accent: "#3d6a2c" },
|
|
||||||
"syn-ar-refund-v1",
|
|
||||||
"AR · Customer Refund Approval",
|
|
||||||
"Customer refund · risk-scored · synthetic preview",
|
|
||||||
[
|
|
||||||
{ id: "intake", name: "Refund request received", kind: "start", owner: "system", state: "done" },
|
|
||||||
{ id: "validate", name: "Validate purchase & eligibility", kind: "service", owner: "system", state: "done" },
|
|
||||||
{ id: "risk", name: "Fraud & risk scoring", kind: "agent", owner: "agent", state: "done" },
|
|
||||||
{
|
|
||||||
id: "review",
|
|
||||||
name: "Finance review",
|
|
||||||
kind: "human",
|
|
||||||
owner: "human",
|
|
||||||
state: "running",
|
|
||||||
governs: ["refund-cap", "fraud-flag"],
|
|
||||||
actions: [
|
|
||||||
{ id: "approve", label: "Approve refund", kind: "approve" },
|
|
||||||
{ id: "decline", label: "Decline", kind: "decline" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ id: "credit_memo", name: "Post credit memo to GL", kind: "service", owner: "system", state: "queued" },
|
|
||||||
{ id: "notify", name: "Notify customer", kind: "service", owner: "system", state: "idle" },
|
|
||||||
{ id: "done", name: "Refund closed", kind: "end", owner: "system", state: "idle" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "e1", source: "intake", target: "validate" },
|
|
||||||
{ id: "e2", source: "validate", target: "risk" },
|
|
||||||
{ id: "e3", source: "risk", target: "review" },
|
|
||||||
{ id: "e4", source: "review", target: "credit_memo", label: "approve" },
|
|
||||||
{ id: "e5", source: "review", target: "notify", label: "decline" },
|
|
||||||
{ id: "e6", source: "credit_memo", target: "notify" },
|
|
||||||
{ id: "e7", source: "notify", target: "done" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "refund-cap", name: "Refund cap", expr: "amount > $5,000 → CFO approval required", isSynthetic: true },
|
|
||||||
{ id: "fraud-flag", name: "Fraud flag", expr: "risk_score > 0.8 → hold and escalate", isSynthetic: true },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ stepId: "intake", at: "09:12", actor: "customer-portal", summary: "Customer #C-104522 submitted refund #RF-0091 for $1,840 (order #O-771)" },
|
|
||||||
{ stepId: "validate", at: "09:12", actor: "service:ar-validator", summary: "Eligibility OK (within 30-day window, order delivered, no prior refund)" },
|
|
||||||
{ stepId: "risk", at: "09:13", actor: "agent:risk-v3", summary: "Risk score 0.18 (LOW). Vendor reputation good. No chargeback history." },
|
|
||||||
{ stepId: "review", at: "09:14", actor: "agent:sidekick", summary: "Proposed: approve. Awaiting CFO sign-off (within delegation matrix)." },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ stepId: "review", title: "RF-0091 · CFO approval", waitingOn: "approval", ageDays: 0, status: "running" },
|
|
||||||
{ stepId: "review", title: "RF-0088 · CFO approval", waitingOn: "approval", ageDays: 1, status: "running" },
|
|
||||||
{ stepId: "risk", title: "RF-0093 · risk scoring", waitingOn: "agent", ageDays: 0, status: "running" },
|
|
||||||
{ stepId: "credit_memo", title: "RF-0085 · post credit memo", waitingOn: "input", ageDays: 0, status: "queued" },
|
|
||||||
{ stepId: "notify", title: "RF-0079 · notify customer", waitingOn: "input", ageDays: 2, status: "errored" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ stepId: "review", status: "awaiting-confirm", intent: "Approve refund RF-0091 ($1,840) — within CFO delegation." },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "RF-0091", shortId: "RF-0091", activeStep: "Finance review", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 12).toISOString(), durationSec: 720 },
|
|
||||||
{ id: "RF-0088", shortId: "RF-0088", activeStep: "Finance review", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), durationSec: 93600 },
|
|
||||||
{ id: "RF-0085", shortId: "RF-0085", activeStep: "Post credit memo", status: "queued", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 4).toISOString(), durationSec: 14400 },
|
|
||||||
{ id: "RF-0079", shortId: "RF-0079", activeStep: "Notify customer", status: "errored", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 50).toISOString(), durationSec: 180000 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ label: "Open refunds", value: "23", trend: "up", trendValue: "+4 wk" },
|
|
||||||
{ label: "Avg cycle", value: "1.4d", trend: "down", trendValue: "-18%" },
|
|
||||||
{ label: "Auto-approved", value: "62%", trend: "up", trendValue: "+8%" },
|
|
||||||
{ label: "Refund $ MTD", value: "$48.3k", trend: "flat" },
|
|
||||||
],
|
|
||||||
baseTour("AR"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------- HCM · New Hire Onboarding ----------
|
|
||||||
export const hcmOnboarding = build(
|
|
||||||
"hcm",
|
|
||||||
{ id: "hcm", label: "People Operations", subtitle: "Onboard · Offboard · Leave", accent: "#1d6f82" },
|
|
||||||
"syn-hcm-onboarding-v1",
|
|
||||||
"HCM · New Hire Onboarding",
|
|
||||||
"Day-0 → Day-30 onboarding · synthetic preview",
|
|
||||||
[
|
|
||||||
{ id: "offer", name: "Offer accepted", kind: "start", owner: "system", state: "done" },
|
|
||||||
{ id: "paperwork", name: "Collect paperwork (I-9, W-4)", kind: "human", owner: "human", state: "done", actions: [{ id: "complete", label: "Submit", kind: "complete" }] },
|
|
||||||
{ id: "background", name: "Background check", kind: "agent", owner: "agent", state: "done" },
|
|
||||||
{ id: "provision", name: "Provision laptop & accounts", kind: "service", owner: "system", state: "running" },
|
|
||||||
{ id: "buddy", name: "Assign buddy", kind: "human", owner: "human", state: "queued", actions: [{ id: "complete", label: "Assign", kind: "complete" }] },
|
|
||||||
{ id: "day1", name: "Day-1 welcome", kind: "human", owner: "human", state: "idle", actions: [{ id: "complete", label: "Confirm", kind: "complete" }] },
|
|
||||||
{ id: "training", name: "Generate role-specific training plan", kind: "agent", owner: "agent", state: "idle" },
|
|
||||||
{ id: "checkin", name: "30-day check-in", kind: "human", owner: "human", state: "idle", actions: [{ id: "complete", label: "Complete check-in", kind: "complete" }] },
|
|
||||||
{ id: "ramped", name: "Onboarding complete", kind: "end", owner: "system", state: "idle" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "e1", source: "offer", target: "paperwork" },
|
|
||||||
{ id: "e2", source: "paperwork", target: "background" },
|
|
||||||
{ id: "e3", source: "background", target: "provision" },
|
|
||||||
{ id: "e4", source: "provision", target: "buddy" },
|
|
||||||
{ id: "e5", source: "buddy", target: "day1" },
|
|
||||||
{ id: "e6", source: "day1", target: "training" },
|
|
||||||
{ id: "e7", source: "training", target: "checkin" },
|
|
||||||
{ id: "e8", source: "checkin", target: "ramped" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "provision-sla", name: "Provisioning SLA", expr: "IT request open > 3 business days → page IT-Ops on-call", isSynthetic: true },
|
|
||||||
{ id: "training-cap", name: "Training plan cap", expr: "training plan > 12 modules → manager review", isSynthetic: true },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ stepId: "offer", at: "Mon 10:01", actor: "hcm-ats", summary: "Riley Park accepted offer for SWE-3 (Eng / Platform)" },
|
|
||||||
{ stepId: "paperwork", at: "Mon 11:34", actor: "Riley Park", summary: "Submitted I-9 + W-4, signed offer letter" },
|
|
||||||
{ stepId: "background", at: "Tue 08:09", actor: "agent:bg-check", summary: "Background check clear (Checkr report #BC-91244)" },
|
|
||||||
{ stepId: "provision", at: "Tue 08:12", actor: "service:it-provision", summary: "Laptop ordered (MBP 16, M4 Pro); Okta + Slack + Linear seats reserved" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ stepId: "provision", title: "Riley Park · provisioning", waitingOn: "input", ageDays: 0, status: "running" },
|
|
||||||
{ stepId: "buddy", title: "Sam Diaz · buddy assignment", waitingOn: "approval", ageDays: 1, status: "running" },
|
|
||||||
{ stepId: "day1", title: "Jordan Wu · Day-1 welcome", waitingOn: "approval", ageDays: 0, status: "queued" },
|
|
||||||
{ stepId: "checkin", title: "Alex Kim · 30-day check-in", waitingOn: "approval", ageDays: 14, status: "running" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ stepId: "training", status: "proposed", intent: "Draft 8-module training plan for Riley Park (SWE-3, Platform team)" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "H-0091", shortId: "Riley Park", activeStep: "Provision laptop & accounts", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 28).toISOString(), durationSec: 100800 },
|
|
||||||
{ id: "H-0090", shortId: "Sam Diaz", activeStep: "Assign buddy", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 52).toISOString(), durationSec: 187200 },
|
|
||||||
{ id: "H-0088", shortId: "Jordan Wu", activeStep: "Day-1 welcome", status: "queued", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 96).toISOString(), durationSec: 345600 },
|
|
||||||
{ id: "H-0079", shortId: "Alex Kim", activeStep: "30-day check-in", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 28).toISOString(), durationSec: 2419200 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ label: "Active onboardings", value: "11", trend: "up", trendValue: "+3 wk" },
|
|
||||||
{ label: "Avg time-to-ramp", value: "28d", trend: "down", trendValue: "-4d" },
|
|
||||||
{ label: "On-time provisioning", value: "94%", trend: "up", trendValue: "+6%" },
|
|
||||||
{ label: "Buddy assigned <24h", value: "82%", trend: "flat" },
|
|
||||||
],
|
|
||||||
baseTour("HCM"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------- GL · Period-End Close ----------
|
|
||||||
export const glClose = build(
|
|
||||||
"gl",
|
|
||||||
{ id: "gl", label: "GL Close", subtitle: "Accruals, reconciliations, journals", accent: "#c46a14" },
|
|
||||||
"syn-gl-close-v1",
|
|
||||||
"GL · Period-End Close",
|
|
||||||
"Period-end close · automated accrual collection · synthetic preview",
|
|
||||||
[
|
|
||||||
{ id: "cutoff", name: "Period cutoff", kind: "start", owner: "system", state: "done" },
|
|
||||||
{ id: "accruals", name: "Collect accruals from subledgers", kind: "agent", owner: "agent", state: "done" },
|
|
||||||
{ id: "bank_rec", name: "Bank reconciliation", kind: "service", owner: "system", state: "done" },
|
|
||||||
{ id: "intercompany", name: "Intercompany match", kind: "service", owner: "system", state: "running" },
|
|
||||||
{ id: "journals", name: "Journal entry review", kind: "human", owner: "human", state: "queued", governs: ["journal-threshold"], actions: [{ id: "post", label: "Post journals", kind: "complete" }] },
|
|
||||||
{ id: "variance", name: "Variance analysis & narrative", kind: "agent", owner: "agent", state: "idle" },
|
|
||||||
{ id: "signoff", name: "CFO sign-off", kind: "human", owner: "human", state: "idle", governs: ["signoff-quorum"], actions: [{ id: "signoff", label: "Sign off", kind: "approve" }] },
|
|
||||||
{ id: "closed", name: "Books closed", kind: "end", owner: "system", state: "idle" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "e1", source: "cutoff", target: "accruals" },
|
|
||||||
{ id: "e2", source: "accruals", target: "bank_rec" },
|
|
||||||
{ id: "e3", source: "bank_rec", target: "intercompany" },
|
|
||||||
{ id: "e4", source: "intercompany", target: "journals" },
|
|
||||||
{ id: "e5", source: "journals", target: "variance" },
|
|
||||||
{ id: "e6", source: "variance", target: "signoff" },
|
|
||||||
{ id: "e7", source: "signoff", target: "closed" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "journal-threshold", name: "Journal threshold", expr: "any journal > $50k → controller review", isSynthetic: true },
|
|
||||||
{ id: "signoff-quorum", name: "Sign-off quorum", expr: "requires CFO + Controller", isSynthetic: true },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ stepId: "cutoff", at: "Mon 18:00", actor: "scheduler", summary: "Period 2026-06 cut over; subledger snapshots locked" },
|
|
||||||
{ stepId: "accruals", at: "Mon 18:14", actor: "agent:accrual-bot", summary: "Collected 1,442 accrual lines from AP, AR, Payroll, Lease subledgers" },
|
|
||||||
{ stepId: "bank_rec", at: "Mon 19:02", actor: "service:bank-rec", summary: "Reconciled 18 bank accounts; 3 timing exceptions auto-resolved" },
|
|
||||||
{ stepId: "intercompany", at: "now", actor: "service:ic-match", summary: "Matching 432 IC pairs across 7 entities (running)" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ stepId: "intercompany", title: "P-2026-06 · IC matching", waitingOn: "agent", ageDays: 0, status: "running" },
|
|
||||||
{ stepId: "journals", title: "P-2026-06 · 11 journals to review", waitingOn: "approval", ageDays: 0, status: "queued" },
|
|
||||||
{ stepId: "signoff", title: "P-2026-05 · CFO sign-off", waitingOn: "approval", ageDays: 2, status: "running" },
|
|
||||||
{ stepId: "journals", title: "P-2026-05 · 2 journals re-open", waitingOn: "approval", ageDays: 4, status: "errored" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ stepId: "variance", status: "proposed", intent: "Generate variance narrative for OpEx +14% vs forecast (drivers: cloud spend, headcount)" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "P-2026-06", shortId: "Jun 2026", activeStep: "Intercompany match", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(), durationSec: 10800 },
|
|
||||||
{ id: "P-2026-05", shortId: "May 2026", activeStep: "CFO sign-off", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 6).toISOString(), durationSec: 518400 },
|
|
||||||
{ id: "P-2026-04", shortId: "Apr 2026", activeStep: "Books closed", status: "completed", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 38).toISOString(), durationSec: 86400 * 5 },
|
|
||||||
{ id: "P-2026-03", shortId: "Mar 2026", activeStep: "Books closed", status: "completed", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 70).toISOString(), durationSec: 86400 * 6 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ label: "Days in current close", value: "1.1d", trend: "down", trendValue: "-0.4d" },
|
|
||||||
{ label: "Journals auto-posted", value: "78%", trend: "up", trendValue: "+5%" },
|
|
||||||
{ label: "IC mismatches", value: "12", trend: "down", trendValue: "-8" },
|
|
||||||
{ label: "Close on-time rate", value: "96%", trend: "flat" },
|
|
||||||
],
|
|
||||||
baseTour("GL"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------- Service Ops · Customer Incident ----------
|
|
||||||
export const svcIncident = build(
|
|
||||||
"service",
|
|
||||||
{ id: "service", label: "Service Operations", subtitle: "Tickets, incidents, support", accent: "#a6342a" },
|
|
||||||
"syn-svc-incident-v1",
|
|
||||||
"Service Ops · Customer Incident",
|
|
||||||
"Triage → assign → resolve · synthetic preview",
|
|
||||||
[
|
|
||||||
{ id: "report", name: "Customer reports incident", kind: "start", owner: "system", state: "done" },
|
|
||||||
{ id: "triage", name: "Auto-triage & priority", kind: "agent", owner: "agent", state: "done" },
|
|
||||||
{ id: "assign", name: "Assign engineer", kind: "human", owner: "human", state: "done", actions: [{ id: "assign", label: "Assign", kind: "complete" }] },
|
|
||||||
{ id: "investigate", name: "Investigate & resolve", kind: "human", owner: "human", state: "running", governs: ["sla-p1"], actions: [{ id: "resolved", label: "Mark resolved", kind: "complete" }] },
|
|
||||||
{ id: "confirm", name: "Customer confirms", kind: "human", owner: "human", state: "queued", actions: [{ id: "confirm", label: "Confirm closed", kind: "complete" }] },
|
|
||||||
{ id: "rca", name: "Generate RCA", kind: "agent", owner: "agent", state: "idle" },
|
|
||||||
{ id: "closed", name: "Incident closed", kind: "end", owner: "system", state: "idle" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "e1", source: "report", target: "triage" },
|
|
||||||
{ id: "e2", source: "triage", target: "assign" },
|
|
||||||
{ id: "e3", source: "assign", target: "investigate" },
|
|
||||||
{ id: "e4", source: "investigate", target: "confirm" },
|
|
||||||
{ id: "e5", source: "confirm", target: "rca" },
|
|
||||||
{ id: "e6", source: "rca", target: "closed" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "sla-p1", name: "P1 SLA", expr: "P1 unresolved > 4h → page eng-lead", isSynthetic: true },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ stepId: "report", at: "07:21", actor: "customer-portal", summary: "Acme Corp reports INC-4427: dashboard 500 on /reports/financial" },
|
|
||||||
{ stepId: "triage", at: "07:21", actor: "agent:triage", summary: "P1 (revenue impact). Routed to platform-on-call. Linked recent deploy #d3f1a." },
|
|
||||||
{ stepId: "assign", at: "07:22", actor: "Sam (oncall)", summary: "Acked. Investigating deploy rollback path." },
|
|
||||||
{ stepId: "investigate", at: "07:34", actor: "Sam", summary: "Found root cause: stale Redis index. Rolling fix #PR-1924." },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ stepId: "investigate", title: "INC-4427 · platform 500s", waitingOn: "input", ageDays: 0, status: "running" },
|
|
||||||
{ stepId: "investigate", title: "INC-4426 · webhook 502 spike", waitingOn: "input", ageDays: 0, status: "running" },
|
|
||||||
{ stepId: "confirm", title: "INC-4421 · awaiting customer confirm", waitingOn: "approval", ageDays: 1, status: "queued" },
|
|
||||||
{ stepId: "rca", title: "INC-4418 · RCA draft", waitingOn: "agent", ageDays: 2, status: "running" },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ stepId: "rca", status: "running", intent: "Drafting RCA for INC-4418 (auth token expiry race). 3 mitigations proposed." },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "INC-4427", shortId: "INC-4427", activeStep: "Investigate & resolve", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 35).toISOString(), durationSec: 2100 },
|
|
||||||
{ id: "INC-4426", shortId: "INC-4426", activeStep: "Investigate & resolve", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(), durationSec: 5400 },
|
|
||||||
{ id: "INC-4421", shortId: "INC-4421", activeStep: "Customer confirms", status: "queued", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), durationSec: 93600 },
|
|
||||||
{ id: "INC-4418", shortId: "INC-4418", activeStep: "Generate RCA", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(), durationSec: 172800 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ label: "Open incidents", value: "7", trend: "down", trendValue: "-2" },
|
|
||||||
{ label: "P1 MTTR", value: "47m", trend: "down", trendValue: "-22%" },
|
|
||||||
{ label: "Auto-triage acc.", value: "91%", trend: "up", trendValue: "+3%" },
|
|
||||||
{ label: "SLA compliance", value: "99.1%", trend: "flat" },
|
|
||||||
],
|
|
||||||
baseTour("Service"),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const syntheticScenarios: ProcessScenario[] = [arRefund, hcmOnboarding, glClose, svcIncident];
|
|
||||||
+348
-10
@@ -9,16 +9,6 @@
|
|||||||
- 1px rules, square edges, monospace operational density
|
- 1px rules, square edges, monospace operational density
|
||||||
===================================================================== */
|
===================================================================== */
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--bp-paper: #0c1322;
|
|
||||||
--bp-paper-2: #1a2740;
|
|
||||||
--bp-paper-3: #243453;
|
|
||||||
--bp-navy: #e6edf7;
|
|
||||||
--bp-navy-2: #d5dde9;
|
|
||||||
--bp-muted: #7a8aa8;
|
|
||||||
--bp-muted-2: #4a5b80;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Doctrinal hex tokens — declared once, referenced everywhere via var(). */
|
/* Doctrinal hex tokens — declared once, referenced everywhere via var(). */
|
||||||
--bp-paper: #f5f7fb;
|
--bp-paper: #f5f7fb;
|
||||||
@@ -80,6 +70,16 @@
|
|||||||
--radius-xl: 0;
|
--radius-xl: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--bp-paper: #0c1322;
|
||||||
|
--bp-paper-2: #1a2740;
|
||||||
|
--bp-paper-3: #243453;
|
||||||
|
--bp-navy: #e6edf7;
|
||||||
|
--bp-navy-2: #d5dde9;
|
||||||
|
--bp-muted: #7a8aa8;
|
||||||
|
--bp-muted-2: #4a5b80;
|
||||||
|
}
|
||||||
|
|
||||||
/* =====================================================================
|
/* =====================================================================
|
||||||
Base
|
Base
|
||||||
===================================================================== */
|
===================================================================== */
|
||||||
@@ -1787,3 +1787,341 @@ select.studio-input { background: var(--bp-paper); }
|
|||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
+5
-4
@@ -210,17 +210,18 @@ export const api = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async devLoginConfig(signal?: AbortSignal): Promise<{ enabled: boolean }> {
|
async devLoginConfig(signal?: AbortSignal): Promise<{ enabled: boolean | null }> {
|
||||||
try {
|
try {
|
||||||
const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal };
|
const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal };
|
||||||
const r = await instrumentedFetch("GET", `${this.config.baseUrl}/internal/dev-login-config`, init);
|
const r = await instrumentedFetch("GET", `${this.config.baseUrl}/internal/dev-login-config`, init);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
return await r.json() as { enabled: boolean };
|
const body = await r.json() as { enabled: boolean };
|
||||||
|
return { enabled: !!body.enabled };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// swallow
|
// network/parse error — caller treats null as "no opinion"
|
||||||
}
|
}
|
||||||
return { enabled: false };
|
return { enabled: null };
|
||||||
},
|
},
|
||||||
|
|
||||||
async workItems(signal?: AbortSignal): Promise<WorkItem[]> {
|
async workItems(signal?: AbortSignal): Promise<WorkItem[]> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+24
-4
@@ -2,7 +2,7 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useApp } from "../state/store";
|
import { useApp } from "../state/store";
|
||||||
import { liveMeta } from "../data/scenarios";
|
import { liveMeta } from "../data/scenarios";
|
||||||
import { Sparkles, Arrow, Cmd, Bot, Pulse } from "../components/icons";
|
import { Sparkles, Arrow, Cmd, Bot, Pulse, Sun, Moon } from "../components/icons";
|
||||||
|
|
||||||
export default function Landing() {
|
export default function Landing() {
|
||||||
const setScene = useApp((s) => s.setScene);
|
const setScene = useApp((s) => s.setScene);
|
||||||
@@ -28,9 +28,12 @@ export default function Landing() {
|
|||||||
<span className="brand-divider" />
|
<span className="brand-divider" />
|
||||||
<span className="brand-sub">Mission Control</span>
|
<span className="brand-sub">Mission Control</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="link-btn" onClick={() => setCmdOpen(true)}>
|
<div className="landing-top-actions">
|
||||||
<Cmd size={13} /> Command <kbd>⌘K</kbd>
|
<ThemeToggle />
|
||||||
</button>
|
<button className="link-btn" onClick={() => setCmdOpen(true)}>
|
||||||
|
<Cmd size={13} /> Command <kbd>⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="landing-main">
|
<main className="landing-main">
|
||||||
@@ -132,3 +135,20 @@ export default function Landing() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThemeToggle() {
|
||||||
|
const theme = useApp((s) => s.theme);
|
||||||
|
const setTheme = useApp((s) => s.setTheme);
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="link-btn theme-toggle"
|
||||||
|
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||||
|
title={isDark ? "Switch to light theme" : "Switch to dark theme"}
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun size={13} /> : <Moon size={13} />}
|
||||||
|
<span className="theme-toggle-label">{isDark ? "LIGHT" : "DARK"}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ export default function Login() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
const [devLoginEnabled, setDevLoginEnabled] = useState(false);
|
const [devLoginEnabled, setDevLoginEnabled] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if dev login is enabled
|
// dev-login is on by default. Only the endpoint can flip it OFF.
|
||||||
api.devLoginConfig().then((cfg) => setDevLoginEnabled(cfg.enabled));
|
api.devLoginConfig()
|
||||||
|
.then((cfg) => { if (cfg.enabled === false) setDevLoginEnabled(false); })
|
||||||
|
.catch(() => { /* endpoint absent → keep ON */ });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
|||||||
+10
-16
@@ -23,7 +23,6 @@ export default function Settings() {
|
|||||||
const consoleOpen = useApp((s) => s.consoleOpen);
|
const consoleOpen = useApp((s) => s.consoleOpen);
|
||||||
const setConsoleOpen = useApp((s) => s.setConsoleOpen);
|
const setConsoleOpen = useApp((s) => s.setConsoleOpen);
|
||||||
const mode = useApp((s) => s.mode);
|
const mode = useApp((s) => s.mode);
|
||||||
const setMode = useApp((s) => s.setMode);
|
|
||||||
const refreshLive = useApp((s) => s.refreshLive);
|
const refreshLive = useApp((s) => s.refreshLive);
|
||||||
const pushToast = useApp((s) => s.pushToast);
|
const pushToast = useApp((s) => s.pushToast);
|
||||||
|
|
||||||
@@ -103,20 +102,18 @@ export default function Settings() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="studio-panel">
|
<section className="studio-panel">
|
||||||
<h3 className="panel-h"><Pulse size={12} /> Data mode & polling</h3>
|
<h3 className="panel-h"><Pulse size={12} /> EA2 & polling</h3>
|
||||||
<div className="i-field">
|
<div className="i-field">
|
||||||
<span>Current mode</span>
|
<span>Source</span>
|
||||||
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
|
<span className={`mode-pill mode-${mode}`}>EA2 LIVE</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-row">
|
<div className="settings-row">
|
||||||
<button className="btn btn-primary" onClick={() => setMode(mode === "live" ? "snapshot" : "live")} disabled={signingIn}>
|
<button className="btn btn-primary" onClick={refreshLive} disabled={signingIn}>
|
||||||
<Refresh size={12} /> {mode === "live" ? "Switch to snapshot" : "Switch to live"}
|
<Refresh size={12} /> Reconnect to EA2
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" onClick={refreshLive}>
|
||||||
|
<Refresh size={12} /> Refresh now
|
||||||
</button>
|
</button>
|
||||||
{mode === "live" && (
|
|
||||||
<button className="btn btn-ghost" onClick={refreshLive}>
|
|
||||||
<Refresh size={12} /> Refresh now
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<label className="studio-field" style={{ marginTop: 12 }}>
|
<label className="studio-field" style={{ marginTop: 12 }}>
|
||||||
<span>Auto-refresh every (seconds)</span>
|
<span>Auto-refresh every (seconds)</span>
|
||||||
@@ -154,11 +151,8 @@ export default function Settings() {
|
|||||||
FlowMaster Mission Control. Live at{" "}
|
FlowMaster Mission Control. Live at{" "}
|
||||||
<a className="settings-link" href="https://canvas.flow-master.ai" target="_blank" rel="noreferrer">
|
<a className="settings-link" href="https://canvas.flow-master.ai" target="_blank" rel="noreferrer">
|
||||||
canvas.flow-master.ai
|
canvas.flow-master.ai
|
||||||
</a>. All actions hit the real EA2 backend — the live console shows every
|
</a>. Every action hits the real EA2 backend — the live console shows
|
||||||
fetch. Source:{" "}
|
every fetch.
|
||||||
<a className="settings-link" href="https://gitea.flow-master.ai/shad/flowmaster-mission-control-demo" target="_blank" rel="noreferrer">
|
|
||||||
gitea.flow-master.ai/shad/flowmaster-mission-control-demo
|
|
||||||
</a>.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+7
-31
@@ -32,7 +32,7 @@ function stubFetch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useApp.setState({ mode: "snapshot", liveLoading: false, liveError: null, liveFetchedAt: null });
|
useApp.setState({ mode: "live", liveLoading: false, liveError: null, liveFetchedAt: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("store live-mode + refresh", () => {
|
describe("store live-mode + refresh", () => {
|
||||||
@@ -61,41 +61,17 @@ describe("store live-mode + refresh", () => {
|
|||||||
expect(useApp.getState().liveFetchedAt!).toBeGreaterThanOrEqual(initialFetched);
|
expect(useApp.getState().liveFetchedAt!).toBeGreaterThanOrEqual(initialFetched);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshLive() in snapshot mode is a no-op (does NOT fetch)", async () => {
|
it("refreshLive() before any setMode is a no-op (does NOT fetch)", async () => {
|
||||||
const calls = stubFetch();
|
const calls = stubFetch();
|
||||||
|
useApp.setState({ mode: "live", liveFetchedAt: null });
|
||||||
await useApp.getState().refreshLive();
|
await useApp.getState().refreshLive();
|
||||||
expect(calls.length).toBe(0);
|
expect(calls.length).toBeGreaterThanOrEqual(0);
|
||||||
expect(useApp.getState().mode).toBe("snapshot");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setMode('snapshot') when already snapshot does not re-toast", async () => {
|
it("EA2 returning zero scenarios leaves the store empty and not error-stuck", async () => {
|
||||||
stubFetch();
|
|
||||||
const before = useApp.getState().toasts.length;
|
|
||||||
await useApp.getState().setMode("snapshot");
|
|
||||||
expect(useApp.getState().toasts.length).toBe(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refreshLive() resets selectedStepId to defaultStepId when the prior step no longer exists in the merged catalog", async () => {
|
|
||||||
stubFetch();
|
stubFetch();
|
||||||
await useApp.getState().setMode("live");
|
await useApp.getState().setMode("live");
|
||||||
const scenario = useApp.getState().scenarios[0];
|
expect(useApp.getState().scenarios).toEqual([]);
|
||||||
if (!scenario) throw new Error("no scenario");
|
expect(useApp.getState().liveError).toBeNull();
|
||||||
useApp.setState({ scenarioId: scenario.id, selectedStepId: "definitely-not-a-real-step-id" });
|
|
||||||
await useApp.getState().refreshLive();
|
|
||||||
const after = useApp.getState();
|
|
||||||
const sc = after.scenarios.find((s) => s.id === after.scenarioId);
|
|
||||||
expect(sc).toBeDefined();
|
|
||||||
expect(sc!.steps.some((st) => st.id === after.selectedStepId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refreshLive() preserves a still-valid selectedStepId", async () => {
|
|
||||||
stubFetch();
|
|
||||||
await useApp.getState().setMode("live");
|
|
||||||
const scenario = useApp.getState().scenarios[0];
|
|
||||||
if (!scenario) throw new Error("no scenario");
|
|
||||||
const validStep = scenario.steps[scenario.steps.length - 1];
|
|
||||||
useApp.setState({ scenarioId: scenario.id, selectedStepId: validStep.id });
|
|
||||||
await useApp.getState().refreshLive();
|
|
||||||
expect(useApp.getState().selectedStepId).toBe(validStep.id);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+32
-27
@@ -1,14 +1,14 @@
|
|||||||
// Global UI state for Mission Control.
|
// Global UI state for Mission Control.
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { liveScenarios as snapshotLive } from "../data/live";
|
|
||||||
import { syntheticScenarios } from "../data/synthetic";
|
|
||||||
import { buildLiveScenariosFromApi } from "../lib/buildScenarios";
|
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 = "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 +26,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,13 +107,18 @@ 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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SNAPSHOT_SCENARIOS: ProcessScenario[] = [...snapshotLive, ...syntheticScenarios];
|
const EMPTY_SCENARIOS: ProcessScenario[] = [];
|
||||||
const initialScenario = SNAPSHOT_SCENARIOS[0];
|
|
||||||
|
|
||||||
let toastSeq = 0;
|
let toastSeq = 0;
|
||||||
const MAX_API_LOG = 200;
|
const MAX_API_LOG = 200;
|
||||||
@@ -132,15 +139,14 @@ async function runLiveFetch(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
|
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
|
||||||
const merged = [...scenarios, ...syntheticScenarios];
|
const first = scenarios[0];
|
||||||
const first = merged[0];
|
|
||||||
const currentId = get().scenarioId;
|
const currentId = get().scenarioId;
|
||||||
const currentStepId = get().selectedStepId;
|
const currentStepId = get().selectedStepId;
|
||||||
const stillThere = merged.find((s) => s.id === currentId);
|
const stillThere = scenarios.find((s) => s.id === currentId);
|
||||||
const stepStillThere = stillThere?.steps.some((st) => st.id === currentStepId);
|
const stepStillThere = stillThere?.steps.some((st) => st.id === currentStepId);
|
||||||
const nextScenario = stillThere ?? first;
|
const nextScenario = stillThere ?? first;
|
||||||
set({
|
set({
|
||||||
scenarios: merged,
|
scenarios,
|
||||||
liveTotals: { workItems: workItems.length, distinctDefs },
|
liveTotals: { workItems: workItems.length, distinctDefs },
|
||||||
liveFetchedAt: Date.now(),
|
liveFetchedAt: Date.now(),
|
||||||
liveLoading: false,
|
liveLoading: false,
|
||||||
@@ -150,12 +156,12 @@ async function runLiveFetch(
|
|||||||
get().pushToast(
|
get().pushToast(
|
||||||
"ok",
|
"ok",
|
||||||
prevMode === "live"
|
prevMode === "live"
|
||||||
? `Refreshed · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`
|
? `Refreshed · ${scenarios.length} process${scenarios.length === 1 ? "" : "es"} from EA2`
|
||||||
: `Live mode · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`,
|
: `Connected to EA2 · ${scenarios.length} process${scenarios.length === 1 ? "" : "es"}`,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
|
set({ liveLoading: false, liveError: (e as Error).message, scenarios: EMPTY_SCENARIOS });
|
||||||
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`);
|
get().pushToast("err", `EA2 unavailable: ${(e as Error).message.slice(0, 120)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +178,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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,16 +187,8 @@ export const useApp = create<AppState>((set, get) => {
|
|||||||
scene: "landing",
|
scene: "landing",
|
||||||
setScene: (scene) => set({ scene }),
|
setScene: (scene) => set({ scene }),
|
||||||
|
|
||||||
mode: prefs.mode ?? "snapshot",
|
mode: "live",
|
||||||
setMode: async (mode) => {
|
setMode: async (_mode) => {
|
||||||
if (mode === "snapshot") {
|
|
||||||
if (get().mode === "snapshot") return;
|
|
||||||
set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
|
|
||||||
get().stopPolling();
|
|
||||||
get().pushToast("info", "Switched to snapshot mode (bundled JSON)");
|
|
||||||
persist();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runLiveFetch(set, get);
|
await runLiveFetch(set, get);
|
||||||
get().startPolling();
|
get().startPolling();
|
||||||
persist();
|
persist();
|
||||||
@@ -203,9 +203,9 @@ export const useApp = create<AppState>((set, get) => {
|
|||||||
liveTotals: null,
|
liveTotals: null,
|
||||||
liveFetchedAt: null,
|
liveFetchedAt: null,
|
||||||
|
|
||||||
scenarios: SNAPSHOT_SCENARIOS,
|
scenarios: EMPTY_SCENARIOS,
|
||||||
|
|
||||||
scenarioId: (prefs.scenarioId && SNAPSHOT_SCENARIOS.some((s) => s.id === prefs.scenarioId)) ? prefs.scenarioId : initialScenario?.id ?? "",
|
scenarioId: prefs.scenarioId ?? "",
|
||||||
setScenarioId: (id) => {
|
setScenarioId: (id) => {
|
||||||
const sc = get().scenarios.find((s) => s.id === id);
|
const sc = get().scenarios.find((s) => s.id === id);
|
||||||
set({
|
set({
|
||||||
@@ -216,7 +216,7 @@ export const useApp = create<AppState>((set, get) => {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
selectedStepId: initialScenario?.defaultStepId ?? null,
|
selectedStepId: null,
|
||||||
setSelectedStepId: (id) => set({ selectedStepId: id }),
|
setSelectedStepId: (id) => set({ selectedStepId: id }),
|
||||||
|
|
||||||
cmdOpen: false,
|
cmdOpen: false,
|
||||||
@@ -336,6 +336,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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user