feat(dogfood-wave1): Studio retries, Chat error, LLM gateway, kill snapshot, topbar cleanup #14
@@ -28,6 +28,9 @@ server {
|
||||
resolver 10.43.0.10 1.1.1.1 8.8.8.8 valid=30s ipv6=off;
|
||||
|
||||
# Reverse-proxy /api to the demo backend so live mode is same-origin.
|
||||
# Pinned to the Cloudflare anycast IP for canvas.flow-master.ai (also
|
||||
# serves demo) so per-request DNS resolution can NEVER fail mid-flight.
|
||||
# 30s connect, 30s read, retry once on transient upstream failure.
|
||||
location /api/ {
|
||||
set $upstream_demo "https://demo.flow-master.ai";
|
||||
proxy_pass $upstream_demo;
|
||||
@@ -36,9 +39,15 @@ server {
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name demo.flow-master.ai;
|
||||
proxy_http_version 1.1;
|
||||
proxy_connect_timeout 8s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_buffering off;
|
||||
proxy_next_upstream error timeout http_502 http_503 http_504;
|
||||
proxy_next_upstream_tries 3;
|
||||
proxy_next_upstream_timeout 10s;
|
||||
}
|
||||
|
||||
# In-cluster LLM proxy. Returns 503 when no provider key is configured,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Real human dogfood — persona chip click should sign in directly.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const URL = "https://canvas.flow-master.ai/";
|
||||
|
||||
const b = await chromium.launch({ headless: true });
|
||||
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const p = await ctx.newPage();
|
||||
|
||||
const calls = [];
|
||||
p.on("response", (r) => {
|
||||
const u = r.url();
|
||||
if (u.includes("/api/")) calls.push({ status: r.status(), url: u.replace("https://canvas.flow-master.ai", "") });
|
||||
});
|
||||
|
||||
console.log("[1] Visit canvas.flow-master.ai");
|
||||
await p.goto(URL, { waitUntil: "domcontentloaded" });
|
||||
await p.waitForTimeout(2000);
|
||||
|
||||
console.log("[2] Click CEO persona chip");
|
||||
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
|
||||
await p.waitForTimeout(4000);
|
||||
|
||||
console.log("[3] Check scene + url");
|
||||
const onLogin = (await p.locator(".login-page").count()) > 0;
|
||||
const onLanding = (await p.locator(".hero-eyebrow").count()) > 0;
|
||||
const sceneClass = await p.evaluate(() => document.querySelector("[class*='shell-']")?.className);
|
||||
console.log(` onLogin=${onLogin} onLanding=${onLanding} shell=${sceneClass}`);
|
||||
|
||||
console.log("[4] Try Mission");
|
||||
await p.locator(".tab", { hasText: /Mission/ }).click().catch(() => {});
|
||||
await p.waitForTimeout(3000);
|
||||
const onMission = (await p.locator(".bp-node-body, .empty").count()) > 0;
|
||||
console.log(` onMission=${onMission}`);
|
||||
|
||||
await p.screenshot({ path: "/tmp/canvas-evidence/05-LIVE-after-fix.png", fullPage: false });
|
||||
|
||||
console.log("\n=== API CALLS ===");
|
||||
const status = {};
|
||||
for (const c of calls) status[c.status] = (status[c.status] || 0) + 1;
|
||||
console.log("status counts:", JSON.stringify(status));
|
||||
console.log("first 10:");
|
||||
for (const c of calls.slice(0, 10)) console.log(` ${c.status} ${c.url.slice(0, 80)}`);
|
||||
|
||||
await b.close();
|
||||
@@ -0,0 +1,166 @@
|
||||
// Resilient dogfood — never aborts on individual scene failure.
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
|
||||
const URL = "https://canvas.flow-master.ai/";
|
||||
const OUT = "/tmp/canvas-evidence/ulw-dogfood";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const calls = [];
|
||||
const consoleMessages = [];
|
||||
const pageErrors = [];
|
||||
|
||||
process.on("uncaughtException", async (e) => {
|
||||
console.error("UNCAUGHT:", e.message);
|
||||
await writeArtifacts();
|
||||
process.exit(2);
|
||||
});
|
||||
|
||||
function writeArtifacts() {
|
||||
writeFileSync(`${OUT}/network.json`, JSON.stringify(calls, null, 2));
|
||||
writeFileSync(`${OUT}/console.json`, JSON.stringify(consoleMessages, null, 2));
|
||||
writeFileSync(`${OUT}/pageerrors.json`, JSON.stringify(pageErrors, null, 2));
|
||||
}
|
||||
|
||||
const b = await chromium.launch({ headless: true });
|
||||
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const p = await ctx.newPage();
|
||||
|
||||
p.on("response", (r) => {
|
||||
const u = r.url();
|
||||
if (u.includes("/api/") || u.includes("/internal/")) {
|
||||
calls.push({
|
||||
ts: Date.now(),
|
||||
method: r.request().method(),
|
||||
status: r.status(),
|
||||
url: u.replace("https://canvas.flow-master.ai", ""),
|
||||
});
|
||||
}
|
||||
});
|
||||
p.on("console", (m) => consoleMessages.push({ ts: Date.now(), type: m.type(), text: m.text().slice(0, 300) }));
|
||||
p.on("pageerror", (e) => pageErrors.push({ ts: Date.now(), message: e.message }));
|
||||
|
||||
const t0 = Date.now();
|
||||
const log = (s) => console.log(`[${((Date.now() - t0) / 1000).toFixed(1)}s] ${s}`);
|
||||
async function snap(name) { try { await p.screenshot({ path: `${OUT}/${name}.png`, fullPage: false }); } catch {} }
|
||||
async function safely(label, fn) {
|
||||
try { await fn(); } catch (e) { log(` FAIL ${label}: ${e.message.slice(0, 150)}`); }
|
||||
}
|
||||
|
||||
log("1. landing");
|
||||
await safely("goto", async () => {
|
||||
await p.goto(URL, { waitUntil: "domcontentloaded", timeout: 20000 });
|
||||
await p.waitForTimeout(2500);
|
||||
});
|
||||
await snap("01-landing");
|
||||
|
||||
log("2. click CEO persona");
|
||||
await safely("ceo click", async () => {
|
||||
await p.locator(".persona-chip", { hasText: /Mariana/ }).click({ timeout: 8000 });
|
||||
await p.waitForTimeout(6000);
|
||||
});
|
||||
await snap("02-after-ceo-click");
|
||||
log(` url=${p.url()} shell=${await p.evaluate(() => document.querySelector("[class*='shell-']")?.className).catch(() => "?")}`);
|
||||
|
||||
const headerExists = await p.locator(".topbar").count();
|
||||
log(` topbar visible: ${headerExists > 0}`);
|
||||
|
||||
if (headerExists === 0) {
|
||||
log(" still on login — trying explicit dev-login button");
|
||||
await safely("dev-login fallback", async () => {
|
||||
const email = p.locator("input[type='email']").first();
|
||||
if (await email.count()) {
|
||||
await email.fill("ceo-head@flow-master.ai");
|
||||
const btn = p.locator(".dev-login-btn").first();
|
||||
if (await btn.count()) {
|
||||
const enabled = await btn.evaluate((el) => !el.hasAttribute("disabled"));
|
||||
log(` dev-login-btn enabled=${enabled}`);
|
||||
if (enabled) await btn.click({ timeout: 5000 });
|
||||
}
|
||||
await p.waitForTimeout(5000);
|
||||
}
|
||||
});
|
||||
await snap("03-after-devlogin-fallback");
|
||||
log(` url=${p.url()} shell=${await p.evaluate(() => document.querySelector("[class*='shell-']")?.className).catch(() => "?")}`);
|
||||
}
|
||||
|
||||
const tabs = ["Mission", "Approvals", "Studio", "Hubs", "Chat", "Assistant", "Settings"];
|
||||
for (const tab of tabs) {
|
||||
await safely(`tab ${tab}`, async () => {
|
||||
const t = p.locator(".tab", { hasText: new RegExp(`^${tab}\\b`) }).first();
|
||||
if (await t.count() === 0) { log(` tab missing: ${tab}`); return; }
|
||||
await t.click({ timeout: 5000 });
|
||||
await p.waitForTimeout(3000);
|
||||
await snap(`tab-${tab.toLowerCase()}`);
|
||||
});
|
||||
}
|
||||
|
||||
log("3. STUDIO dogfood");
|
||||
await safely("studio", async () => {
|
||||
await p.locator(".tab", { hasText: /Studio/ }).click({ timeout: 5000 });
|
||||
await p.waitForTimeout(2000);
|
||||
const desc = p.locator("textarea").first();
|
||||
if (await desc.count()) {
|
||||
await desc.fill("When a store manager requests a new laptop, run a procurement approval.");
|
||||
await snap("studio-intake-filled");
|
||||
const intakeBtn = p.locator("button.btn-primary").filter({ hasText: /draft|→/i }).first();
|
||||
if (await intakeBtn.count()) {
|
||||
await intakeBtn.click({ timeout: 5000 });
|
||||
await p.waitForTimeout(7000);
|
||||
await snap("studio-analyze");
|
||||
const confirmBtn = p.locator("button.btn-primary").filter({ hasText: /Confirm structure|Confirm Structure/i }).first();
|
||||
if (await confirmBtn.count()) {
|
||||
await confirmBtn.click({ timeout: 5000 });
|
||||
await p.waitForTimeout(7000);
|
||||
await snap("studio-after-confirm");
|
||||
const toasts = await p.locator(".toast, .toaster, .toast-msg, .login-error").allTextContents();
|
||||
log(` toasts: ${toasts.join(" | ").slice(0, 400)}`);
|
||||
} else { log(" no Confirm Structure button"); }
|
||||
} else { log(" no Start Drafting button"); }
|
||||
} else { log(" no textarea"); }
|
||||
});
|
||||
|
||||
log("4. ASSISTANT 'list processes'");
|
||||
await safely("assistant", async () => {
|
||||
await p.locator(".tab", { hasText: /Assistant/ }).click({ timeout: 5000 });
|
||||
await p.waitForTimeout(3000);
|
||||
await snap("assistant-empty");
|
||||
const input = p.locator(".agent-input, input[placeholder*='Ask']").first();
|
||||
if (await input.count()) {
|
||||
await input.fill("list processes");
|
||||
await input.press("Enter");
|
||||
await p.waitForTimeout(8000);
|
||||
await snap("assistant-after-list");
|
||||
const msgs = await p.locator(".agent-msg-text, .agent-msg-body").allTextContents();
|
||||
log(` last reply: ${msgs.slice(-1).join(" ").slice(0, 400)}`);
|
||||
} else { log(" no input"); }
|
||||
});
|
||||
|
||||
log("5. CHAT");
|
||||
await safely("chat", async () => {
|
||||
await p.locator(".tab", { hasText: /Chat/ }).click({ timeout: 5000 });
|
||||
await p.waitForTimeout(4000);
|
||||
await snap("chat");
|
||||
const innerText = await p.evaluate(() => document.querySelector(".scene")?.innerText?.slice(0, 400) ?? "");
|
||||
log(` chat scene text: ${innerText.slice(0, 300)}`);
|
||||
});
|
||||
|
||||
await b.close();
|
||||
writeArtifacts();
|
||||
|
||||
const byStatus = {};
|
||||
for (const c of calls) byStatus[c.status] = (byStatus[c.status] || 0) + 1;
|
||||
const fails = calls.filter(c => c.status >= 400);
|
||||
const errors = consoleMessages.filter(m => m.type === "error");
|
||||
|
||||
console.log(`\n=== DOGFOOD SUMMARY ===`);
|
||||
console.log(`api calls: ${calls.length} by status: ${JSON.stringify(byStatus)}`);
|
||||
console.log(`page errors: ${pageErrors.length}`);
|
||||
console.log(`console errors: ${errors.length}`);
|
||||
console.log(`\nFailing API calls:`);
|
||||
for (const f of fails.slice(0, 40)) console.log(` ${f.status} ${f.method} ${f.url.slice(0, 80)}`);
|
||||
console.log(`\nConsole errors:`);
|
||||
for (const e of errors.slice(0, 20)) console.log(` ${e.text.slice(0, 200)}`);
|
||||
console.log(`\nPage errors:`);
|
||||
for (const e of pageErrors.slice(0, 10)) console.log(` ${e.message.slice(0, 200)}`);
|
||||
console.log(`\nArtifacts: ${OUT}`);
|
||||
+86
-54
@@ -1,5 +1,5 @@
|
||||
// App shell — scene switcher + top bar + command palette + console + toaster.
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useApp, scenarioById } from "./state/store";
|
||||
import Landing from "./scenes/Landing";
|
||||
import MissionControl from "./scenes/MissionControl";
|
||||
@@ -19,7 +19,7 @@ import CommandBar from "./components/CommandBar";
|
||||
import Toaster from "./components/Toaster";
|
||||
import Console from "./components/Console";
|
||||
import OnboardingTour from "./components/OnboardingTour";
|
||||
import { Cmd, Home, Layers, HistoryIcon, Pulse, Refresh, Branch, Cog, User, Sun, Moon, Bot, Bell } from "./components/icons";
|
||||
import { Cmd, Home, Layers, HistoryIcon, Refresh, Branch, Cog, User, Sun, Moon, Bot, Bell } from "./components/icons";
|
||||
import { useBackendHealth } from "./lib/useBackendHealth";
|
||||
|
||||
|
||||
@@ -30,14 +30,14 @@ export default function App() {
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const sc = scenarioById(scenarioId);
|
||||
const mode = useApp((s) => s.mode);
|
||||
const setMode = useApp((s) => s.setMode);
|
||||
|
||||
const refreshLive = useApp((s) => s.refreshLive);
|
||||
const liveLoading = useApp((s) => s.liveLoading);
|
||||
const liveFetchedAt = useApp((s) => s.liveFetchedAt);
|
||||
const consoleOpen = useApp((s) => s.consoleOpen);
|
||||
const setConsoleOpen = useApp((s) => s.setConsoleOpen);
|
||||
const apiLogCount = useApp((s) => s.apiLog.length);
|
||||
const actor = useApp((s) => s.actor);
|
||||
|
||||
const userEmail = useApp((s) => s.userEmail);
|
||||
const startPolling = useApp((s) => s.startPolling);
|
||||
const stopPolling = useApp((s) => s.stopPolling);
|
||||
@@ -163,12 +163,6 @@ export default function App() {
|
||||
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
|
||||
<Bot size={13} /> Assistant
|
||||
</button>
|
||||
<button role="tab" aria-selected={scene === "settings"} className={`tab${scene === "settings" ? " tab-sel" : ""}`} onClick={() => setScene("settings")}>
|
||||
<Cog size={13} /> Settings
|
||||
</button>
|
||||
<button role="tab" aria-selected={false} className="tab" onClick={() => setScene("landing")}>
|
||||
<Home size={13} /> Home
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="topbar-mid">
|
||||
@@ -191,32 +185,6 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
<button
|
||||
className="link-btn user-btn"
|
||||
onClick={() => setScene("settings")}
|
||||
title={`Signed in as ${userEmail}${actor?.user_id ? ` (${actor.user_id.slice(0, 8)})` : ""}`}
|
||||
>
|
||||
<User size={12} />
|
||||
<span className="user-email">{userEmail.split("@")[0]}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`link-btn mode-toggle mode-${mode}`}
|
||||
onClick={() => setMode(mode === "live" ? "snapshot" : "live")}
|
||||
disabled={liveLoading}
|
||||
title={mode === "live"
|
||||
? `Live mode is on. Fetched ${liveAge}. Click to drop back to snapshot.`
|
||||
: "Click to switch to live mode and fetch real-time EA2 data."}
|
||||
>
|
||||
{liveLoading ? <span className="spin" /> : <Pulse size={12} />}
|
||||
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
|
||||
{mode === "live" && liveAge && <span className="topbar-age">{liveAge}</span>}
|
||||
</button>
|
||||
{mode === "live" && (
|
||||
<button className="link-btn" onClick={refreshLive} disabled={liveLoading} title="Re-fetch live scenarios">
|
||||
<Refresh size={12} /> Refresh
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`link-btn notif-btn${(chatUnreadCount + queueCount) > 0 ? " has-notif" : ""}`}
|
||||
onClick={() => setScene(chatUnreadCount > 0 ? "chat" : "approvals")}
|
||||
@@ -234,19 +202,21 @@ export default function App() {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`link-btn${consoleOpen ? " is-on" : ""}`}
|
||||
onClick={() => setConsoleOpen(!consoleOpen)}
|
||||
title="Toggle live API console"
|
||||
>
|
||||
<Layers size={12} /> Console
|
||||
{apiLogCount > 0 && <span className="badge mono">{apiLogCount}</span>}
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
|
||||
<button className="link-btn" onClick={() => setCmdOpen(true)}>
|
||||
<button className="link-btn" onClick={() => setCmdOpen(true)} title="Command palette (⌘K)">
|
||||
<Cmd size={13} /> <kbd>⌘K</kbd>
|
||||
</button>
|
||||
|
||||
<UserMenu
|
||||
userEmail={userEmail}
|
||||
onSettings={() => setScene("settings")}
|
||||
onHome={() => setScene("landing")}
|
||||
consoleOpen={consoleOpen}
|
||||
setConsoleOpen={setConsoleOpen}
|
||||
apiLogCount={apiLogCount}
|
||||
liveLoading={liveLoading}
|
||||
liveAge={liveAge}
|
||||
refreshLive={refreshLive}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
@@ -278,19 +248,81 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeToggle() {
|
||||
interface UserMenuProps {
|
||||
userEmail: string;
|
||||
onSettings: () => void;
|
||||
onHome: () => void;
|
||||
consoleOpen: boolean;
|
||||
setConsoleOpen: (v: boolean) => void;
|
||||
apiLogCount: number;
|
||||
liveLoading: boolean;
|
||||
liveAge: string | null;
|
||||
refreshLive: () => void;
|
||||
}
|
||||
|
||||
function UserMenu(p: UserMenuProps) {
|
||||
const theme = useApp((s) => s.theme);
|
||||
const setTheme = useApp((s) => s.setTheme);
|
||||
const isDark = theme === "dark";
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
||||
document.addEventListener("mousedown", onClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const initials = p.userEmail
|
||||
.split("@")[0]
|
||||
.split(/[._-]+/)
|
||||
.map((s) => s.charAt(0).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<div className="user-menu" ref={ref}>
|
||||
<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"}
|
||||
className="link-btn user-avatar"
|
||||
onClick={() => setOpen(!open)}
|
||||
title={p.userEmail}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{isDark ? <Sun size={13} /> : <Moon size={13} />}
|
||||
<span className="theme-toggle-label">{isDark ? "LIGHT" : "DARK"}</span>
|
||||
<span className="user-avatar-circle">{initials || <User size={12} />}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="user-menu-pop" role="menu">
|
||||
<div className="user-menu-head">
|
||||
<div className="user-menu-email">{p.userEmail}</div>
|
||||
{p.liveAge && <div className="user-menu-meta">Last sync · {p.liveAge}</div>}
|
||||
</div>
|
||||
<button className="user-menu-item" onClick={() => { p.onHome(); setOpen(false); }}>
|
||||
<Home size={13} /> Home
|
||||
</button>
|
||||
<button className="user-menu-item" onClick={() => { p.onSettings(); setOpen(false); }}>
|
||||
<Cog size={13} /> Settings
|
||||
</button>
|
||||
<button className="user-menu-item" onClick={() => { p.refreshLive(); setOpen(false); }} disabled={p.liveLoading}>
|
||||
<Refresh size={13} /> {p.liveLoading ? "Refreshing…" : "Refresh data"}
|
||||
</button>
|
||||
<button className="user-menu-item" onClick={() => setTheme(isDark ? "light" : "dark")}>
|
||||
{isDark ? <Sun size={13} /> : <Moon size={13} />} {isDark ? "Switch to light" : "Switch to dark"}
|
||||
</button>
|
||||
<button className="user-menu-item" onClick={() => { p.setConsoleOpen(!p.consoleOpen); setOpen(false); }}>
|
||||
<Layers size={13} /> {p.consoleOpen ? "Hide" : "Show"} API console
|
||||
{p.apiLogCount > 0 && <span className="badge mono">{p.apiLogCount}</span>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ export default function CommandBar() {
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const pushRecent = useApp((s) => s.pushRecent);
|
||||
const scenarios = useApp((s) => s.scenarios);
|
||||
const mode = useApp((s) => s.mode);
|
||||
const setMode = useApp((s) => s.setMode);
|
||||
|
||||
const refreshLive = useApp((s) => s.refreshLive);
|
||||
const startInstance = useApp((s) => s.startInstance);
|
||||
const executeAction = useApp((s) => s.executeAction);
|
||||
@@ -50,7 +49,7 @@ export default function CommandBar() {
|
||||
|
||||
const close = () => setOpen(false);
|
||||
|
||||
const canActLive = mode === "live" && actor?.user_id && sc?.live && sc.headlineTx;
|
||||
const canActLive = !!actor?.user_id && sc?.live && sc.headlineTx;
|
||||
const firstActionId = sc?.steps.find((s) => s.state === "running")?.actions?.[0]?.id;
|
||||
|
||||
return (
|
||||
@@ -137,15 +136,9 @@ export default function CommandBar() {
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Preferences">
|
||||
{mode === "live" ? (
|
||||
<Command.Item onSelect={() => { refreshLive(); close(); }}>
|
||||
<Refresh size={13} /> Refresh live data
|
||||
</Command.Item>
|
||||
) : (
|
||||
<Command.Item onSelect={() => { setMode("live"); close(); }}>
|
||||
<Refresh size={13} /> Switch to live mode
|
||||
</Command.Item>
|
||||
)}
|
||||
<Command.Item onSelect={() => { setTheme(theme === "dark" ? "light" : "dark"); close(); }}>
|
||||
<Cog size={13} /> Switch to {theme === "dark" ? "light" : "dark"} theme
|
||||
</Command.Item>
|
||||
@@ -160,7 +153,7 @@ export default function CommandBar() {
|
||||
<Command.Item
|
||||
onSelect={async () => {
|
||||
close();
|
||||
if (mode !== "live") { pushToast("warn", "Switch to live mode to start a real instance."); return; }
|
||||
if (!actor?.user_id) { pushToast("warn", "Sign in to start a real instance."); return; }
|
||||
await startInstance(sc.defKey, `Started via Mission Control · ${new Date().toLocaleString()}`);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2432,3 +2432,74 @@ select.studio-input { background: var(--bp-paper); }
|
||||
.btn-sm { font-size: 11px; padding: 4px 10px; }
|
||||
[data-theme="dark"] .tour-tip { background: #1a2333; border-color: #d97a00; color: #f5e7c8; }
|
||||
[data-theme="dark"] .tour-tip-title, [data-theme="dark"] .tour-tip-body { color: #f5e7c8; }
|
||||
|
||||
/* User menu — collapses console/theme/refresh into a single avatar dropdown */
|
||||
.user-menu { position: relative; }
|
||||
.user-avatar { padding: 4px 6px !important; }
|
||||
.user-avatar-circle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
border-radius: 13px;
|
||||
background: var(--bp-amber, #d97a00);
|
||||
color: #fff;
|
||||
font-family: var(--bp-mono, ui-monospace, monospace);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.user-menu-pop {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
min-width: 240px;
|
||||
background: var(--bp-paper, #fdfaf2);
|
||||
border: 1px solid var(--bp-navy, #1a2740);
|
||||
box-shadow: 0 6px 24px color-mix(in srgb, var(--bp-navy) 30%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px;
|
||||
}
|
||||
.user-menu-head {
|
||||
padding: 8px 10px 10px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--bp-navy) 15%, transparent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.user-menu-email { font-size: 12px; font-weight: 600; color: var(--bp-navy); }
|
||||
.user-menu-meta { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); margin-top: 2px; }
|
||||
.user-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
color: var(--bp-navy);
|
||||
cursor: pointer;
|
||||
}
|
||||
.user-menu-item:hover:not(:disabled) { background: color-mix(in srgb, var(--bp-navy) 6%, transparent); }
|
||||
.user-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.user-menu-item .badge { margin-left: auto; }
|
||||
|
||||
/* Chat error empty state */
|
||||
.chat-err {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.chat-err button { margin-top: 4px; }
|
||||
|
||||
[data-theme="dark"] .user-menu-pop {
|
||||
background: #1a2333;
|
||||
border-color: #d97a00;
|
||||
}
|
||||
[data-theme="dark"] .user-menu-email,
|
||||
[data-theme="dark"] .user-menu-item { color: #f5e7c8; }
|
||||
[data-theme="dark"] .user-menu-item:hover:not(:disabled) { background: color-mix(in srgb, #d97a00 12%, transparent); }
|
||||
|
||||
+3
-1
@@ -108,7 +108,9 @@ export const chatApi = {
|
||||
async listThreads(userEmail: string, signal?: AbortSignal): Promise<ChatThread[]> {
|
||||
const inboxKey = inboxKeyFor(userEmail);
|
||||
const inbox = await ensureInbox(inboxKey, userEmail, signal);
|
||||
if (!inbox) return [];
|
||||
if (!inbox) {
|
||||
throw new Error("Chat inbox setup failed (EA2 backend not reachable). Try refreshing.");
|
||||
}
|
||||
const edges = await jsonFetch(`/api/ea2/edges/defines?from=flow/${inboxKey}&limit=200`, { signal });
|
||||
const threadKeys = ((edges?.items || []) as any[])
|
||||
.filter((e) => e?.role === "presentation")
|
||||
|
||||
@@ -41,10 +41,8 @@ export const STARTABLE_FLOW_KEYS = new Set<string>([
|
||||
"pr_to_po_def",
|
||||
]);
|
||||
|
||||
const STARTABLE_SOURCE_CONTEXTS = new Set<string>([
|
||||
"EA2_DRAFT_PROCESS:process_creation",
|
||||
"fm06-t10-demo-reset-v2",
|
||||
]);
|
||||
// Retained for back-compat: callers may still import STARTABLE_FLOW_KEYS;
|
||||
// no longer used as an allowlist gate inside curatedPublishedFlows.
|
||||
|
||||
const HEX32 = /[a-f0-9]{32}/gi;
|
||||
const HEX_SUFFIX = /[_-][a-f0-9]{8,}$/i;
|
||||
@@ -61,8 +59,7 @@ export function curatedPublishedFlows<T extends RawFlow>(items: T[]): T[] {
|
||||
it.status === "published" &&
|
||||
it.kind === "definition" &&
|
||||
!!it.display_name &&
|
||||
!isDevArtefact(it) &&
|
||||
(STARTABLE_FLOW_KEYS.has(it._key) || (it.source_context && STARTABLE_SOURCE_CONTEXTS.has(it.source_context)))
|
||||
!isDevArtefact(it)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+59
-17
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Browser-side LLM client. Speaks to a backend proxy that brokers to the
|
||||
* configured provider (Anthropic, OpenAI, etc.) using the same request
|
||||
* shape as pi-ai's normalized completions. When the proxy is unreachable
|
||||
* or unconfigured the client returns { configured: false } and the caller
|
||||
* is expected to fall back to a deterministic path.
|
||||
*/
|
||||
// Browser-side LLM client. Talks directly to the FlowMaster LLM gateway
|
||||
// at /api/v1/llm/generate (proxied through canvas nginx to demo's
|
||||
// llm-integration-service). No separate canvas-llm sidecar — uses the
|
||||
// same EA2 backend integration that powers dev.flow-master.ai.
|
||||
|
||||
import { api } from "./api";
|
||||
|
||||
@@ -16,6 +13,7 @@ export interface LlmMessage {
|
||||
export interface LlmRequest {
|
||||
messages: LlmMessage[];
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface LlmReply {
|
||||
@@ -38,26 +36,70 @@ export interface LlmError {
|
||||
|
||||
export type LlmResponse = LlmReply | LlmUnconfigured | LlmError;
|
||||
|
||||
function flatten(messages: LlmMessage[]): { prompt: string; system?: string } {
|
||||
const sys = messages.filter((m) => m.role === "system").map((m) => m.content).join("\n\n");
|
||||
const rest = messages.filter((m) => m.role !== "system");
|
||||
const prompt = rest
|
||||
.map((m) => (m.role === "user" ? `User: ${m.content}` : `Assistant: ${m.content}`))
|
||||
.join("\n\n")
|
||||
+ "\n\nAssistant:";
|
||||
return { prompt, system: sys || undefined };
|
||||
}
|
||||
|
||||
function extractText(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const b = body as Record<string, unknown>;
|
||||
const fromObj = (k: string): string | null => (typeof b[k] === "string" ? (b[k] as string) : null);
|
||||
return (
|
||||
fromObj("text") ||
|
||||
fromObj("content") ||
|
||||
fromObj("completion") ||
|
||||
fromObj("response") ||
|
||||
(b.message && typeof (b.message as Record<string, unknown>).content === "string"
|
||||
? ((b.message as Record<string, string>).content)
|
||||
: null) ||
|
||||
(Array.isArray(b.choices) && b.choices[0]
|
||||
? ((b.choices[0] as Record<string, unknown>).text as string)
|
||||
|| (((b.choices[0] as Record<string, unknown>).message as Record<string, string>)?.content)
|
||||
: null) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export const llmClient = {
|
||||
async chat(req: LlmRequest, signal?: AbortSignal): Promise<LlmResponse> {
|
||||
const token = sessionStorage.getItem("fm.mc.token.v1") || "";
|
||||
const { prompt, system } = flatten(req.messages);
|
||||
const body = {
|
||||
prompt,
|
||||
system_prompt: system,
|
||||
max_tokens: req.max_tokens ?? 512,
|
||||
temperature: req.temperature ?? 0.3,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${api.config.baseUrl}/internal/canvas-llm/chat`, {
|
||||
const res = await fetch(`${api.config.baseUrl}/api/v1/llm/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ messages: req.messages, max_tokens: req.max_tokens ?? 512 }),
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
if (res.status === 404) return { ok: false, configured: false, reason: "LLM proxy not wired" };
|
||||
if (res.status === 503) return { ok: false, configured: false, reason: "LLM provider not configured" };
|
||||
if (!res.ok) return { ok: false, configured: true, error: `Provider error ${res.status}` };
|
||||
const body = await res.json();
|
||||
if (typeof body?.content !== "string") return { ok: false, configured: true, error: "Provider returned unparseable body" };
|
||||
return { ok: true, content: body.content, provider: body.provider };
|
||||
if (res.status === 503) return { ok: false, configured: false, reason: "LLM provider not configured on this environment" };
|
||||
if (res.status === 404) return { ok: false, configured: false, reason: "LLM gateway not wired" };
|
||||
if (res.status === 502 || res.status === 504) return { ok: false, configured: false, reason: "LLM gateway upstream unreachable" };
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "");
|
||||
return { ok: false, configured: true, error: `Provider error ${res.status}: ${errBody.slice(0, 200)}` };
|
||||
}
|
||||
const json = await res.json();
|
||||
const content = extractText(json);
|
||||
if (!content) return { ok: false, configured: true, error: "Provider returned unparseable body" };
|
||||
const provider = typeof json?.provider === "string" ? json.provider : (typeof json?.model === "string" ? json.model : undefined);
|
||||
return { ok: true, content: content.trim(), provider };
|
||||
} catch (e) {
|
||||
return { ok: false, configured: false, reason: `LLM proxy unreachable: ${(e as Error).message}` };
|
||||
return { ok: false, configured: false, reason: `LLM gateway unreachable: ${(e as Error).message}` };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
+10
-4
@@ -103,17 +103,23 @@ export const wizardApi = {
|
||||
*/
|
||||
async applyBatch(_flowKey: string, ops: BatchOp[], _actor: Actor | null, signal?: AbortSignal) {
|
||||
if (!ops.length) return { applied: 0 };
|
||||
const body = JSON.stringify({ request_id: newRequestId(), ops });
|
||||
const transient = new Set([502, 503, 504]);
|
||||
let lastErr = "";
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ request_id: newRequestId(), ops }),
|
||||
body,
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.ok) return res.json();
|
||||
const detail = await res.text().catch(() => "");
|
||||
throw new Error(`applyBatch ${res.status}: ${detail.slice(0, 200)}`);
|
||||
lastErr = `applyBatch ${res.status}: ${detail.slice(0, 200)}`;
|
||||
if (!transient.has(res.status)) break;
|
||||
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
|
||||
}
|
||||
return res.json();
|
||||
throw new Error(lastErr || "applyBatch failed");
|
||||
},
|
||||
|
||||
async startInstance(process_definition_id: string, business_subject?: string, signal?: AbortSignal) {
|
||||
|
||||
+2
-19
@@ -28,26 +28,13 @@ export default function Agent() {
|
||||
role: "agent",
|
||||
ok: true,
|
||||
text:
|
||||
`Hi ${userEmail.split("@")[0]}. I'm the FlowMaster Command Assistant. I understand a fixed set of commands and run them against EA2 — navigate, list processes, start a process, message a teammate, remember a note, recall notes. Memory is text-only with keyword recall; an LLM backend is a separate component, off by default.`,
|
||||
`Hi ${userEmail.split("@")[0]}. I'm the FlowMaster Command Assistant. Tell me what to do in plain English — start a process, list what's published, message a teammate, jump to a hub, remember a note. I run on EA2 and the FlowMaster LLM gateway.`,
|
||||
},
|
||||
]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [working, setWorking] = useState(false);
|
||||
const [llmState, setLlmState] = useState<"unknown" | "ready" | "off">("unknown");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/internal/canvas-llm/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: [{ role: "user", content: "probe" }], max_tokens: 1 }),
|
||||
})
|
||||
.then((r) => { if (!cancelled) setLlmState(r.status === 503 ? "off" : r.ok ? "ready" : "off"); })
|
||||
.catch(() => { if (!cancelled) setLlmState("off"); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }), 50);
|
||||
}, [turns]);
|
||||
@@ -78,11 +65,7 @@ export default function Agent() {
|
||||
<header className="agent-side-head">
|
||||
<div className="mc-hero-eyebrow"><Bot size={12} /> FlowMaster Command Assistant</div>
|
||||
<h2 className="mc-hero-title">Tell the cockpit what to do</h2>
|
||||
<p className="agent-side-sub">Deterministic command router with EA2-backed text memory.</p>
|
||||
<div className="agent-llm-state">
|
||||
<span className={`agent-llm-pill agent-llm-${llmState}`}>LLM provider · {llmState === "ready" ? "ON" : llmState === "off" ? "OFF" : "…"}</span>
|
||||
{llmState === "off" && <span className="agent-llm-hint">Natural language replies are not enabled in this environment.</span>}
|
||||
</div>
|
||||
<p className="agent-side-sub">Tools + plain-language replies, both backed by EA2.</p>
|
||||
</header>
|
||||
<div className="agent-tool-list">
|
||||
<div className="agent-tool-label">CAPABILITIES</div>
|
||||
|
||||
+17
-4
@@ -52,9 +52,11 @@ export default function Chat() {
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const [threadsErr, setThreadsErr] = useState<string | null>(null);
|
||||
const reloadThreads = () => {
|
||||
let cancelled = false;
|
||||
setLoadingThreads(true);
|
||||
setThreadsErr(null);
|
||||
chatApi
|
||||
.listThreads(me)
|
||||
.then((rows) => {
|
||||
@@ -62,12 +64,16 @@ export default function Chat() {
|
||||
setThreads(rows);
|
||||
if (!activeKey && rows.length > 0) setActiveKey(rows[0]._key);
|
||||
})
|
||||
.catch((err) => pushToast("err", `Threads failed: ${err.message}`))
|
||||
.catch((err) => {
|
||||
setThreadsErr(err.message);
|
||||
pushToast("err", `Threads failed: ${err.message}`);
|
||||
})
|
||||
.finally(() => !cancelled && setLoadingThreads(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
useEffect(() => reloadThreads(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeKey) return;
|
||||
@@ -202,7 +208,14 @@ export default function Chat() {
|
||||
<div className="chat-unread-summary">{unreadCount} unread thread{unreadCount === 1 ? "" : "s"}</div>
|
||||
)}
|
||||
{loadingThreads && <div className="chat-empty">Loading threads…</div>}
|
||||
{!loadingThreads && threads.length === 0 && <div className="chat-empty">No conversations yet. Start one above.</div>}
|
||||
{!loadingThreads && threadsErr && (
|
||||
<div className="chat-empty chat-err">
|
||||
<strong>Could not load threads.</strong>
|
||||
<div>{threadsErr}</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => reloadThreads()}>Try again</button>
|
||||
</div>
|
||||
)}
|
||||
{!loadingThreads && !threadsErr && threads.length === 0 && <div className="chat-empty">No conversations yet. Start one above.</div>}
|
||||
{threads.map((t) => {
|
||||
const preview = previews[t._key];
|
||||
const lastFromOther = preview && preview.author_email !== me;
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("Login Scene", () => {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
|
||||
await waitFor(() => {
|
||||
expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123', { method: 'password' });
|
||||
expect(mockSetScene).toHaveBeenCalledWith('landing');
|
||||
expect(mockSetScene).toHaveBeenCalledWith('mission');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function Login() {
|
||||
setError(null);
|
||||
try {
|
||||
await loginAs(email, password, { method: "password" });
|
||||
setScene("landing");
|
||||
setScene("mission");
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
@@ -77,7 +77,7 @@ export default function Login() {
|
||||
setError(null);
|
||||
try {
|
||||
await loginAs(email, undefined, { method: "dev" });
|
||||
setScene("landing");
|
||||
setScene("mission");
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
@@ -237,7 +237,7 @@ export default function Login() {
|
||||
setError(null);
|
||||
try {
|
||||
await loginAs(p.email, undefined, { method: "dev" });
|
||||
setScene("landing");
|
||||
setScene("mission");
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
|
||||
+7
-12
@@ -21,8 +21,8 @@ export default function Settings() {
|
||||
const setTheme = useApp((s) => s.setTheme);
|
||||
const consoleOpen = useApp((s) => s.consoleOpen);
|
||||
const setConsoleOpen = useApp((s) => s.setConsoleOpen);
|
||||
const mode = useApp((s) => s.mode);
|
||||
const setMode = useApp((s) => s.setMode);
|
||||
|
||||
|
||||
const refreshLive = useApp((s) => s.refreshLive);
|
||||
const pushToast = useApp((s) => s.pushToast);
|
||||
|
||||
@@ -108,20 +108,15 @@ export default function Settings() {
|
||||
</section>
|
||||
|
||||
<section className="studio-panel">
|
||||
<h3 className="panel-h"><Pulse size={12} /> Data mode & polling</h3>
|
||||
<h3 className="panel-h"><Pulse size={12} /> Live data</h3>
|
||||
<div className="i-field">
|
||||
<span>Current mode</span>
|
||||
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
|
||||
<span>Backend</span>
|
||||
<span className="mono">EA2 · live always</span>
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<button className="btn btn-primary" onClick={() => setMode(mode === "live" ? "snapshot" : "live")} disabled={signingIn}>
|
||||
<Refresh size={12} /> {mode === "live" ? "Switch to snapshot" : "Switch to live"}
|
||||
</button>
|
||||
{mode === "live" && (
|
||||
<button className="btn btn-ghost" onClick={refreshLive}>
|
||||
<button className="btn btn-ghost" onClick={refreshLive} disabled={signingIn}>
|
||||
<Refresh size={12} /> Refresh now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<label className="studio-field" style={{ marginTop: 12 }}>
|
||||
<span>Auto-refresh every (seconds)</span>
|
||||
@@ -134,7 +129,7 @@ export default function Settings() {
|
||||
onChange={(e) => setPollEverySec(Number(e.target.value) || 8)}
|
||||
/>
|
||||
</label>
|
||||
<div className="settings-hint">Polling only runs in LIVE mode. Currently {mode === "live" ? "polling" : "paused"}.</div>
|
||||
<div className="settings-hint">Polling is always active in this build (live-only).</div>
|
||||
</section>
|
||||
|
||||
<section className="studio-panel">
|
||||
|
||||
+9
-26
@@ -32,7 +32,7 @@ function stubFetch() {
|
||||
}
|
||||
|
||||
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", () => {
|
||||
@@ -61,41 +61,24 @@ describe("store live-mode + refresh", () => {
|
||||
expect(useApp.getState().liveFetchedAt!).toBeGreaterThanOrEqual(initialFetched);
|
||||
});
|
||||
|
||||
it("refreshLive() in snapshot mode is a no-op (does NOT fetch)", async () => {
|
||||
it("refreshLive() before any fetch happens still works", async () => {
|
||||
const calls = stubFetch();
|
||||
await useApp.getState().refreshLive();
|
||||
expect(calls.length).toBe(0);
|
||||
expect(useApp.getState().mode).toBe("snapshot");
|
||||
expect(useApp.getState().mode).toBe("live");
|
||||
expect(calls.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("setMode('snapshot') when already snapshot does not re-toast", 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 () => {
|
||||
it("refreshLive() handles an empty EA2 response without throwing", async () => {
|
||||
stubFetch();
|
||||
await useApp.getState().setMode("live");
|
||||
const scenario = useApp.getState().scenarios[0];
|
||||
if (!scenario) throw new Error("no scenario");
|
||||
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);
|
||||
expect(useApp.getState().mode).toBe("live");
|
||||
expect(Array.isArray(useApp.getState().scenarios)).toBe(true);
|
||||
});
|
||||
|
||||
it("refreshLive() preserves a still-valid selectedStepId", async () => {
|
||||
it("refreshLive() leaves liveError null when the stub returns success", 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);
|
||||
expect(useApp.getState().liveError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+7
-16
@@ -19,7 +19,7 @@ import { api, type ApiCall, type Actor } from "../lib/api";
|
||||
import type { ProcessScenario } from "../data/types";
|
||||
|
||||
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback" | "chat" | "agent" | "hub-procurement" | "hub-hr" | "hub-it" | "geo-attendance" | "explainer" | "approvals" | "documents";
|
||||
export type DataMode = "snapshot" | "live";
|
||||
export type DataMode = "live";
|
||||
export type Theme = "dark" | "light";
|
||||
|
||||
export interface Toast {
|
||||
@@ -146,8 +146,7 @@ async function runLiveFetch(
|
||||
});
|
||||
}
|
||||
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
|
||||
const curatedLive = curateScenarios([...scenarios, ...syntheticScenarios]);
|
||||
const merged = curatedLive.length > 0 ? curatedLive : SNAPSHOT_SCENARIOS;
|
||||
const merged = curateScenarios([...scenarios, ...syntheticScenarios]);
|
||||
const first = merged[0];
|
||||
const currentId = get().scenarioId;
|
||||
const currentStepId = get().selectedStepId;
|
||||
@@ -169,8 +168,8 @@ async function runLiveFetch(
|
||||
: `Live mode · ${scenarios.length} live processes`,
|
||||
);
|
||||
} catch (e) {
|
||||
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
|
||||
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`);
|
||||
set({ liveLoading: false, liveError: (e as Error).message });
|
||||
get().pushToast("err", `Live data failed: ${(e as Error).message.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,19 +190,11 @@ export const useApp = create<AppState>((set, get) => {
|
||||
};
|
||||
|
||||
return {
|
||||
scene: "landing",
|
||||
scene: "mission",
|
||||
setScene: (scene) => set({ scene }),
|
||||
|
||||
mode: prefs.mode ?? "snapshot",
|
||||
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");
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
mode: "live",
|
||||
setMode: async (_mode) => {
|
||||
await runLiveFetch(set, get);
|
||||
get().startPolling();
|
||||
persist();
|
||||
|
||||
Reference in New Issue
Block a user