fix(oracle-l8): close 3 polish caveats (dispatch labels / chat unread / leftrail leak)
Oracle PASS-with-caveats; closing all 3:
1. Wizard dispatch labels were 'human'/'agent'/'system' (impl-ish).
Now 'Person'/'Assistant'/'System' in both the dropdown and the
preview-pane badge.
2. Chat badge was thread COUNT (a buyer with 5 chats sees '5' forever).
Now real unread count, computed locally from localStorage heads +
read maps that the Chat scene already persists. App.tsx topbar
poll still only fires 2 network calls (listThreads + workItems);
per-thread unread math is zero-network.
3. LeftRail toast leaked 'POST /api/runtime/transactions/{id}/actions/
submit' / save_draft. Rewritten as 'Switch to live mode and sign
in to submit this action against EA2.'
Plus engineering-string sweep elsewhere: 'demo.flow-master.ai' /
'bundled JSON' / 'in-browser fetch' references removed from:
- App.tsx mode toggle title
- MissionControl loading spinner
- Landing mode-button title
- state/store snapshot toast
- data/live.ts + data/synthetic.ts tour-step bodies
- buildScenarios.ts tagline
Source-grep confirms no remaining user-visible engineering jargon
referencing the backend hostname or raw endpoints.
This commit is contained in:
+24
-4
@@ -76,12 +76,32 @@ export default function App() {
|
|||||||
|
|
||||||
const liveAge = liveFetchedAt ? `${Math.max(0, Math.floor((Date.now() - liveFetchedAt) / 1000))}s ago` : null;
|
const liveAge = liveFetchedAt ? `${Math.max(0, Math.floor((Date.now() - liveFetchedAt) / 1000))}s ago` : null;
|
||||||
|
|
||||||
const [chatThreadCount, setChatThreadCount] = useState(0);
|
const [chatUnreadCount, setChatUnreadCount] = useState(0);
|
||||||
const [queueCount, setQueueCount] = useState(0);
|
const [queueCount, setQueueCount] = useState(0);
|
||||||
const tenantId = useApp((s) => s.tenantId);
|
const tenantId = useApp((s) => s.tenantId);
|
||||||
|
|
||||||
|
// Topbar badge poll. listThreads + workItems only (one request each, no
|
||||||
|
// per-thread reads — that's what caused the idle-poll regression earlier).
|
||||||
|
// Unread count is computed locally from heads + read maps persisted by the
|
||||||
|
// Chat scene to localStorage, so no extra network is needed.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthed || !tenantId || !userEmail) return;
|
if (!isAuthed || !tenantId || !userEmail) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const computeUnread = (threadKeys: string[]): number => {
|
||||||
|
let heads: Record<string, { at: string; by: string }> = {};
|
||||||
|
let readMap: Record<string, string> = {};
|
||||||
|
try { heads = JSON.parse(localStorage.getItem(`fm.canvas.chat.heads.${userEmail}`) || "{}"); } catch { /* ignore */ }
|
||||||
|
try { readMap = JSON.parse(localStorage.getItem(`fm.canvas.chat.read.${userEmail}`) || "{}"); } catch { /* ignore */ }
|
||||||
|
let n = 0;
|
||||||
|
for (const k of threadKeys) {
|
||||||
|
const head = heads[k];
|
||||||
|
if (!head) continue;
|
||||||
|
if (head.by === userEmail) continue;
|
||||||
|
const readAt = readMap[k];
|
||||||
|
if (!readAt || head.at > readAt) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
};
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
try {
|
try {
|
||||||
const { chatApi } = await import("./lib/chatApi");
|
const { chatApi } = await import("./lib/chatApi");
|
||||||
@@ -91,7 +111,7 @@ export default function App() {
|
|||||||
apiMod.workItems().catch(() => []),
|
apiMod.workItems().catch(() => []),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setChatThreadCount(threads.length);
|
setChatUnreadCount(computeUnread(threads.map((t) => t._key)));
|
||||||
setQueueCount(items.filter((it) => (it as any).tenant_id === tenantId).length);
|
setQueueCount(items.filter((it) => (it as any).tenant_id === tenantId).length);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
@@ -127,7 +147,7 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
<button role="tab" aria-selected={scene === "chat"} className={`tab${scene === "chat" ? " tab-sel" : ""}`} onClick={() => setScene("chat")}>
|
<button role="tab" aria-selected={scene === "chat"} className={`tab${scene === "chat" ? " tab-sel" : ""}`} onClick={() => setScene("chat")}>
|
||||||
<Bot size={13} /> Chat
|
<Bot size={13} /> Chat
|
||||||
{chatThreadCount > 0 && <span className="tab-badge tab-badge-amber">{chatThreadCount > 99 ? "99+" : chatThreadCount}</span>}
|
{chatUnreadCount > 0 && <span className="tab-badge tab-badge-amber">{chatUnreadCount > 99 ? "99+" : chatUnreadCount}</span>}
|
||||||
</button>
|
</button>
|
||||||
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
|
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
|
||||||
<Bot size={13} /> Assistant
|
<Bot size={13} /> Assistant
|
||||||
@@ -175,7 +195,7 @@ export default function App() {
|
|||||||
disabled={liveLoading}
|
disabled={liveLoading}
|
||||||
title={mode === "live"
|
title={mode === "live"
|
||||||
? `Live mode is on. Fetched ${liveAge}. Click to drop back to snapshot.`
|
? `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."}
|
: "Click to switch to live mode and fetch real-time EA2 data."}
|
||||||
>
|
>
|
||||||
{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}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function AgentActions({ txId }: { txId: string | null }) {
|
|||||||
const [running, setRunning] = useState<"confirm" | "reject" | null>(null);
|
const [running, setRunning] = useState<"confirm" | "reject" | null>(null);
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
pushToast("info", "Switch to LIVE mode + sign in to fire POST /api/runtime/transactions/{id}/actions/submit for real.");
|
pushToast("info", "Switch to live mode and sign in to submit this action against EA2.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRunning("confirm");
|
setRunning("confirm");
|
||||||
@@ -100,7 +100,7 @@ function AgentActions({ txId }: { txId: string | null }) {
|
|||||||
};
|
};
|
||||||
const onReject = async () => {
|
const onReject = async () => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
pushToast("info", "Switch to LIVE mode + sign in to fire POST /api/runtime/transactions/{id}/actions/save_draft for real.");
|
pushToast("info", "Switch to live mode and sign in to save this as a draft.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRunning("reject");
|
setRunning("reject");
|
||||||
|
|||||||
+2
-2
@@ -247,7 +247,7 @@ function buildScenario(raw: LiveScenarioRaw): ProcessScenario {
|
|||||||
defName: pd.display_name || pd.name,
|
defName: pd.display_name || pd.name,
|
||||||
version: versionLabel,
|
version: versionLabel,
|
||||||
headlineTx: raw.headlineTx,
|
headlineTx: raw.headlineTx,
|
||||||
tagline: `${pd.display_name || pd.name} · ${versionLabel} · live from demo.flow-master.ai`,
|
tagline: `${pd.display_name || pd.name} · ${versionLabel} · live from EA2`,
|
||||||
steps,
|
steps,
|
||||||
edges,
|
edges,
|
||||||
rules,
|
rules,
|
||||||
@@ -270,7 +270,7 @@ function buildTour(familyId: string, defaultStepId: string, steps: ProcessStep[]
|
|||||||
id: "t1",
|
id: "t1",
|
||||||
anchor: "graph",
|
anchor: "graph",
|
||||||
title: `Welcome to ${familyId === "procurement" ? "Procurement to Pay" : "this process"}`,
|
title: `Welcome to ${familyId === "procurement" ? "Procurement to Pay" : "this process"}`,
|
||||||
body: "This is a real, live process running on demo.flow-master.ai. Each node is a step the system actually executes. Click any node to inspect it.",
|
body: "This is a real, live process running on EA2. Each node is a step the system actually executes. Click any node to inspect it.",
|
||||||
selectStep: running,
|
selectStep: running,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function baseTour(familyId: string): TourStep[] {
|
|||||||
{ 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: "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: "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: "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." },
|
{ 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 EA2 right in the browser." },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ function buildScenarioFromGraph(
|
|||||||
defName: pd.display_name || pd.name,
|
defName: pd.display_name || pd.name,
|
||||||
version: versionLabel,
|
version: versionLabel,
|
||||||
headlineTx: headlineRt?.transaction_id ?? null,
|
headlineTx: headlineRt?.transaction_id ?? null,
|
||||||
tagline: `${pd.display_name || pd.name} · ${versionLabel} · live from demo.flow-master.ai`,
|
tagline: `${pd.display_name || pd.name} · ${versionLabel} · live from EA2`,
|
||||||
steps,
|
steps,
|
||||||
edges,
|
edges,
|
||||||
rules,
|
rules,
|
||||||
|
|||||||
+7
-2
@@ -110,11 +110,16 @@ export default function Chat() {
|
|||||||
).then((pairs) => {
|
).then((pairs) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const map: Record<string, ChatMessage | null> = {};
|
const map: Record<string, ChatMessage | null> = {};
|
||||||
for (const [k, v] of pairs) map[k] = v;
|
const heads: Record<string, { at: string; by: string }> = {};
|
||||||
|
for (const [k, v] of pairs) {
|
||||||
|
map[k] = v;
|
||||||
|
if (v) heads[k] = { at: v.created_at, by: v.author_email };
|
||||||
|
}
|
||||||
setPreviews(map);
|
setPreviews(map);
|
||||||
|
try { localStorage.setItem(`fm.canvas.chat.heads.${me}`, JSON.stringify(heads)); } catch { /* ignore */ }
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [threads]);
|
}, [threads, me]);
|
||||||
|
|
||||||
const unreadCount = useMemo(() => {
|
const unreadCount = useMemo(() => {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function Landing() {
|
|||||||
className={`btn btn-ghost btn-lg${mode === "live" ? " is-on" : ""}`}
|
className={`btn btn-ghost btn-lg${mode === "live" ? " is-on" : ""}`}
|
||||||
onClick={() => mode === "live" ? refreshLive() : setMode("live")}
|
onClick={() => mode === "live" ? refreshLive() : setMode("live")}
|
||||||
disabled={liveLoading}
|
disabled={liveLoading}
|
||||||
title={mode === "live" ? "Re-fetch live scenarios from demo.flow-master.ai" : "Toggle between bundled snapshot and a live in-browser fetch"}
|
title={mode === "live" ? "Re-fetch live data from EA2" : "Switch to live mode"}
|
||||||
>
|
>
|
||||||
{liveLoading ? <span className="spin" /> : <Pulse size={13} />}
|
{liveLoading ? <span className="spin" /> : <Pulse size={13} />}
|
||||||
{mode === "live" ? "Live · refresh" : "Go live"}
|
{mode === "live" ? "Live · refresh" : "Go live"}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function MissionControl() {
|
|||||||
<div className="mc">
|
<div className="mc">
|
||||||
{liveLoading && (
|
{liveLoading && (
|
||||||
<div className="mc-banner mc-banner-info">
|
<div className="mc-banner mc-banner-info">
|
||||||
<span className="spin" /> Fetching live scenarios from demo.flow-master.ai…
|
<span className="spin" /> Fetching live data from EA2…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{liveError && (
|
{liveError && (
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ export default function Wizard() {
|
|||||||
<li key={n._key || i} className="wizard-preview-chain-item">
|
<li key={n._key || i} className="wizard-preview-chain-item">
|
||||||
<span className="wizard-preview-chain-idx">{i + 1}</span>
|
<span className="wizard-preview-chain-idx">{i + 1}</span>
|
||||||
<span className="wizard-preview-chain-name">{n.display_name || "Untitled step"}</span>
|
<span className="wizard-preview-chain-name">{n.display_name || "Untitled step"}</span>
|
||||||
<span className={`wizard-preview-chain-kind kind-${n.dispatch_kind || "human"}`}>{n.dispatch_kind || "human"}</span>
|
<span className={`wizard-preview-chain-kind kind-${n.dispatch_kind || "human"}`}>{({ human: "Person", agent: "Assistant", system: "System" } as Record<string, string>)[n.dispatch_kind || "human"] || "Person"}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -412,8 +412,8 @@ export default function Wizard() {
|
|||||||
updateDraft({ nodes: newNodes });
|
updateDraft({ nodes: newNodes });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="human">Human</option>
|
<option value="human">Person</option>
|
||||||
<option value="agent">Agent</option>
|
<option value="agent">Assistant</option>
|
||||||
<option value="system">System</option>
|
<option value="system">System</option>
|
||||||
</select>
|
</select>
|
||||||
<div className="node-controls">
|
<div className="node-controls">
|
||||||
|
|||||||
+1
-1
@@ -200,7 +200,7 @@ export const useApp = create<AppState>((set, get) => {
|
|||||||
if (get().mode === "snapshot") return;
|
if (get().mode === "snapshot") return;
|
||||||
set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
|
set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
|
||||||
get().stopPolling();
|
get().stopPolling();
|
||||||
get().pushToast("info", "Switched to snapshot mode (bundled JSON)");
|
get().pushToast("info", "Switched to snapshot mode");
|
||||||
persist();
|
persist();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user