{d.error}
+ {JSON.stringify(s.args, null, 2)}
+ :
+ const m = part.match(/^(Err[A-Za-z0-9]+):\s*([\s\S]*)$/);
+ if (!m) return { code: "", message: part };
+ const code = m[1];
+ let rest = m[2];
+ // Pull the " -> ()" detail block off the end, if present.
+ let details = null;
+ const arrowIdx = rest.lastIndexOf(" -> (");
+ if (arrowIdx > -1) {
+ const inner = rest.slice(arrowIdx + 5); // skip ` -> (`
+ let depth = 1, end = -1;
+ for (let i = 0; i < inner.length; i++) {
+ const c = inner[i];
+ if (c === "(") depth++;
+ else if (c === ")") { depth--; if (depth === 0) { end = i; break; } }
+ }
+ if (end > -1) {
+ const jsonText = inner.slice(0, end);
+ try { details = JSON.parse(jsonText); }
+ catch { details = jsonText; }
+ rest = rest.slice(0, arrowIdx).trim();
+ }
+ }
+ let message = rest.trim();
+ if (!message) message = "—";
+ return { code, message, details };
+}
+
+function ErrorDetailPopup({ parsed, raw, onClose, pos }) {
+ // Portaled to document.body so the popup ALWAYS sits on top of the
+ // drawer / panels / overlays — being inside the drawer DOM put it
+ // under the drawer's own stacking context, which masked the
+ // z-index: 9999.
+ React.useEffect(() => {
+ function onKey(e) { if (e.key === "Escape") onClose(); }
+ function onClick(e) {
+ const el = document.querySelector(".lc-err-popup");
+ if (el && !el.contains(e.target)) onClose();
+ }
+ window.addEventListener("keydown", onKey);
+ setTimeout(() => window.addEventListener("click", onClick), 0);
+ return () => {
+ window.removeEventListener("keydown", onKey);
+ window.removeEventListener("click", onClick);
+ };
+ }, [onClose]);
+ const style = { top: pos.top, right: pos.right };
+ const popup = (
+ e.stopPropagation()}>
+
+ error chain
+ {countDepth(parsed)} {countDepth(parsed) === 1 ? "level" : "levels"}
+
+
+
+
+
+
+
+ raw
+ {raw}
+
+
+ );
+ return ReactDOM.createPortal(popup, document.body);
+}
+
+function countDepth(node) {
+ let n = 0;
+ for (let c = node; c; c = c.cause) n++;
+ return n;
+}
+
+function ErrorNodeView({ node, depth }) {
+ if (!node) return null;
+ return (
+
+
+ {node.code || "Error"}
+ {node.message}
+
+ {node.details && (
+
+ {typeof node.details === "string" ? node.details : JSON.stringify(node.details, null, 2)}
+
+ )}
+ {node.cause && }
+
+ );
+}
+
+// FinalResponseSection renders the response (or error) the CLIENT
+// received from `net.Forward`. For successful requests we pretty-print
+// the JSON body; for failures we run the error string through the same
+// parseErpcError + structured-popup machinery we already use for
+// per-attempt errors. This is the section that answers "the request
+// shows outcome:fail but every attempt was won — what actually
+// happened?" — typically a consensus dispute where individual
+// upstreams succeeded but disagreed on the data.
+function FinalResponseSection({ req }) {
+ const [open, setOpen] = React.useState(false);
+ if (req.requestError) {
+ return (
+
+ final error · what the client saw
+
+
+ );
+ }
+ // Success — render a pretty-printed JSON-RPC response.
+ let parsed = null;
+ try { parsed = JSON.parse(req.responseBody); } catch { /* leave null */ }
+ const pretty = parsed ? JSON.stringify(parsed, null, 2) : req.responseBody;
+ const isLong = pretty.length > 320;
+ return (
+
+
+ final response · what the client received
+ {isLong && (
+
+ )}
+
+
+ {isLong && !open ? pretty.slice(0, 320) + "…" : pretty}
+
+
+ );
+}
+
+// FinalErrorView renders the error chain the same way per-attempt
+// errors do, so the visual language is consistent: top-level code
+// chip, message, expandable popup with the full caused-by tree.
+function FinalErrorView({ raw }) {
+ const [popup, setPopup] = React.useState(null);
+ const btnRef = React.useRef(null);
+ const parsed = parseErpcError(raw);
+ function toggle(e) {
+ e.stopPropagation();
+ if (popup) { setPopup(null); return; }
+ const r = btnRef.current?.getBoundingClientRect();
+ if (!r) return;
+ setPopup({
+ top: r.bottom + 6,
+ right: Math.max(16, window.innerWidth - r.right - 4),
+ });
+ }
+ return (
+
+
+ {parsed.code || "Error"}
+ {parsed.message}
+
+
+ {parsed.details && (
+
+ {typeof parsed.details === "string" ? parsed.details : JSON.stringify(parsed.details, null, 2)}
+
+ )}
+ {popup && (
+ setPopup(null)} pos={popup} />
+ )}
+
+ );
+}
+
+// buildLifecycleRows interleaves "gap" rows between attempts so the
+// drawer surfaces WHY each attempt started when it did. Two distinct
+// cases:
+//
+// * Sequential gap (next.t0 ≥ previous.end):
+// the executor was sleeping between attempts — failsafe's
+// retry-delay + jitter + backoffFactor. Rendered as a
+// dashed "⏱ retry backoff · Xms" row.
+//
+// * Parallel overlap (next.t0 < previous.end AND > previous.t0):
+// a hedge was fan-out from the network executor at +hedgeDelay
+// while the previous attempt was still in flight. Rendered as a
+// "⚡ hedge fan-out · Xms after primary" row, immediately before
+// the hedge attempt. The hedge attempt ALSO gets an inline
+// "⚡ hedge" chip so it's clear that row was concurrent with the
+// primary, not a sequential retry.
+//
+// Attempts are sorted by `t0` ascending so the timeline reads
+// chronologically top-to-bottom (primary first, hedge after, the
+// possible second-sweep retries last).
+function buildLifecycleRows(attempts) {
+ if (!attempts || attempts.length === 0) return [];
+ const sorted = [...attempts].sort((a, b) => a.t0 - b.t0);
+ const rows = [];
+ let prevEnd = -1;
+ let prevT0 = -1;
+ sorted.forEach((a, i) => {
+ if (i > 0) {
+ if (a.t0 >= prevEnd) {
+ // Sequential gap = retry backoff time.
+ const gap = a.t0 - prevEnd;
+ if (gap > 3) {
+ rows.push({ kind: "gap", t0: prevEnd, dur: gap, label: "retry backoff", icon: "⏱" });
+ }
+ } else if (a.t0 > prevT0) {
+ // Parallel overlap = hedge fired this many ms after primary.
+ const hedgeAt = a.t0;
+ rows.push({
+ kind: "gap",
+ t0: prevT0,
+ dur: hedgeAt - prevT0,
+ label: "hedge fan-out",
+ icon: "⚡",
+ isHedge: true,
+ });
+ }
+ }
+ const isHedgeParallel = i > 0 && a.t0 < prevEnd;
+ rows.push({ kind: "attempt", a, isHedgeParallel });
+ prevT0 = a.t0;
+ if (a.t0 + a.dur > prevEnd) prevEnd = a.t0 + a.dur;
+ });
+ return rows;
+}
+
+// ============== Drawer ==============
+function EventDrawer({ req, onClose }) {
+ // Persisted resizable width.
+ const [drawerW, setDrawerW] = React.useState(() => {
+ try {
+ const v = parseInt(localStorage.getItem("erpc-sim-drawer-w") || "");
+ return isFinite(v) && v > 320 ? v : 460;
+ } catch { return 460; }
+ });
+ React.useEffect(() => {
+ try { localStorage.setItem("erpc-sim-drawer-w", String(drawerW)); } catch (_) {}
+ }, [drawerW]);
+ function onResizeStart(e) {
+ e.preventDefault();
+ document.body.classList.add("dragging-split", "col-resize");
+ const startX = e.clientX;
+ const startW = drawerW;
+ function move(ev) {
+ // Drawer is anchored to the right edge of the viewport. Dragging
+ // its left handle to the LEFT increases the drawer width.
+ const newW = Math.max(320, Math.min(window.innerWidth - 80, startW + (startX - ev.clientX)));
+ setDrawerW(newW);
+ }
+ function up() {
+ document.body.classList.remove("dragging-split", "col-resize");
+ window.removeEventListener("mousemove", move);
+ window.removeEventListener("mouseup", up);
+ }
+ window.addEventListener("mousemove", move);
+ window.addEventListener("mouseup", up);
+ }
+
+ if (!req) return ;
+ const maxDur = Math.max(...(req.attempts || []).map(a => a.t0 + a.dur), req.duration);
+ // Consensus end-state — derived once for the whole drawer. Used by
+ // per-attempt rows (to relabel "won" → "responded" / etc. when the
+ // aggregator rejected everyone) and by the synthesized "consensus
+ // result" row appended after the last attempt.
+ const csState = (() => {
+ if ((req.consensusSlots || 0) === 0) return null;
+ if (req.outcome !== "fail") return "agreed";
+ const err = req.requestError || "";
+ if (err.includes("ErrConsensusDispute")) return "disputed";
+ if (err.includes("ErrConsensusLowParticipants")) return "low-participants";
+ return "failed";
+ })();
+ return (
+ <>
+
+
+ {/* Left-edge resizer (dragging makes the drawer wider/narrower). */}
+
+
+ req #{req.id}
+ {req.method}
+
+
+
+
+
+
+ outcome{req.outcome}
+ winner{req.winner || "—"}
+ duration{req.duration.toFixed(0)}ms
+ attempts{(req.attempts || []).length}
+ {req.usedHedge && hedgefired}
+ {req.usedRetry && retryfired}
+ {req.consensusSlots > 0 && (
+
+ consensus{req.consensusSlots} slots
+
+ )}
+ {req.consensusDisputes > 0 && (
+
+ dispute{req.consensusDisputes}
+
+ )}
+ {req.consensusLowParts > 0 && (
+
+ low parts{req.consensusLowParts}
+
+ )}
+
+
+
+ {(req.sel || []).length > 0 && (
+
+ selection trail · evalFunc returned
+
+ {req.sel.map((s, i) => {
+ const winner = s.id === req.winner;
+ const cls = winner ? "winner" : s.excluded ? "excluded" : "";
+ return (
+
+ {s.excluded ? "—" : s.idx}
+ {s.id}
+ {s.excluded ? `excluded · ${s.reason}` : `score ${s.score?.toFixed(3)}`}
+
+ );
+ })}
+
+
+ )}
+
+ {/* Final response / error — what the CLIENT actually saw.
+ For failed requests this surfaces things invisible from
+ per-attempt rows (e.g. consensus dispute where all
+ participants ok'd but disagreed on data). For successful
+ requests it shows the actual JSON-RPC result body. */}
+ {(req.requestError || req.responseBody) && (
+
+ )}
+
+
+ lifecycle
+
+ {buildLifecycleRows(req.attempts || []).map((row, i) => {
+ if (row.kind === "gap") {
+ // Sequential retry-backoff (dashed line) vs parallel
+ // fan-out. When consensus is active, the parallel
+ // fan-out IS the consensus spawning its N participants
+ // — relabel the row so it's not confused with hedge.
+ const offsetPct = (row.t0 / maxDur) * 100;
+ const widthPct = (row.dur / maxDur) * 100;
+ const inConsensus = (req.consensusSlots || 0) > 0;
+ const cls = row.isHedge
+ ? (inConsensus ? "lc-gap consensus" : "lc-gap hedge")
+ : "lc-gap";
+ const icon = row.isHedge
+ ? (inConsensus ? "⊕" : "⚡")
+ : "⏱";
+ const labelMain = row.isHedge
+ ? (inConsensus ? "consensus slot spawned" : "hedge fired")
+ : "wait";
+ const labelDesc = row.isHedge
+ ? (inConsensus ? "consensus fan-out" : row.label)
+ : row.label;
+ return (
+
+
+ {icon} {labelMain}
+ {labelDesc}
+ +{row.dur.toFixed(0)}ms after primary
+
+
+
+
+
+ );
+ }
+ const a = row.a;
+ // Color semantics (per user spec):
+ // green = winner of any flavor
+ // amber = miss / no data / throttled (bad event,
+ // not an error)
+ // red = upstream error (timeout, fail)
+ // blue = harmless / parallel attempt that lost
+ // its race (hedge-loser, cancelled)
+ //
+ // Consensus override: if this request went through
+ // consensus AND failed at the aggregator (disputed /
+ // low-participants), the per-participant `won` flag
+ // is misleading — none of them actually contributed
+ // to the final response. We render them as amber
+ // ("responded") instead of green ("won"), and add a
+ // synthesized "consensus result" row at the bottom
+ // (see below) showing the aggregator's verdict.
+ let cls;
+ const consensusFailed = csState === "disputed" || csState === "low-participants" || csState === "failed";
+ if (a.winner && consensusFailed) {
+ // amber — they DID respond with data; the aggregator just
+ // rejected the response. The `consensus-loser` modifier
+ // suppresses the "no data · " CSS prefix that .miss would
+ // otherwise prepend (those participants returned data).
+ cls = "miss consensus-loser";
+ } else if (a.winner) {
+ cls = "ok";
+ } else if (a.outcome === "miss" || a.outcome === "throttled") {
+ cls = "miss";
+ } else if (a.outcome === "hedge-loser") {
+ cls = "neutral hedge-loser";
+ } else if (a.outcome === "timeout" ||
+ a.outcome === "fail") {
+ cls = "bad";
+ } else {
+ cls = "neutral";
+ }
+ const widthPct = (a.dur / maxDur) * 100;
+ const offsetPct = (a.t0 / maxDur) * 100;
+ const isHedgeParallel = row.isHedgeParallel;
+ // Cumulative wall-clock at end of this attempt — the
+ // "time so far" the user reads on the RIGHT.
+ const cumulativeMs = a.t0 + a.dur;
+ // `+` = this step's duration contributed sequentially.
+ // `~` = parallel work that didn't add to the request's
+ // total time (typically a hedge participant that
+ // lost; or any attempt running concurrently with
+ // another and not the winner).
+ const sequentialAdd = !isHedgeParallel && !(a.isHedge && !a.winner);
+ const durPrefix = sequentialAdd ? "+" : "~";
+ // Per-attempt selection-reason chip — clarifies WHY this
+ // attempt fired. The decision tree:
+ // 1. If the WHOLE request went through consensus
+ // (req.consensusSlots > 0), any parallel attempt
+ // is a consensus participant — label it as such
+ // even though eRPC's internal SelectionReason may
+ // say "hedge" (because consensus is implemented
+ // on top of the hedge fan-out machinery).
+ // 2. Otherwise fall back to the SelectionReason
+ // field as recorded by the executors.
+ const inConsensus = (req.consensusSlots || 0) > 0;
+ let reasonChip = null;
+ if (a.selReason === "retry" || a.isRetry) {
+ reasonChip = ↻ retry #{a.attemptIdx || i};
+ } else if (inConsensus && (isHedgeParallel || i === 0)) {
+ reasonChip = ⊕ consensus #{i + 1};
+ } else if (a.selReason === "hedge" || isHedgeParallel) {
+ reasonChip = ⚡ hedge;
+ } else if (a.selReason === "consensus_slot") {
+ reasonChip = ⊕ consensus;
+ } else if (a.selReason === "sweep") {
+ reasonChip = ↳ sweep #{a.attemptIdx || 1};
+ } else if (i === 0) {
+ // First attempt in sequence — primary call (no other reason matched).
+ reasonChip = primary;
+ } else {
+ // Position-based fallback: a 2nd+ attempt with no specific
+ // reason from the backend is a sweep-fallback after the
+ // prior upstream failed (e.g. empty result on a sparse
+ // method, transient error). eRPC's executor sometimes
+ // doesn't tag these as `retry` because they happen
+ // within ONE sweep iteration (no NetworkRetries bump),
+ // so this position-heuristic surfaces them as retries
+ // from the operator's point of view.
+ reasonChip = ↻ retry #{i};
+ }
+ return (
+
+
+ {a.id}
+
+ {reasonChip}
+
+
+
+ {durPrefix}{a.dur.toFixed(0)}ms
+ ·
+ {cumulativeMs.toFixed(0)}ms
+
+
+
+
+
+
+ );
+ })}
+ {csState && }
+
+
+
+
+ >
+ );
+}
+
+// ConsensusResultRow is the SYNTHETIC final row showing what the
+// consensus aggregator decided. Without this the lifecycle ends with
+// the last participant marked "won" — confusing because the request
+// actually failed AT the aggregator AFTER all participants responded.
+// Colors per the project spec:
+// green = agreed (consensus reached, request succeeded)
+// red = disputed (no agreement, returnError fired)
+// amber = low-participants (didn't meet threshold)
+// red = failed (other aggregator failure)
+function ConsensusResultRow({ req, csState }) {
+ const map = {
+ agreed: { cls: "ok", icon: "✓", label: "consensus agreed", why: `${req.consensusSlots} participants reached agreement` },
+ disputed: { cls: "bad", icon: "✗", label: "consensus DISPUTED", why: `${req.consensusSlots} participants disagreed on the response` },
+ "low-participants":{ cls: "miss", icon: "△", label: "consensus low quorum", why: `fewer than threshold participants responded` },
+ failed: { cls: "bad", icon: "✗", label: "consensus FAILED", why: "aggregator could not resolve" },
+ };
+ const m = map[csState];
+ if (!m) return null;
+ return (
+
+
+ {m.icon} {m.label}
+ {m.why}
+
+ {req.duration.toFixed(0)}ms total
+
+
+
+
+
+
+ );
+}
+
+window.RightCol = RightCol;
+window.EventDrawer = EventDrawer;
diff --git a/cmd/erpc-simulator/web/selection-policy.jsx b/cmd/erpc-simulator/web/selection-policy.jsx
new file mode 100644
index 000000000..196bc48e5
--- /dev/null
+++ b/cmd/erpc-simulator/web/selection-policy.jsx
@@ -0,0 +1,452 @@
+// selection-policy.jsx — JS/TS function editor for the routing policy.
+//
+// Draft + lastApplied live in the sim store (state.policyDraft /
+// state.policyLastApplied) so the AI assistant can read & write them
+// via the same hook surface as the editor.
+//
+// Compilation is ENTIRELY server-side (sobek): typing debounces a
+// `validate-policy` frame; Apply sends `apply-policy`. Both round-trip
+// real compile errors back through the WS shim.
+//
+// History: every successful apply is pushed to a 50-deep LRU stored
+// in localStorage. ⌘/Ctrl-Z / Shift-⌘/Ctrl-Z navigate it; the history
+// menu lists past versions with their timestamp + source.
+
+const { useEffect, useRef, useState } = React;
+
+function SelectionPolicy() {
+ const draft = window.usePolicyDraft();
+ const serverPolicy = window.useServerPolicy();
+ const defaultPolicy = window.useDefaultPolicy();
+ const policyResult = window.usePolicyResult();
+ const policyValidate = window.usePolicyValidate();
+ const policyHistory = window.usePolicyHistory();
+ const policyHistoryIdx = window.usePolicyHistoryIdx();
+ const actions = window.useSimActions();
+ const perSec = window.usePerSecond();
+ const upstreams = window.useUpstreams();
+ const upstreamStats = window.useUpstreamStats();
+
+ const [hint, setHint] = useState(null);
+ const [historyOpen, setHistoryOpen] = useState(false);
+ const [errOpen, setErrOpen] = useState(false);
+ const taRef = useRef(null);
+ const preRef = useRef(null);
+ const gutRef = useRef(null);
+
+ // The displayed source of truth — either the live draft or the
+ // history snapshot the user is browsing.
+ const lastApplied = policyHistory.length > 0
+ ? policyHistory[policyHistory.length - 1].code
+ : (serverPolicy || defaultPolicy || "");
+ const dirty = draft !== lastApplied;
+
+ // Surface error from validate or apply.
+ const error = (policyResult && !policyResult.ok && policyResult.error)
+ || (policyValidate && !policyValidate.ok && policyValidate.error)
+ || null;
+
+ // Surface "applied" toast when policyResult flips to ok and matches
+ // current draft.
+ useEffect(() => {
+ if (policyResult && policyResult.ok && policyResult.compiledAt) {
+ setHint("Applied · " + new Date(policyResult.compiledAt).toLocaleTimeString());
+ const id = setTimeout(() => setHint(null), 2200);
+ return () => clearTimeout(id);
+ }
+ }, [policyResult]);
+
+ // Debounced server-side validate as user types.
+ useEffect(() => {
+ if (!draft || draft === lastApplied) return;
+ const id = setTimeout(() => actions.validatePolicy(draft), 450);
+ return () => clearTimeout(id);
+ }, [draft, lastApplied, actions]);
+
+ // ⌘↵ apply / ⌘Z undo / ⌘⇧Z redo / ⌘/ comment-toggle (when editor focused).
+ useEffect(() => {
+ function onKey(e) {
+ if (taRef.current && document.activeElement !== taRef.current) return;
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
+ e.preventDefault(); actions.applyPolicy(draft); return;
+ }
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
+ e.preventDefault();
+ if (e.shiftKey) actions.redoPolicy();
+ else actions.undoPolicy();
+ return;
+ }
+ if ((e.metaKey || e.ctrlKey) && e.key === "/") {
+ e.preventDefault();
+ toggleCommentOnSelection();
+ return;
+ }
+ }
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [draft, actions]);
+
+ // toggleCommentOnSelection — Cmd+/ behavior modeled on VS Code:
+ // 1. Find the line range covered by the textarea's current
+ // selection (or just the caret line if no selection).
+ // 2. If EVERY non-blank line in the range is already commented
+ // (starts with `// ` after its indent), strip the comment.
+ // 3. Otherwise add `// ` after the minimum indent shared by all
+ // non-blank lines in the range — keeps the block visually
+ // aligned when toggling indented code.
+ // The selection is preserved across the toggle so chained ⌘/ presses
+ // expand/contract the same block consistently.
+ function toggleCommentOnSelection() {
+ const ta = taRef.current;
+ if (!ta) return;
+ const value = ta.value;
+ const selStart = ta.selectionStart;
+ const selEnd = ta.selectionEnd;
+
+ // Expand the selection to whole lines.
+ const lineStart = value.lastIndexOf("\n", selStart - 1) + 1;
+ let lineEnd = value.indexOf("\n", selEnd);
+ if (lineEnd < 0) lineEnd = value.length;
+
+ const block = value.slice(lineStart, lineEnd);
+ const lines = block.split("\n");
+
+ // Min indent across non-blank lines — the comment prefix is
+ // inserted at this column so the toggle stays visually aligned.
+ let minIndent = Infinity;
+ for (const ln of lines) {
+ if (ln.trim() === "") continue;
+ const m = ln.match(/^[ \t]*/);
+ const w = m ? m[0].length : 0;
+ if (w < minIndent) minIndent = w;
+ }
+ if (!isFinite(minIndent)) minIndent = 0;
+
+ // Are ALL non-blank lines already commented at minIndent?
+ const allCommented = lines.every(ln => {
+ if (ln.trim() === "") return true;
+ const rest = ln.slice(minIndent);
+ return rest.startsWith("// ") || rest.startsWith("//");
+ });
+
+ let delta = 0; // net char-count change for selection adjustment
+ const updated = lines.map(ln => {
+ if (ln.trim() === "") return ln;
+ if (allCommented) {
+ // Strip `// ` or `//` after the shared indent.
+ const head = ln.slice(0, minIndent);
+ let tail = ln.slice(minIndent);
+ if (tail.startsWith("// ")) { tail = tail.slice(3); delta -= 3; }
+ else if (tail.startsWith("//")) { tail = tail.slice(2); delta -= 2; }
+ return head + tail;
+ } else {
+ delta += 3;
+ return ln.slice(0, minIndent) + "// " + ln.slice(minIndent);
+ }
+ }).join("\n");
+
+ const newValue = value.slice(0, lineStart) + updated + value.slice(lineEnd);
+ actions.setPolicyDraft(newValue);
+ // Restore selection — adjust by per-line delta. We approximate by
+ // shifting both ends by the average delta-per-line × lines-before.
+ // For most cases (single line, or block of similar lines) this
+ // lands the caret/selection where the user expects.
+ const perLine = lines.length > 0 ? Math.round(delta / lines.length) : 0;
+ requestAnimationFrame(() => {
+ const ta2 = taRef.current;
+ if (!ta2) return;
+ const newStart = selStart + (selStart === lineStart ? 0 : perLine);
+ const newEnd = selEnd + delta;
+ ta2.setSelectionRange(Math.max(lineStart, newStart), Math.max(newStart, newEnd));
+ });
+ }
+
+ // Reset-to-default flow:
+ // * "↺ default" — preview the default in the draft. Non-destructive;
+ // user still must hit Apply (or ⌘↵) to commit.
+ // * "↺ reset & apply" — set draft AND immediately apply. Skips the
+ // preview step for the common "I broke it, give
+ // me the defaults back NOW" case.
+ function resetToDefaultDraft() {
+ actions.setPolicyDraft(defaultPolicy);
+ }
+ function resetAndApply() {
+ actions.setPolicyDraft(defaultPolicy);
+ actions.applyPolicy(defaultPolicy);
+ }
+
+ // Syntax highlight (tokenized — see comment above the loop).
+ //
+ // History note: an earlier chained-regex implementation wrapped strings
+ // with `…` and then ran a keyword regex
+ // that included `class` — which matched the literal `class` attribute
+ // INSIDE the span tag we'd just inserted, producing busted markup like
+ // `class="tk-str">…`
+ // which the browser renders as the raw attribute text. That's the
+ // `class="tk-str">'!tier:fallback'` glitch users reported.
+ //
+ // The robust fix: split the line into [text, str, text, str, …]
+ // segments FIRST, run keyword/number/prop regexes ONLY on the text
+ // segments, then wrap each str segment wholesale at the end. The
+ // keyword regex literally cannot see the string-span markup, so the
+ // bug class is eliminated by construction.
+ function highlight(line) {
+ if (line.match(/^\s*\/\//)) return `${esc(line) || " "}`;
+ const escLine = esc(line);
+ const strRe = /("[^&]*?"|'[^']*?'|`[^`]*?`)/g;
+ const segs = [];
+ let lastEnd = 0;
+ let m;
+ while ((m = strRe.exec(escLine)) !== null) {
+ if (m.index > lastEnd) segs.push({ k: "t", v: escLine.slice(lastEnd, m.index) });
+ segs.push({ k: "s", v: m[0] });
+ lastEnd = m.index + m[0].length;
+ }
+ if (lastEnd < escLine.length) segs.push({ k: "t", v: escLine.slice(lastEnd) });
+ const html = segs.map(seg => {
+ if (seg.k === "s") return `${seg.v}`;
+ let s = seg.v;
+ s = s.replace(/\b(const|let|var|function|return|if|else|for|while|do|break|continue|new|in|of|typeof|instanceof|true|false|null|undefined|this|throw|try|catch|finally|class|extends|interface|type|as|export|import|from|async|await)\b/g, '$1');
+ s = s.replace(/(?$1');
+ s = s.replace(/\.([a-zA-Z_]\w*)\b/g, '.$1');
+ return s;
+ }).join("");
+ return html || " ";
+ }
+ function esc(t) { return t.replace(/[&<>"]/g, c => ({ "&":"&","<":"<",">":">","\"":""" }[c])); }
+
+ function onScroll(e) {
+ if (preRef.current) {
+ preRef.current.scrollTop = e.target.scrollTop;
+ preRef.current.scrollLeft = e.target.scrollLeft;
+ }
+ if (gutRef.current) gutRef.current.scrollTop = e.target.scrollTop;
+ }
+
+ const lines = (draft || "").split("\n");
+
+ return (
+
+
+
+ signature
+ (upstreams, ctx) => Upstream[]
+
+
+
+
+
+
+
+
+
+ {historyOpen && (
+
+ {policyHistory.length === 0 ? (
+ no edits yet
+ ) : (
+
+ {policyHistory.slice().reverse().map((h, i) => {
+ const idx = policyHistory.length - 1 - i;
+ const active = policyHistoryIdx === idx || (policyHistoryIdx < 0 && idx === policyHistory.length - 1);
+ return (
+ { actions.gotoPolicyHistory(idx); setHistoryOpen(false); }}>
+ {new Date(h.ts).toLocaleTimeString()}
+ {h.source}
+ {(h.code || "").replace(/\s+/g, " ").slice(0, 80)}
+
+ );
+ })}
+
+ )}
+
+ )}
+
+
+
+
+ {lines.map((_, i) => {i + 1})}
+
+
+
+
+
+
+
+
+
+
+ {error ? (
+
+ ) : (
+ {dirty ? "validated · awaiting apply" : "in use"}
+ )}
+
+ {hint && {hint}}
+ {dirty && !hint && modified}
+
+
+
+ );
+}
+
+// ErrorChip is the footer compile-error indicator. Click to toggle a
+// floating popup with the full error text — the inline chip is short
+// (~80 chars) so it fits next to Apply, the popup shows everything
+// (multi-line, scrollable). Portals the popup to so it isn't
+// clipped by the editor pane's `overflow:hidden` parents.
+function ErrorChip({ error, open, setOpen }) {
+ const anchorRef = useRef(null);
+ const [pos, setPos] = useState(null);
+ useEffect(() => {
+ if (!open) { setPos(null); return; }
+ const r = anchorRef.current?.getBoundingClientRect();
+ if (!r) return;
+ setPos({ left: r.left, bottom: window.innerHeight - r.top + 6 });
+ }, [open, error]);
+ // Close on outside-click or Esc.
+ useEffect(() => {
+ if (!open) return;
+ function onDoc(e) {
+ if (anchorRef.current && anchorRef.current.contains(e.target)) return;
+ if (e.target.closest && e.target.closest(".sp-err-popup")) return;
+ setOpen(false);
+ }
+ function onKey(e) { if (e.key === "Escape") setOpen(false); }
+ document.addEventListener("mousedown", onDoc);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("mousedown", onDoc);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [open, setOpen]);
+ const full = String(error || "");
+ // The chip shows the FIRST error line — that's almost always the
+ // useful summary (SyntaxError: …:Line N:M Unexpected …). Anything
+ // past the first newline goes into the popup.
+ const firstLine = full.split("\n", 1)[0];
+ return (
+ <>
+
+ {open && pos && ReactDOM.createPortal(
+
+
+ compile error · full detail
+
+
+ {full}
+ ,
+ document.body,
+ )}
+ >
+ );
+}
+
+window.SelectionPolicy = SelectionPolicy;
diff --git a/cmd/erpc-simulator/web/sim-context.jsx b/cmd/erpc-simulator/web/sim-context.jsx
new file mode 100644
index 000000000..36b61a8cd
--- /dev/null
+++ b/cmd/erpc-simulator/web/sim-context.jsx
@@ -0,0 +1,163 @@
+// ============================================================
+// SimContext — the React surface over the runtime store.
+//
+// → owns a single runtime instance, drives
+// the traffic-gen tick, exposes context.
+// useSim() → raw runtime (rarely needed).
+// useSimState(selector?) → subscribes to state via
+// useSyncExternalStore. With no selector
+// returns the full state. With a selector
+// returns just that slice (re-renders only
+// when the selector result changes by
+// Object.is).
+// useSimActions() → stable bag of mutation functions
+// (applyConfig, patchUpstream, …).
+//
+// Components NEVER reach into window.* — every read/write goes through
+// the context. The runtime keeps a small `window.eRPCSimRuntime` export
+// only so the inline