Merge local Modal placement warning styling

This commit is contained in:
Ken Simpson
2026-08-19 12:30:27 -07:00
5 changed files with 170 additions and 62 deletions
+2 -2
View File
@@ -199,10 +199,10 @@ The frontend shows remote state directly on the canvas:
- pulsing purple border: executing remotely
- steady green border: finished for the current run
- red border on a remote node: queue-time or execution failure
- red border with a `!` badge on a local node: the planner found a remote-to-local-to-remote path through that node, so the local re-entry may force extra component splits and data transfers
- neutral `!` badge on an otherwise normally styled local node: the planner found a remote-to-local-to-remote path through that node, so the local re-entry may force extra component splits and data transfers
- numbered badge: remote component assignment for the current prompt
These state colours work with both the legacy LiteGraph renderer and ComfyUI's experimental Nodes 2.0 renderer. The legacy renderer uses its canvas foreground hook; Nodes 2.0 receives an equivalent DOM border, glow, fill, and component badge on each `.lg-node`, driven by the same palette and throttled animation state. Local re-entry warnings cover every local node in a path between remote regions, including multi-node local chains. They refresh when the planner analyzes a changed remote selection or queues a prompt, and clear immediately when you change a `Run on Modal` flag so stale advice is not left on the canvas.
These state colours work with both the legacy LiteGraph renderer and ComfyUI's experimental Nodes 2.0 renderer. The legacy renderer uses its canvas foreground hook; Nodes 2.0 receives an equivalent DOM border, glow, fill, and component badge on each `.lg-node`, driven by the same palette and throttled animation state. Local re-entry warnings cover every local node in a path between remote regions, including multi-node local chains. They preserve the node's normal theme and add only a neutral `!` badge; hovering the badge asks whether the node was intended to run on Modal. The warnings refresh when the planner analyzes a changed remote selection or queues a prompt, and clear immediately when you change a `Run on Modal` flag so stale advice is not left on the canvas.
Remote sampler-style progress is rendered in a small temporary panel near the node. Each active progress bar includes a smoothed iterations-per-second (`it/s`) rate derived from successive streamed progress samples; LLM token streams use engine-reported `tok/s`, show token count and TTFT, and reuse the header for compact startup-stage text. Parallel mapped lanes report their rates independently. Static progress redraws happen only when progress events arrive, while indeterminate LLM work, pulsing node phases, setup lane placeholders, and short fade-outs use a throttled canvas animation loop. Preview images and ComfyUI UI payloads emitted by remote nodes are streamed back into the local PromptServer while the remote component is still running. When the browser regains focus, Modal-Sync replays recent UI events and reconciles them against ComfyUI queue/history state so cancelled or completed prompts do not leave stale progress bars behind.
+1 -1
View File
@@ -36,7 +36,7 @@
## 2026-08-14
- A costly local re-entry is a graph property, not just a single-edge pattern. The planner can identify every affected local node by intersecting two local-only closures: nodes reachable downstream from any remote node and nodes that can reach any remote node upstream. This catches both one-node and multi-node remote-to-local-to-remote paths while excluding local preview branches that never re-enter remote execution.
- Planner advice should remain visually distinct from execution failure even when both use red. A static red border and `!` badge on local nodes communicates a potential component-splitting bottleneck, while remote failure keeps its existing red execution-state styling and remote nodes always take precedence if a warning becomes stale.
- Planner advice must not reuse execution-failure colors. A local node between remote regions should keep its native theme and show only a neutral `!` badge whose hover text explains the possible placement mistake; reserve red node styling for actual execution failures.
## 2026-08-13
+42
View File
@@ -24,6 +24,7 @@ globalThis.__modalApiStub = {
globalThis.requestAnimationFrame = () => 1;
globalThis.cancelAnimationFrame = () => {};
globalThis.performance = { now: () => 0 };
globalThis.LiteGraph = { NODE_TITLE_HEIGHT: 24 };
globalThis.CustomEvent = class CustomEvent {
constructor(type, init = {}) {
this.type = type;
@@ -53,6 +54,8 @@ const transformedSource = `${[
" extractRemoteNodeIds,",
" setSandwichedLocalNodeIds,",
" isSandwichedLocalNode,",
" localBottleneckBadgeContainsPoint,",
" decorateNode,",
" setAllEligibleWorkflowNodesRemote,",
" selectedModalGpu,",
" setSelectedModalGpu,",
@@ -106,6 +109,45 @@ assert.equal(modalToggle.isSandwichedLocalNode(localBottleneckNode), false);
modalToggle.setRemoteFlag(localBottleneckNode, false);
assert.equal(modalToggle.modalSandwichedLocalNodeIds.size, 0);
resetFrontendState();
const legacyCanvasElement = {
title: "",
removeAttribute(name) {
if (name === "title") {
this.title = "";
}
},
};
const legacyGraphCanvas = {
canvas: legacyCanvasElement,
ds: { scale: 1 },
};
const legacyBottleneckNode = {
id: 175,
comfyClass: "ModalLLM",
properties: {},
addWidget() {
return {};
},
};
modalToggle.setSandwichedLocalNodeIds([175]);
assert.equal(
modalToggle.localBottleneckBadgeContainsPoint(legacyBottleneckNode, [10, -14], 1),
true,
);
assert.equal(
modalToggle.localBottleneckBadgeContainsPoint(legacyBottleneckNode, [40, 40], 1),
false,
);
modalToggle.decorateNode(legacyBottleneckNode);
legacyBottleneckNode.onMouseMove({}, [10, -14], legacyGraphCanvas);
assert.equal(
legacyCanvasElement.title,
"Did you mean to make this node execute on Modal?",
);
legacyBottleneckNode.onMouseMove({}, [40, 40], legacyGraphCanvas);
assert.equal(legacyCanvasElement.title, "");
resetFrontendState();
modalToggle.registerPromptComponents("prompt-a", ["10", "11", "12"], [
{
+23 -6
View File
@@ -167,12 +167,12 @@ def test_nodes_2_0_dom_nodes_receive_remote_state_decorations() -> None:
assert "function syncVueNodeDecorations(timestamp = performance.now())" in source
assert 'document.querySelectorAll(".lg-node[data-node-id]")' in source
assert 'nodeElement.dataset.modalPhase = phase;' in source
assert 'decoration.style.borderColor = palette.borderColor;' in source
assert 'decoration.style.borderColor = palette?.borderColor ?? "transparent";' in source
assert (
'decoration.style.backgroundColor = palette.fillColor ?? "transparent";'
'decoration.style.backgroundColor = palette?.fillColor ?? "transparent";'
in source
)
assert 'decoration.style.boxShadow = `0 0 8px ${palette.shadowColor}`;' in source
assert 'decoration.style.boxShadow = palette ?' in source
assert 'decoration.className = "comfy-modal-vue-node-decoration";' in source
assert 'badge.className = "comfy-modal-vue-node-badge";' in source
@@ -240,15 +240,32 @@ def test_remote_modal_nodes_show_component_badges() -> None:
assert "ctx.fillText(String(nodeBadgeText), badgeX, badgeY + 0.5 / scale);" in source
def test_planner_highlights_sandwiched_local_nodes() -> None:
"""Queue-time local re-entry warnings should render in both node frontends."""
def test_planner_marks_sandwiched_local_nodes_without_error_styling() -> None:
"""Local re-entry advice should preserve default node colors and explain itself."""
source = _modal_toggle_source()
assert "const modalSandwichedLocalNodeIds = new Set();" in source
assert "function isSandwichedLocalNode(node)" in source
assert "function localBottleneckDecorationPalette()" in source
assert "function localBottleneckDecorationPalette()" not in source
assert "LOCAL_BOTTLENECK_BORDER_COLOR" not in source
assert "LOCAL_BOTTLENECK_FILL_COLOR" not in source
assert "LOCAL_BOTTLENECK_SHADOW_COLOR" not in source
assert 'const palette = localBottleneck ? null :' in source
assert 'decoration.style.borderColor = palette?.borderColor ?? "transparent";' in source
assert 'decoration.style.boxShadow = palette ?' in source
assert 'const phase = localBottleneck ? "local-bottleneck"' in source
assert 'const nodeBadgeText = localBottleneck ? "!"' in source
assert (
'const LOCAL_BOTTLENECK_TOOLTIP = "Did you mean to make this node execute '
'on Modal?";'
in source
)
assert "badge.title = localBottleneck ? LOCAL_BOTTLENECK_TOOLTIP" in source
assert "function localBottleneckBadgeContainsPoint(" in source
assert "updateLegacyBottleneckTooltip(graphCanvas, badgeHovered);" in source
assert 'data-modal-phase="local-bottleneck"' in source
assert "pointer-events: auto;" in source
assert "cursor: help;" in source
assert "responsePayload.modal_sandwiched_local_node_ids" in source
assert "setSandwichedLocalNodeIds(sandwichedLocalNodeIds);" in source
+102 -53
View File
@@ -45,9 +45,8 @@ const COMPLETE_BORDER_COLOR = "#004FA4";
const COMPLETE_FILL_COLOR = "#001C71";
const FINALIZING_NODE_BORDER_COLOR = "#00358A";
const ERROR_BORDER_COLOR = "#ef4444";
const LOCAL_BOTTLENECK_BORDER_COLOR = "#ef4444";
const LOCAL_BOTTLENECK_FILL_COLOR = "rgba(239, 68, 68, 0.14)";
const LOCAL_BOTTLENECK_SHADOW_COLOR = "rgba(239, 68, 68, 0.55)";
const LOCAL_BOTTLENECK_BADGE_BORDER_COLOR = "rgba(148, 163, 184, 0.72)";
const LOCAL_BOTTLENECK_TOOLTIP = "Did you mean to make this node execute on Modal?";
const STATE_SETUP = "setup";
const STATE_STARTING = "starting";
@@ -3447,18 +3446,6 @@ function remoteDecorationPalette(state, elapsed) {
);
}
/**
* Return the static warning palette for a local node between remote graph regions.
* @returns {{ borderColor: string, shadowColor: string, fillColor: string }}
*/
function localBottleneckDecorationPalette() {
return {
borderColor: LOCAL_BOTTLENECK_BORDER_COLOR,
shadowColor: LOCAL_BOTTLENECK_SHADOW_COLOR,
fillColor: LOCAL_BOTTLENECK_FILL_COLOR,
};
}
/**
* Return the visible LiteGraph node matching a Nodes 2.0 DOM element.
* @param {HTMLElement} nodeElement
@@ -3514,19 +3501,17 @@ function updateVueNodeDecoration(nodeElement, node, timestamp) {
return;
}
const state = getRemoteVisualState(node);
const palette = localBottleneck
? localBottleneckDecorationPalette()
: remoteDecorationPalette(state, timestamp / 1000);
const palette = localBottleneck ? null : remoteDecorationPalette(state, timestamp / 1000);
const decoration = ensureVueNodeDecoration(nodeElement);
const innerWrapper = nodeElement.querySelector(':scope > [data-testid="node-inner-wrapper"]');
const borderRadius =
innerWrapper && typeof getComputedStyle === "function"
? getComputedStyle(innerWrapper).borderRadius
: "12px";
decoration.style.borderColor = palette.borderColor;
decoration.style.backgroundColor = palette.fillColor ?? "transparent";
decoration.style.borderColor = palette?.borderColor ?? "transparent";
decoration.style.backgroundColor = palette?.fillColor ?? "transparent";
decoration.style.borderRadius = borderRadius || "12px";
decoration.style.boxShadow = `0 0 8px ${palette.shadowColor}`;
decoration.style.boxShadow = palette ? `0 0 8px ${palette.shadowColor}` : "none";
const phase = localBottleneck ? "local-bottleneck" : (state?.phase ?? "idle");
nodeElement.classList.add("comfy-modal-vue-node");
nodeElement.dataset.modalPhase = phase;
@@ -3534,8 +3519,11 @@ function updateVueNodeDecoration(nodeElement, node, timestamp) {
const badgeText = localBottleneck ? "!" : String(state?.componentLabel ?? "");
badge.textContent = badgeText;
badge.hidden = !badgeText;
badge.style.borderColor = palette.borderColor;
badge.style.boxShadow = `0 0 8px ${palette.shadowColor}`;
badge.title = localBottleneck ? LOCAL_BOTTLENECK_TOOLTIP : "";
badge.style.borderColor = localBottleneck
? LOCAL_BOTTLENECK_BADGE_BORDER_COLOR
: palette.borderColor;
badge.style.boxShadow = localBottleneck ? "none" : `0 0 8px ${palette.shadowColor}`;
}
/**
@@ -3636,7 +3624,45 @@ function traceRoundedRectPath(ctx, x, y, width, height, radius) {
}
/**
* Draw the Modal execution or local-bottleneck border and shading for a node.
* Return whether a node-local pointer position is over the local-warning badge.
* @param {LGraphNode} node
* @param {number[] | undefined} localPosition
* @param {number} scale
* @returns {boolean}
*/
function localBottleneckBadgeContainsPoint(node, localPosition, scale = 1) {
if (!Array.isArray(localPosition) || localPosition.length < 2) {
return false;
}
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
const titleHeight = node.constructor?.title_height ?? LiteGraph.NODE_TITLE_HEIGHT ?? 24;
const badgeRadius = 10 / safeScale;
const badgeX = 10 / safeScale;
const badgeY = -titleHeight + 10 / safeScale;
const deltaX = Number(localPosition[0]) - badgeX;
const deltaY = Number(localPosition[1]) - badgeY;
return deltaX * deltaX + deltaY * deltaY <= badgeRadius * badgeRadius;
}
/**
* Set or clear the native hover text for the legacy canvas warning badge.
* @param {LGraphCanvas | undefined} graphCanvas
* @param {boolean} visible
*/
function updateLegacyBottleneckTooltip(graphCanvas, visible) {
const canvasElement = graphCanvas?.canvas ?? app.canvas?.canvas;
if (!canvasElement) {
return;
}
if (visible) {
canvasElement.title = LOCAL_BOTTLENECK_TOOLTIP;
} else if (canvasElement.title === LOCAL_BOTTLENECK_TOOLTIP) {
canvasElement.removeAttribute("title");
}
}
/**
* Draw the Modal execution decoration or neutral local-bottleneck badge for a node.
* @param {LGraphNode} node
* @param {CanvasRenderingContext2D} ctx
*/
@@ -3652,37 +3678,37 @@ function drawModalNodeDecoration(node, ctx) {
const borderWidth = 3 / scale;
const cornerRadius = 12 / scale;
const elapsed = performance.now() / 1000;
const { borderColor, shadowColor, fillColor } = localBottleneck
? localBottleneckDecorationPalette()
: remoteDecorationPalette(state, elapsed);
const palette = localBottleneck ? null : remoteDecorationPalette(state, elapsed);
ctx.save();
if (fillColor) {
ctx.fillStyle = fillColor;
if (palette) {
ctx.save();
if (palette.fillColor) {
ctx.fillStyle = palette.fillColor;
traceRoundedRectPath(
ctx,
borderWidth,
-titleHeight + borderWidth,
Math.max(0, node.size[0] - borderWidth * 2),
Math.max(0, node.size[1] + titleHeight - borderWidth * 2),
Math.max(0, cornerRadius - borderWidth),
);
ctx.fill();
}
ctx.strokeStyle = palette.borderColor;
ctx.lineWidth = borderWidth;
ctx.shadowColor = palette.shadowColor;
ctx.shadowBlur = 8 / scale;
traceRoundedRectPath(
ctx,
borderWidth,
-titleHeight + borderWidth,
Math.max(0, node.size[0] - borderWidth * 2),
Math.max(0, node.size[1] + titleHeight - borderWidth * 2),
Math.max(0, cornerRadius - borderWidth),
-borderWidth,
-titleHeight,
node.size[0] + borderWidth * 2,
node.size[1] + titleHeight + borderWidth,
cornerRadius,
);
ctx.fill();
ctx.stroke();
ctx.restore();
}
ctx.strokeStyle = borderColor;
ctx.lineWidth = borderWidth;
ctx.shadowColor = shadowColor;
ctx.shadowBlur = 8 / scale;
traceRoundedRectPath(
ctx,
-borderWidth,
-titleHeight,
node.size[0] + borderWidth * 2,
node.size[1] + titleHeight + borderWidth,
cornerRadius,
);
ctx.stroke();
ctx.restore();
const nodeBadgeText = localBottleneck ? "!" : state?.componentLabel;
if (nodeBadgeText) {
@@ -3691,10 +3717,12 @@ function drawModalNodeDecoration(node, ctx) {
const badgeX = 10 / scale;
const badgeY = -titleHeight + 10 / scale;
ctx.fillStyle = "rgba(15, 23, 42, 0.92)";
ctx.strokeStyle = borderColor;
ctx.strokeStyle = localBottleneck
? LOCAL_BOTTLENECK_BADGE_BORDER_COLOR
: palette.borderColor;
ctx.lineWidth = 1.5 / scale;
ctx.shadowColor = shadowColor;
ctx.shadowBlur = 8 / scale;
ctx.shadowColor = localBottleneck ? "transparent" : palette.shadowColor;
ctx.shadowBlur = localBottleneck ? 0 : 8 / scale;
ctx.beginPath();
ctx.arc(badgeX, badgeY, badgeRadius, 0, Math.PI * 2);
ctx.fill();
@@ -3947,6 +3975,22 @@ function decorateNode(node) {
originalDrawForeground?.apply(this, arguments);
drawModalNodeDecoration(this, ctx);
};
const originalOnMouseMove = node.onMouseMove;
node.onMouseMove = function onMouseMove(event, localPosition, graphCanvas) {
const result = originalOnMouseMove?.apply(this, arguments);
const scale = graphCanvas?.ds?.scale ?? app.canvas?.ds?.scale ?? 1;
const badgeHovered =
isSandwichedLocalNode(this) &&
localBottleneckBadgeContainsPoint(this, localPosition, scale);
updateLegacyBottleneckTooltip(graphCanvas, badgeHovered);
return result;
};
const originalOnMouseLeave = node.onMouseLeave;
node.onMouseLeave = function onMouseLeave(event, graphCanvas) {
const result = originalOnMouseLeave?.apply(this, arguments);
updateLegacyBottleneckTooltip(graphCanvas, false);
return result;
};
queueVueNodeDecorationSync();
}
@@ -4793,6 +4837,11 @@ function installGlobalStatusStyles() {
font: 10px/1 ui-sans-serif, system-ui, sans-serif;
}
.comfy-modal-vue-node[data-modal-phase="local-bottleneck"] .comfy-modal-vue-node-badge {
pointer-events: auto;
cursor: help;
}
.comfy-modal-vue-node-badge[hidden] {
display: none;
}