diff --git a/qa/smoke_quick.mjs b/qa/smoke_quick.mjs new file mode 100644 index 0000000..12f40c9 --- /dev/null +++ b/qa/smoke_quick.mjs @@ -0,0 +1,43 @@ +import { chromium } from "playwright"; +const b = await chromium.launch({ headless: true }); +const p = await b.newPage({ viewport: { width: 1440, height: 900 } }); +const errors = []; +p.on("pageerror", e => errors.push(`pageerror ${e.message}`)); +p.on("console", m => { if (m.type() === "error") errors.push(`console.error ${m.text().slice(0, 200)}`); }); + +await p.goto("http://127.0.0.1:5173", { waitUntil: "networkidle" }); +await p.locator(".sc-card").first().click(); +await p.waitForSelector(".mc"); await p.waitForTimeout(400); + +// Open console first +await p.locator(".link-btn", { hasText: "Console" }).first().click(); +await p.waitForSelector(".console"); + +// Toggle live +await p.locator(".mode-toggle").first().click(); +await p.waitForTimeout(6000); // let all calls land + +const callCount = await p.locator(".call").count(); +console.log("api calls logged:", callCount); +const firstCall = await p.locator(".call-head").first(); +if (await firstCall.count()) { + console.log("first call:", await firstCall.innerText()); +} + +// Click a call to expand +if (callCount > 0) { + await p.locator(".call-head").first().click(); + await p.waitForTimeout(300); + console.log("expanded has body:", await p.locator(".call-body").first().isVisible()); +} + +// Sign in via settings +await p.locator(".tab", { hasText: "Settings" }).click(); +await p.waitForSelector(".settings"); await p.waitForTimeout(300); +const userBtn = p.locator(".quick-users .link-btn", { hasText: "dev@flow-master.ai" }).first(); +console.log("settings quick-user:", await userBtn.isVisible()); + +console.log("\nconsole errors:", errors.length); +errors.slice(0, 5).forEach(e => console.log(" -", e)); +await p.screenshot({ path: "qa/screenshots/v3-console-open.png", fullPage: false }); +await b.close(); diff --git a/src/App.tsx b/src/App.tsx index 1e82ad4..53d21a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,15 @@ -// App shell — scene switcher + top bar + command palette + tour overlay. +// App shell — scene switcher + top bar + command palette + console + toaster. +import { useEffect } from "react"; import { useApp, scenarioById } from "./state/store"; import Landing from "./scenes/Landing"; import MissionControl from "./scenes/MissionControl"; import RunHistory from "./scenes/RunHistory"; +import Studio from "./scenes/Studio"; +import Settings from "./scenes/Settings"; import CommandBar from "./components/CommandBar"; -import Tour from "./components/Tour"; import Toaster from "./components/Toaster"; -import { Cmd, Home, Layers, History as HistoryIcon, Sparkles, Pulse, Refresh } from "./components/icons"; +import Console from "./components/Console"; +import { Cmd, Home, Layers, History as HistoryIcon, Pulse, Refresh, Branch, Cog, User } from "./components/icons"; import { liveMeta } from "./data/scenarios"; export default function App() { @@ -15,13 +18,23 @@ export default function App() { const setCmdOpen = useApp((s) => s.setCmdOpen); const scenarioId = useApp((s) => s.scenarioId); const sc = scenarioById(scenarioId); - const startTour = useApp((s) => s.startTour); - const tourActive = useApp((s) => s.tour.active); 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); + + useEffect(() => { + if (mode === "live") startPolling(); + return () => stopPolling(); + }, [mode, startPolling, stopPolling]); const liveAge = liveFetchedAt ? `${Math.max(0, Math.floor((Date.now() - liveFetchedAt) / 1000))}s ago` : null; @@ -43,13 +56,19 @@ export default function App() { + +
- {sc && ( + {sc && scene === "mission" && (
{sc.family.label} @@ -68,6 +87,15 @@ export default function App() {
+ + )} -
@@ -99,10 +132,12 @@ export default function App() { {scene === "landing" && } {scene === "mission" && } {scene === "history" && } + {scene === "studio" && } + {scene === "settings" && }
- + ); diff --git a/src/components/CommandBar.tsx b/src/components/CommandBar.tsx index 45dc1d1..d95de70 100644 --- a/src/components/CommandBar.tsx +++ b/src/components/CommandBar.tsx @@ -1,15 +1,14 @@ -// Command palette v2 — scenarios, steps, tour, scenes, recents. +// Command palette — scenarios, steps, scenes, recents, real actions. import { useEffect, useMemo } from "react"; import { Command } from "cmdk"; import { useApp, scenarioById } from "../state/store"; -import { Play, Branch, Bot, Check, Sparkles, Home, History as HistoryIcon, Layers, Search, Refresh } from "./icons"; +import { Play, Branch, Bot, Check, Home, History as HistoryIcon, Layers, Search, Refresh, Cog, User } from "./icons"; export default function CommandBar() { const open = useApp((s) => s.cmdOpen); const setOpen = useApp((s) => s.setCmdOpen); const setScenarioId = useApp((s) => s.setScenarioId); const setSelectedStepId = useApp((s) => s.setSelectedStepId); - const startTour = useApp((s) => s.startTour); const setScene = useApp((s) => s.setScene); const recents = useApp((s) => s.recents); const scenarioId = useApp((s) => s.scenarioId); @@ -18,14 +17,16 @@ export default function CommandBar() { 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); const pushToast = useApp((s) => s.pushToast); + const setConsoleOpen = useApp((s) => s.setConsoleOpen); + const setTheme = useApp((s) => s.setTheme); + const theme = useApp((s) => s.theme); + const actor = useApp((s) => s.actor); const sc = scenarioById(scenarioId); - const previewAction = (label: string) => { - pushToast("info", `${label} is preview-only. Wire to POST /api/runtime/transactions/{id}/actions to make it real.`); - }; - useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { @@ -44,12 +45,15 @@ export default function CommandBar() { const close = () => setOpen(false); + const canActLive = mode === "live" && actor?.user_id && sc?.live && sc.headlineTx; + const firstActionId = sc?.steps.find((s) => s.state === "running")?.actions?.[0]?.id; + return (
e.stopPropagation()} label="Command bar">
- + esc
@@ -75,11 +79,70 @@ export default function CommandBar() { { setScene("history"); close(); }}> Run History + { setScene("studio"); close(); }}> + Process Studio + design & publish a new process + + { setScene("settings"); close(); }}> + Settings + - - { startTour(); close(); }}> - Start guided tour presenter mode + + {mode === "live" ? ( + { refreshLive(); close(); }}> + Refresh live scenarios + re-fetch demo.flow-master.ai + + ) : ( + { setMode("live"); close(); }}> + Switch to LIVE mode (fetch demo.flow-master.ai) + in-browser + + )} + { setMode("snapshot"); close(); }}> + Switch to SNAPSHOT mode + bundled JSON + + { setConsoleOpen(true); close(); }}> + Open live API console + stream every fetch + + { setTheme(theme === "dark" ? "light" : "dark"); close(); }}> + Toggle theme + {theme === "dark" ? "→ light" : "→ dark"} + + + + + {sc?.live ? ( + { + close(); + if (mode !== "live") { pushToast("warn", "Switch to LIVE mode to start a real instance."); return; } + await startInstance(sc.defKey, `MC demo · ${new Date().toLocaleString()}`); + }} + > + Start new instance of "{sc.defName}" + POST /api/runtime/transactions + + ) : ( + + Start new instance + switch to a LIVE scenario first + + )} + {canActLive && firstActionId && ( + { close(); await executeAction(sc!.headlineTx!, firstActionId); }} + > + Execute "{firstActionId}" on headline tx + {sc?.headlineTx?.slice(0, 8)} · real + + )} + { setScene("settings"); close(); }}> + Sign in as different user + Settings → identity @@ -112,33 +175,15 @@ export default function CommandBar() { )} - - {mode === "live" ? ( - { refreshLive(); close(); }}> - Refresh live scenarios - re-fetch demo.flow-master.ai - - ) : ( - { setMode("live"); close(); }}> - Switch to LIVE mode (fetch demo.flow-master.ai) - in-browser - - )} - { setMode("snapshot"); close(); }}> - Switch to SNAPSHOT mode - bundled JSON - - - - - { previewAction("Start runtime instance"); close(); }}> - Start runtime instance preview - - { previewAction("Dispatch sidekick agent"); close(); }}> - Dispatch sidekick agent preview - - { previewAction("Confirm awaiting agent runs"); close(); }}> - Confirm awaiting agent runs {sc?.agentRuns.length ?? 0} pending · preview + + { + close(); + pushToast("info", `Sidekick dispatch endpoint not yet wired — see Studio for process-level changes.`); + }} + > + Dispatch sidekick agent + coming soon diff --git a/src/components/Console.tsx b/src/components/Console.tsx new file mode 100644 index 0000000..7d46a40 --- /dev/null +++ b/src/components/Console.tsx @@ -0,0 +1,116 @@ +// Live API call log. Every fetch the app makes shows up here in real time. +// Replaces the old guided-tour overlay. +import { useEffect, useRef, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useApp } from "../state/store"; +import { Close, Refresh, Layers, Pulse } from "./icons"; + +const METHOD_COLOR: Record = { + GET: "var(--text-2)", + POST: "#7eb0ff", + PUT: "#f5b755", + PATCH: "#f5b755", + DELETE: "#ff8a8a", +}; + +function statusClass(s: number): string { + if (s === 0) return "err"; + if (s >= 500) return "err"; + if (s >= 400) return "warn"; + if (s >= 200) return "ok"; + return "info"; +} + +export default function Console() { + const open = useApp((s) => s.consoleOpen); + const setOpen = useApp((s) => s.setConsoleOpen); + const log = useApp((s) => s.apiLog); + const clear = useApp((s) => s.clearApiLog); + const [expanded, setExpanded] = useState(null); + const [filter, setFilter] = useState<"all" | "writes" | "errors">("all"); + const tailRef = useRef(null); + + useEffect(() => { + if (!open) return; + tailRef.current?.scrollTo({ top: tailRef.current.scrollHeight, behavior: "smooth" }); + }, [log, open]); + + const filtered = log.filter((c) => { + if (filter === "writes") return c.method !== "GET"; + if (filter === "errors") return c.status === 0 || c.status >= 400; + return true; + }); + + return ( + + {open && ( + +
+ + Live console + {log.length} +
+ + + +
+ + +
+
+ {filtered.length === 0 && ( +
No API calls yet. Flip to LIVE mode or perform an action.
+ )} + {filtered.map((c) => { + const isExpanded = expanded === c.id; + const shortPath = c.path.replace(/^https?:\/\/[^/]+/, ""); + return ( +
+ + {isExpanded && ( +
+ {c.reqBody !== undefined && ( + <> +
request
+
{JSON.stringify(c.reqBody, null, 2)}
+ + )} + {c.error && ( + <> +
network error
+
{c.error}
+ + )} + {c.resBody !== undefined && ( + <> +
response
+
{typeof c.resBody === "string" ? c.resBody : JSON.stringify(c.resBody, null, 2)}
+ + )} +
+ )} +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 419ebb9..1e58772 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -1,5 +1,5 @@ // Tabbed Inspector for the selected step. -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useApp, scenarioById } from "../state/store"; import { Shield, Doc, Layers, Pulse, History } from "./icons"; @@ -74,16 +74,36 @@ function Empty({ msg }: { msg: string }) { return
{msg}
; } -function PreviewActionButton({ kind, label }: { kind: "complete" | "approve" | "decline" | "fork"; label: string }) { +function ActionButton({ kind, label, actionId }: { kind: "complete" | "approve" | "decline" | "fork"; label: string; actionId: string }) { + const mode = useApp((s) => s.mode); + const actor = useApp((s) => s.actor); + const executeAction = useApp((s) => s.executeAction); const pushToast = useApp((s) => s.pushToast); + const scenarioId = useApp((s) => s.scenarioId); + const sc = scenarioById(scenarioId); + const txId = sc?.headlineTx ?? null; + const isLive = mode === "live" && txId && actor?.user_id; const cls = kind === "approve" ? "btn btn-primary" : kind === "decline" ? "btn btn-ghost btn-decline" : "btn btn-ghost"; + const [running, setRunning] = useState(false); + + const onClick = async () => { + if (!isLive) { + pushToast("info", `Switch to LIVE mode + sign in to execute "${label}" against /api/runtime/transactions/{id}/actions/{actionId}.`); + return; + } + setRunning(true); + try { + await executeAction(txId, actionId); + } finally { + setRunning(false); + } + }; + return ( - ); } @@ -99,10 +119,10 @@ function OverviewTab({ step, sc }: { step: import("../data/types").ProcessStep;
Process{sc.defName} · {sc.version}
{step.actions.length > 0 && ( <> -

Available actions preview

+

Available actions

{step.actions.map((a: import("../data/types").StepAction) => ( - + ))}
diff --git a/src/components/LeftRail.tsx b/src/components/LeftRail.tsx index da4e4c1..76cb2df 100644 --- a/src/components/LeftRail.tsx +++ b/src/components/LeftRail.tsx @@ -1,6 +1,7 @@ // LeftRail: queues + agent runs for the active scenario. +import { useState } from "react"; import { useApp, scenarioById } from "../state/store"; -import { Inbox, Bot, Clock, Check, Close, ArrowUp, ArrowDown, Pulse } from "./icons"; +import { Inbox, Bot, Clock, Check, Close, ArrowUp, ArrowDown, Pulse, Play } from "./icons"; const WAIT_LABEL: Record = { approval: "Approval", agent: "Agent", input: "Input" }; @@ -28,6 +29,7 @@ export default function LeftRail() {
))} +
@@ -73,25 +75,66 @@ export default function LeftRail() {
{a.intent}
-
- - -
+ ))}
); } + +function AgentActions({ txId }: { txId: string | null }) { + const mode = useApp((s) => s.mode); + const actor = useApp((s) => s.actor); + const executeAction = useApp((s) => s.executeAction); + const pushToast = useApp((s) => s.pushToast); + const isLive = mode === "live" && !!txId && !!actor?.user_id; + const [running, setRunning] = useState<"confirm" | "reject" | null>(null); + const onConfirm = async () => { + if (!isLive) { pushToast("info", "Switch to LIVE mode + sign in to confirm a real agent run."); return; } + setRunning("confirm"); + try { await executeAction(txId!, "submit"); } finally { setRunning(null); } + }; + const onReject = async () => { + if (!isLive) { pushToast("info", "Switch to LIVE mode + sign in to reject a real agent run."); return; } + setRunning("reject"); + try { await executeAction(txId!, "save_draft"); } finally { setRunning(null); } + }; + return ( +
+ + +
+ ); +} + +function StartInstanceButton({ defKey, live }: { defKey: string; live: boolean }) { + const mode = useApp((s) => s.mode); + const startInstance = useApp((s) => s.startInstance); + const pushToast = useApp((s) => s.pushToast); + const [busy, setBusy] = useState(false); + const canStart = mode === "live" && live; + const onClick = async () => { + if (!canStart) { + pushToast("info", "Live procurement scenarios can be started for real. Switch to LIVE mode first."); + return; + } + setBusy(true); + try { + await startInstance(defKey, `MC demo · ${new Date().toLocaleString()}`); + } finally { setBusy(false); } + }; + return ( + + ); +} diff --git a/src/components/Tour.tsx b/src/components/Tour.tsx deleted file mode 100644 index 304ee3a..0000000 --- a/src/components/Tour.tsx +++ /dev/null @@ -1,66 +0,0 @@ -// Guided tour overlay. Reads the active scenario's tour script and -// anchors a step-card to the named region. -import { useEffect } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { useApp, scenarioById } from "../state/store"; -import { Sparkles, ChevL, ChevR, Close } from "./icons"; - -export default function Tour() { - const tour = useApp((s) => s.tour); - const scenarioId = useApp((s) => s.scenarioId); - const endTour = useApp((s) => s.endTour); - const tourPrev = useApp((s) => s.tourPrev); - const tourNext = useApp((s) => s.tourNext); - - const sc = scenarioById(scenarioId); - const step = sc?.tour[tour.index] ?? null; - - useEffect(() => { - if (!tour.active) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "ArrowRight" || e.key === "Enter") { e.preventDefault(); tourNext(); } - if (e.key === "ArrowLeft") { e.preventDefault(); tourPrev(); } - if (e.key === "Escape") { e.preventDefault(); endTour(); } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [tour.active, tourPrev, tourNext, endTour]); - - if (!tour.active || !step || !sc) return null; - - const last = tour.index === sc.tour.length - 1; - const first = tour.index === 0; - - return ( - - -
- - Guided tour · {tour.index + 1} / {sc.tour.length} - -
-

{step.title}

-

{step.body}

-
- - {last ? ( - - ) : ( - - )} -
-
-
- ); -} diff --git a/src/index.css b/src/index.css index 0a448b3..0692dda 100644 --- a/src/index.css +++ b/src/index.css @@ -680,3 +680,157 @@ kbd { font-family: var(--mono); font-size: 11px; background: var(--surface-2); p .toast-ok { border-left: 3px solid var(--ok); } .toast-warn { border-left: 3px solid var(--queue); } .toast-err { border-left: 3px solid var(--block); } + +/* ===================================================================== + Light theme — minimal token overrides, everything else inherits dark + ===================================================================== */ +:root[data-theme="light"] { + --bg: #f4f6fb; + --bg-deep: #ffffff; + --surface: #ffffff; + --surface-2: #f0f3f9; + --surface-3: #e2e8f3; + --border: #d8dee9; + --border-strong: #b6c1d4; + --border-glow: #3b82f655; + --text: #0c1322; + --text-2: #4b5872; + --text-3: #7a87a5; + --text-soft: #1c2538; +} +:root[data-theme="light"] body { background: var(--bg); } +:root[data-theme="light"] .landing-bg { + background: + radial-gradient(circle at 12% 18%, rgba(59,130,246,0.18) 0%, transparent 40%), + radial-gradient(circle at 86% 76%, rgba(168,85,247,0.14) 0%, transparent 45%), + var(--bg); +} +:root[data-theme="light"] .landing-grid { mask-image: radial-gradient(circle at 50% 40%, black 0%, transparent 70%); opacity: 0.5; } +:root[data-theme="light"] .topbar { background: linear-gradient(180deg, #fff 0%, var(--bg) 100%); } +:root[data-theme="light"] .mc-main { background: + radial-gradient(circle at 1px 1px, var(--border) 1px, transparent 0) 0 0 / 28px 28px, + var(--bg); } + +/* ===================================================================== + Console panel (right side drawer) + ===================================================================== */ +.console { + position: fixed; right: 0; top: 56px; bottom: 0; + width: min(420px, 95vw); + background: var(--bg-deep); + border-left: 1px solid var(--border); + display: grid; grid-template-rows: auto 1fr; + z-index: 180; + box-shadow: -8px 0 40px rgba(0,0,0,0.45); +} +.console-head { + display: flex; align-items: center; gap: 8px; + padding: 9px 12px; border-bottom: 1px solid var(--border); + font-size: 12px; color: var(--text-2); +} +.console-title { color: var(--text); font-weight: 600; } +.console-count { background: var(--surface-2); padding: 1px 7px; border-radius: 999px; font-size: 11px; color: var(--text-2); } +.console-filters { margin-left: auto; display: inline-flex; gap: 3px; background: var(--surface-2); padding: 2px; border-radius: 7px; border: 1px solid var(--border); } +.console-filter { padding: 3px 8px; border-radius: 5px; font-size: 11px; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.4px; font-weight: 600; } +.console-filter:hover { color: var(--text); } +.console-filter.on { background: var(--surface); color: var(--text); box-shadow: 0 0 0 1px var(--border-strong) inset; } +.console-x { padding: 4px 6px; border-radius: 6px; color: var(--text-3); } +.console-x:hover { color: var(--text); background: var(--surface-2); } +.console-body { overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 3px; font-size: 12px; } + +.call { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; border-left-width: 3px; } +.call.ok { border-left-color: var(--ok); } +.call.warn { border-left-color: var(--queue); } +.call.err { border-left-color: var(--block); } +.call.info { border-left-color: var(--border-strong); } +.call-head { + display: grid; grid-template-columns: 50px 44px 1fr auto; + align-items: center; gap: 8px; + width: 100%; padding: 7px 10px; text-align: left; + font-size: 11.5px; +} +.call-head:hover { background: var(--surface-2); } +.call-method { font-weight: 700; } +.call-status { color: var(--text-2); } +.call-path { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.call-dur { color: var(--text-3); font-size: 11px; } +.call-body { padding: 6px 10px 10px; border-top: 1px dashed var(--border); display: flex; flex-direction: column; gap: 6px; } +.call-label { display: inline-flex; align-items: center; gap: 5px; color: var(--text-3); font-size: 10px; letter-spacing: 0.6px; text-transform: uppercase; font-weight: 600; } +.call-err { color: var(--block); } +.call-json { margin: 0; padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-2); font-family: var(--mono); font-size: 11px; line-height: 1.45; overflow-x: auto; max-height: 260px; overflow-y: auto; } + +/* ===================================================================== + Topbar badge + user button + ===================================================================== */ +.badge { + display: inline-flex; align-items: center; + margin-left: 6px; + font-size: 10px; font-weight: 700; + padding: 1px 6px; border-radius: 999px; + background: var(--primary-deep); color: #fff; +} +.user-btn .user-email { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ===================================================================== + Studio + ===================================================================== */ +.studio { height: 100%; display: flex; flex-direction: column; overflow-y: auto; background: var(--bg); } +.studio-head { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 18px 24px; border-bottom: 1px solid var(--border); background: var(--bg); } +.studio-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); + gap: 16px; padding: 20px 24px 40px; +} +.studio-panel { + background: var(--surface); border: 1px solid var(--border); + border-radius: 14px; padding: 16px 18px; + display: flex; flex-direction: column; gap: 12px; +} +.studio-panel > .panel-h { margin: -2px 0 4px; } +.studio-field { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--text-2); } +.studio-field span { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-3); font-weight: 600; } +.studio-input { + font-family: var(--sans); + background: var(--bg-deep); border: 1px solid var(--border); + border-radius: 7px; padding: 7px 10px; color: var(--text); font-size: 13px; +} +.studio-input:focus { outline: 2px solid var(--primary); outline-offset: 1px; } +.studio-input.mono { font-family: var(--mono); } +textarea.studio-input { resize: vertical; min-height: 64px; } + +.studio-rows { display: flex; flex-direction: column; gap: 6px; } +.studio-row { display: flex; align-items: center; gap: 8px; } +.studio-row .studio-input { flex: 1; } +.studio-row-id { color: var(--text-3); font-size: 11px; min-width: 32px; } + +.studio-result { + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 12px; border-radius: 8px; + font-size: 12.5px; font-weight: 500; +} +.studio-result.ok { background: rgba(52,211,153,0.12); color: var(--ok); border: 1px solid rgba(52,211,153,0.4); } +.studio-result.err { background: rgba(240,82,82,0.12); color: var(--block); border: 1px solid rgba(240,82,82,0.4); } + +.start-btn { + display: inline-flex; align-items: center; gap: 8px; + margin-top: 4px; padding: 9px 12px; border-radius: 9px; + background: linear-gradient(180deg, var(--primary), var(--primary-deep)); color: #fff; + font-size: 12.5px; font-weight: 600; + transition: filter .15s; +} +.start-btn:hover:not(:disabled) { filter: brightness(1.1); } +.start-btn:disabled { opacity: 0.65; cursor: not-allowed; } + +/* ===================================================================== + Settings + ===================================================================== */ +.settings { height: 100%; display: flex; flex-direction: column; overflow-y: auto; background: var(--bg); } +.settings-id { display: flex; flex-direction: column; gap: 4px; } +.settings-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.settings-hint { color: var(--text-3); font-size: 11.5px; margin-top: 4px; } +.settings-toggle { display: inline-flex; align-items: center; gap: 9px; font-size: 13px; color: var(--text); } +.settings-toggle input { accent-color: var(--primary); width: 14px; height: 14px; } +.settings-text { font-size: 13px; color: var(--text-2); line-height: 1.55; } +.settings-link { color: var(--primary); text-decoration: underline; } +.quick-users { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.quick-users .link-btn { font-family: var(--mono); font-size: 11px; } diff --git a/src/lib/api.ts b/src/lib/api.ts index 1a38056..45f0a7b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -42,37 +42,6 @@ async function login(cfg: ApiConfig, signal?: AbortSignal): Promise { } } -async function withAuth( - cfg: ApiConfig, - path: string, - init: RequestInit = {}, - signal?: AbortSignal, -): Promise { - let token = sessionStorage.getItem(TOKEN_KEY); - if (!token) token = await login(cfg, signal); - const doFetch = async () => - fetch(`${cfg.baseUrl}${path}`, { - ...init, - headers: { - ...(init.headers || {}), - Authorization: `Bearer ${token}`, - Accept: "application/json", - }, - signal, - }); - let r = await doFetch(); - if (r.status === 401) { - sessionStorage.removeItem(TOKEN_KEY); - token = await login(cfg, signal); - r = await doFetch(); - } - if (!r.ok) { - const text = await r.text().catch(() => ""); - throw new Error(`${path} → ${r.status} ${text.slice(0, 120)}`); - } - return (await r.json()) as T; -} - export interface WorkItem { transaction_id: string; short_id?: string; @@ -114,31 +83,115 @@ export interface AuthMe { display_name?: string; } +export interface Actor { + mode: "direct_user" | "agent" | "system"; + user_id?: string; +} + +export interface ActionResult { + transaction_id: string; + action_id: string; + status: string; + previous_step?: { display_name?: string; step_run_id?: string }; + next_step?: { display_name?: string; step_definition_id?: string }; + active_step?: { display_name?: string; step_definition_id?: string }; +} + +export type ApiCallObserver = (call: ApiCall) => void; +export interface ApiCall { + id: number; + method: string; + path: string; + status: number; + durationMs: number; + reqBody?: unknown; + resBody?: unknown; + error?: string; + at: number; +} + +let callSeq = 0; +const observers = new Set(); +const emit = (c: ApiCall) => { for (const o of observers) try { o(c); } catch { /* swallow */ } }; + +async function instrumentedFetch( + method: string, + url: string, + init: RequestInit, + reqBody?: unknown, +): Promise { + const id = ++callSeq; + const at = Date.now(); + try { + const r = await fetch(url, init); + const cloned = r.clone(); + let resBody: unknown; + try { + const text = await cloned.text(); + try { resBody = JSON.parse(text); } catch { resBody = text.slice(0, 500); } + } catch { /* swallow */ } + emit({ id, method, path: url, status: r.status, durationMs: Date.now() - at, reqBody, resBody, at }); + return r; + } catch (e) { + emit({ id, method, path: url, status: 0, durationMs: Date.now() - at, reqBody, error: (e as Error).message, at }); + throw e; + } +} + +async function authedRequest( + cfg: ApiConfig, + method: string, + path: string, + body?: unknown, + signal?: AbortSignal, +): Promise { + let token = sessionStorage.getItem(TOKEN_KEY); + if (!token) token = await login(cfg, signal); + const init: RequestInit = { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + signal, + body: body !== undefined ? JSON.stringify(body) : undefined, + }; + let r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body); + if (r.status === 401) { + sessionStorage.removeItem(TOKEN_KEY); + token = await login(cfg, signal); + init.headers = { ...init.headers, Authorization: `Bearer ${token}` }; + r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body); + } + if (!r.ok) { + const text = await r.text().catch(() => ""); + throw new Error(`${path} → ${r.status} ${text.slice(0, 200)}`); + } + return (await r.json()) as T; +} + export const api = { config: DEFAULT_CONFIG, + /** Subscribe to all API calls. Returns unsubscribe. */ + onCall(fn: ApiCallObserver): () => void { + observers.add(fn); + return () => observers.delete(fn); + }, + async me(signal?: AbortSignal): Promise { - return withAuth(this.config, "/api/v1/auth/me", {}, signal); + return authedRequest(this.config, "GET", "/api/v1/auth/me", undefined, signal); }, async workItems(signal?: AbortSignal): Promise { - const body = await withAuth<{ items?: WorkItem[] }>( - this.config, - "/api/ea2/work-items?view=all", - {}, - signal, - ); + const body = await authedRequest<{ items?: WorkItem[] }>(this.config, "GET", "/api/ea2/work-items?view=all", undefined, signal); return body.items ?? []; }, async graph(defKey: string, signal?: AbortSignal): Promise { try { - return await withAuth( - this.config, - `/api/ea2/process-definitions/${defKey}/graph`, - {}, - signal, - ); + return await authedRequest(this.config, "GET", `/api/ea2/process-definitions/${defKey}/graph`, undefined, signal); } catch { return null; } @@ -146,22 +199,76 @@ export const api = { async transaction(txId: string, signal?: AbortSignal): Promise { try { - return await withAuth( - this.config, - `/api/runtime/transactions/${txId}`, - {}, - signal, - ); + return await authedRequest(this.config, "GET", `/api/runtime/transactions/${txId}`, undefined, signal); } catch { return null; } }, + /** Start a fresh runtime instance of a process definition. */ + async startTransaction( + process_definition_id: string, + business_subject?: string, + signal?: AbortSignal, + ): Promise { + return authedRequest( + this.config, + "POST", + "/api/runtime/transactions", + { process_definition_id, business_subject: business_subject ?? null }, + signal, + ); + }, + + /** Execute an action against the active step of a transaction. */ + async executeAction( + txId: string, + actionId: string, + actor: Actor, + values: Record = {}, + signal?: AbortSignal, + ): Promise { + return authedRequest( + this.config, + "POST", + `/api/runtime/transactions/${txId}/actions/${actionId}`, + { actor, values }, + signal, + ); + }, + + /** Create a new published process definition (writes to /api/ea2/flow). */ + async createProcess( + payload: { + name: string; + display_name: string; + label?: string; + description?: string; + hub?: string; + config: { nodes: unknown[]; edges: unknown[]; org_id: string; executable?: boolean }; + }, + signal?: AbortSignal, + ): Promise<{ _key: string; name: string }> { + return authedRequest<{ _key: string; name: string }>( + this.config, + "POST", + "/api/ea2/flow", + { + kind: "definition", + status: "published", + source_context: "mc-demo-studio", + version: 1, + ...payload, + }, + signal, + ); + }, + /** Probe whether the backend is reachable. Never throws. */ - async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string }> { + async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string; user_id?: string }> { try { const me = await this.me(signal); - return { ok: true, user: me.email }; + return { ok: true, user: me.email, user_id: me.user_id }; } catch (e) { return { ok: false, reason: (e as Error).message }; } diff --git a/src/scenes/Landing.tsx b/src/scenes/Landing.tsx index 650c0a4..98dd6d6 100644 --- a/src/scenes/Landing.tsx +++ b/src/scenes/Landing.tsx @@ -7,8 +7,8 @@ import { Sparkles, Arrow, Cmd, Bot, Pulse } from "../components/icons"; export default function Landing() { const setScene = useApp((s) => s.setScene); const setScenarioId = useApp((s) => s.setScenarioId); - const startTour = useApp((s) => s.startTour); const setCmdOpen = useApp((s) => s.setCmdOpen); + const setConsoleOpen = useApp((s) => s.setConsoleOpen); const scenarios = useApp((s) => s.scenarios); const mode = useApp((s) => s.mode); const setMode = useApp((s) => s.setMode); @@ -54,11 +54,8 @@ export default function Landing() { same shell extends to any process family.

- - + +
diff --git a/src/scenes/Settings.tsx b/src/scenes/Settings.tsx new file mode 100644 index 0000000..ef9045c --- /dev/null +++ b/src/scenes/Settings.tsx @@ -0,0 +1,164 @@ +// Settings scene — identity, backend URL, polling cadence, theme. +import { useState } from "react"; +import { useApp } from "../state/store"; +import { api } from "../lib/api"; +import { User, Refresh, Cog, Pulse, Layers, Check } from "../components/icons"; + +const COMMON_EMAILS = [ + "dev@flow-master.ai", + "procurement.operator@flowmaster.local", + "finance.lead@flowmaster.local", + "ops.admin@flowmaster.local", +]; + +export default function Settings() { + const userEmail = useApp((s) => s.userEmail); + const actor = useApp((s) => s.actor); + const userDisplayName = useApp((s) => s.userDisplayName); + const loginAs = useApp((s) => s.loginAs); + const pollEverySec = useApp((s) => s.pollEverySec); + const setPollEverySec = useApp((s) => s.setPollEverySec); + const theme = useApp((s) => s.theme); + 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); + + const [emailInput, setEmailInput] = useState(userEmail); + const [signingIn, setSigningIn] = useState(false); + + const signIn = async (email: string) => { + setSigningIn(true); + try { + await loginAs(email); + } finally { + setSigningIn(false); + } + }; + + const clearToken = () => { + api.clearToken(); + pushToast("info", "Cleared bearer token. Next request will re-login."); + }; + + return ( +
+
+
+
Settings
+

Identity, backend & UI

+
Everything here is persisted to localStorage and survives reloads.
+
+
+ +
+
+

Identity

+
+
+ Current + {actor?.user_id?.slice(0, 12) ?? "—"} +
+
+ Email + {userEmail} +
+
+ Display name + {userDisplayName ?? "—"} +
+
+ Backend + {api.config.baseUrl || "(same-origin via /api)"} +
+
+ +

Sign in as

+
+
+ setEmailInput(e.target.value)} + /> + +
+
+ {COMMON_EMAILS.map((e) => ( + + ))} +
+
+ +
+ +
+

Data mode & polling

+
+ Current mode + {mode === "live" ? "LIVE" : "SNAPSHOT"} +
+
+ + {mode === "live" && ( + + )} +
+ +
Polling only runs in LIVE mode. Currently {mode === "live" ? "polling" : "paused"}.
+
+ +
+

Appearance

+
+ + +
+ +
+ +
+

About

+

+ Mission Control demo. All actions are real backend calls — the live + console shows every fetch. Source:{" "} + + gitea.flow-master.ai/shad/flowmaster-mission-control-demo + . +

+
+
+
+ ); +} diff --git a/src/scenes/Studio.tsx b/src/scenes/Studio.tsx new file mode 100644 index 0000000..2c6b8e0 --- /dev/null +++ b/src/scenes/Studio.tsx @@ -0,0 +1,237 @@ +// Studio scene — design a new FlowMaster process and publish it to EA2. +import { useState } from "react"; +import { useApp } from "../state/store"; +import { api } from "../lib/api"; +import { Branch, Play, Check, Close, Bot, User, Cog } from "../components/icons"; + +type NodeKind = "start" | "human_task" | "agent_task" | "service_task" | "end"; + +interface DraftNode { + id: string; + type: NodeKind; + label: string; +} + +interface DraftEdge { + id: string; + source: string; + target: string; +} + +const DEFAULT_NODES: DraftNode[] = [ + { id: "start", type: "start", label: "Request received" }, + { id: "review", type: "human_task", label: "Manager review" }, + { id: "approved", type: "end", label: "Approved" }, +]; +const DEFAULT_EDGES: DraftEdge[] = [ + { id: "e1", source: "start", target: "review" }, + { id: "e2", source: "review", target: "approved" }, +]; + +const KIND_ICON: Record React.ReactElement> = { + start: Branch, + end: Branch, + human_task: User, + agent_task: Bot, + service_task: Cog, +}; + +const ORG_ID = "a0000000-0000-0000-0000-000000000010"; + +export default function Studio() { + const mode = useApp((s) => s.mode); + const pushToast = useApp((s) => s.pushToast); + const refreshLive = useApp((s) => s.refreshLive); + const actor = useApp((s) => s.actor); + const setScene = useApp((s) => s.setScene); + + const [name, setName] = useState("my-process-" + Math.random().toString(36).slice(2, 8)); + const [displayName, setDisplayName] = useState("My Process"); + const [hub, setHub] = useState("procurement"); + const [description, setDescription] = useState("Demo process designed in the Studio."); + const [nodes, setNodes] = useState(DEFAULT_NODES); + const [edges, setEdges] = useState(DEFAULT_EDGES); + const [publishing, setPublishing] = useState(false); + const [lastResult, setLastResult] = useState<{ ok: boolean; key?: string; err?: string } | null>(null); + + const canPublish = mode === "live" && !!actor?.user_id; + + const addNode = () => { + const idx = nodes.length; + setNodes([...nodes, { id: `n${idx}`, type: "human_task", label: `New step ${idx}` }]); + }; + + const removeNode = (id: string) => { + setNodes(nodes.filter((n) => n.id !== id)); + setEdges(edges.filter((e) => e.source !== id && e.target !== id)); + }; + + const updateNode = (id: string, patch: Partial) => { + setNodes(nodes.map((n) => (n.id === id ? { ...n, ...patch } : n))); + }; + + const addEdge = () => { + if (nodes.length < 2) return; + const idx = edges.length; + setEdges([...edges, { id: `e${idx + 1}`, source: nodes[0].id, target: nodes[nodes.length - 1].id }]); + }; + + const removeEdge = (id: string) => setEdges(edges.filter((e) => e.id !== id)); + + const updateEdge = (id: string, patch: Partial) => { + setEdges(edges.map((e) => (e.id === id ? { ...e, ...patch } : e))); + }; + + const publish = async () => { + if (!canPublish) { + pushToast("warn", "Switch to LIVE mode + sign in to publish a process."); + return; + } + setPublishing(true); + setLastResult(null); + try { + const r = await api.createProcess({ + name, + display_name: displayName, + label: displayName, + description, + hub, + config: { + org_id: ORG_ID, + executable: true, + nodes: nodes.map((n) => ({ id: n.id, type: n.type, label: n.label })), + edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target })), + }, + }); + pushToast("ok", `Published ${r.name} → ${r._key.slice(0, 8)}`); + setLastResult({ ok: true, key: r._key }); + await refreshLive(); + } catch (e) { + pushToast("err", `Publish failed: ${(e as Error).message.slice(0, 140)}`); + setLastResult({ ok: false, err: (e as Error).message }); + } finally { + setPublishing(false); + } + }; + + const valid = nodes.length >= 2 && edges.length >= 1 && nodes.every((n) => n.label.trim().length > 0); + + return ( +
+
+
+
Process Studio
+

Design a new process

+
Hand-craft a typed FlowMaster process and publish it straight to EA2 on demo.flow-master.ai.
+
+ +
+ +
+
+

Definition

+ + + +