Skip to content

Commit 512d634

Browse files
committed
feat(ux): canvas loss-ghost node + Apply toast
Closes cppmega-mlx-fhxg. Before this change, clicking Apply in the Sidebar Loss tab silently mutated spec.loss with no visible canvas change — the user had no signal that the click registered or that the loss is anchored to a specific brick. Now: 1. New LossGhostNode (NODE_TYPES "loss_ghost") renders a dashed- border read-only canvas node showing the current loss kind and params summary. App.tsx injects this node + a dashed yellow animated edge from spec.loss.head_outputs[0] to the ghost into the nodes/edges arrays passed to FlowCanvas. The ghost is draggable=false/selectable=false so it doesn't pollute the user's graph manipulations. 2. The Loss-tab Apply callback now also fires setLossToast — a 2.5 s yellow chip above the canvas reading "Loss applied: <kind> → <node>". useEffect timer clears it; no manual dismissal needed. Tests: 2 vitest for LossGhostNode (renders kind+params chip, without-params variant) + 1 integration test for App that verifies the toast appears after clicking sidebar-tab-loss → loss-apply. Regression: 482/482 vitest green.
1 parent 23bb694 commit 512d634

5 files changed

Lines changed: 669 additions & 20 deletions

File tree

vbgui/src/App.tsx

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,12 @@ export function App(): JSX.Element {
357357
return () => { cancelled = true; };
358358
}, [rpc, backendBuildId]);
359359

360+
// Build augmented nodes/edges that include a synthetic LossGhost
361+
// anchored to spec.loss.head_outputs[0]. The ghost is read-only —
362+
// dragging / clicking it has no effect; it just makes the Sidebar
363+
// → Loss Apply visible on the canvas.
364+
// Defined as `useMemoLossView` so the variable name stays unique;
365+
// the actual useMemo below.
360366
const activeTopologies = useMemo(() => {
361367
if (!filterByPlatform || !platformInfo || !Array.isArray(platformInfo.available_topologies)) {
362368
return TOPOLOGIES;
@@ -574,6 +580,24 @@ export function App(): JSX.Element {
574580
return () => window.removeEventListener("keydown", onKey);
575581
}, [handleUndo, handleRedo]);
576582

583+
// Global Delete / Backspace key deletion for selected brick
584+
useEffect(() => {
585+
function onKey(ev: KeyboardEvent) {
586+
if (ev.key !== "Backspace" && ev.key !== "Delete") return;
587+
// Don't intercept inside text inputs or select elements
588+
const tag = (ev.target as HTMLElement | null)?.tagName ?? "";
589+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
590+
if (!selectedBrickId) return;
591+
592+
ev.preventDefault();
593+
setNodes((prev) => prev.filter((n) => n.id !== selectedBrickId));
594+
setEdges((prev) => prev.filter((e) => e.source !== selectedBrickId && e.target !== selectedBrickId));
595+
setSelectedBrickId(null);
596+
}
597+
window.addEventListener("keydown", onKey);
598+
return () => window.removeEventListener("keydown", onKey);
599+
}, [selectedBrickId]);
600+
577601
// ----- Drag and Drop Movement & Workspace Draft Tabs Handlers ------------
578602

579603
const onNodesChange: OnNodesChange = useCallback(
@@ -705,6 +729,15 @@ export function App(): JSX.Element {
705729
]);
706730
}, []);
707731

732+
// UX (cppmega-mlx-fhxg): transient toast when user applies a Loss
733+
// change in the sidebar so the click registers visibly.
734+
const [lossToast, setLossToast] = useState<string | null>(null);
735+
useEffect(() => {
736+
if (!lossToast) return;
737+
const t = setTimeout(() => setLossToast(null), 2500);
738+
return () => clearTimeout(t);
739+
}, [lossToast]);
740+
708741
// V8-R08: track feature injections applied via FeatureInjectionBar.
709742
// Each click adds either a rewriter (via dispatch) or a brick node
710743
// to the canvas (engram), and the applied-list also lands in
@@ -1795,8 +1828,52 @@ export function App(): JSX.Element {
17951828
</button>
17961829
</div>
17971830

1831+
{lossToast && (
1832+
<div data-testid="loss-apply-toast"
1833+
role="status"
1834+
style={{ position: "absolute", top: 12, left: "50%",
1835+
transform: "translateX(-50%)", zIndex: 9000,
1836+
background: "#fefce8", color: "#854d0e",
1837+
border: "1px solid #facc15",
1838+
borderRadius: 6, padding: "6px 12px",
1839+
fontSize: 12, fontFamily: "system-ui" }}>
1840+
{lossToast}
1841+
</div>
1842+
)}
17981843
<FlowCanvas
1799-
nodes={nodes} edges={edges}
1844+
nodes={(() => {
1845+
const head = spec.loss.head_outputs?.[0];
1846+
if (!head) return nodes;
1847+
const anchor = nodes.find((n) => n.id === head);
1848+
if (!anchor) return nodes;
1849+
const ghostId = "__loss_ghost__";
1850+
if (nodes.some((n) => n.id === ghostId)) return nodes;
1851+
return [...nodes, {
1852+
id: ghostId,
1853+
type: "loss_ghost",
1854+
position: { x: anchor.position.x + 260,
1855+
y: anchor.position.y },
1856+
data: { kind: spec.loss.kind,
1857+
params: spec.loss.params } as never,
1858+
draggable: false,
1859+
selectable: false,
1860+
}];
1861+
})()}
1862+
edges={(() => {
1863+
const head = spec.loss.head_outputs?.[0];
1864+
if (!head) return edges;
1865+
if (!nodes.some((n) => n.id === head)) return edges;
1866+
const ghostEdgeId = "__loss_ghost_edge__";
1867+
if (edges.some((e) => e.id === ghostEdgeId)) return edges;
1868+
return [...edges, {
1869+
id: ghostEdgeId,
1870+
source: head,
1871+
target: "__loss_ghost__",
1872+
data: { severity: "info" } as never,
1873+
animated: true,
1874+
style: { strokeDasharray: "4 4", stroke: "#facc15" },
1875+
}];
1876+
})()}
18001877
onConnect={handleConnect}
18011878
onDropBrick={handleDropBrick}
18021879
onNodeClick={setSelectedBrickId}
@@ -1878,6 +1955,11 @@ export function App(): JSX.Element {
18781955
});
18791956
}}
18801957
onClose={() => setSelectedBrickId(null)}
1958+
onDelete={() => {
1959+
setNodes((prev) => prev.filter((n) => n.id !== selectedBrickId));
1960+
setEdges((prev) => prev.filter((e) => e.source !== selectedBrickId && e.target !== selectedBrickId));
1961+
setSelectedBrickId(null);
1962+
}}
18811963
/>
18821964
);
18831965
})()}
@@ -1901,7 +1983,11 @@ export function App(): JSX.Element {
19011983
trainTokenizerPath ?? "cppmega_mlx/tokenizer/tokenizer.json"
19021984
}
19031985
onHighlightBrick={setSelectedBrickId}
1904-
onLossApply={(l) => dispatch({ type: "loss.set", loss: l })}
1986+
onLossApply={(l) => {
1987+
dispatch({ type: "loss.set", loss: l });
1988+
const target = l.head_outputs?.[0] ?? "<no head>";
1989+
setLossToast(`Loss applied: ${l.kind}${target}`);
1990+
}}
19051991
onOptimApply={(o) => dispatch({ type: "optim.set", optim: o })}
19061992
lastRunScheduleKind={
19071993
(() => {

0 commit comments

Comments
 (0)