From 85dc6db65df3b7bb549fe1ec01342af3d77fbc65 Mon Sep 17 00:00:00 2001 From: Ken Simpson Date: Fri, 17 Apr 2026 13:52:00 -0700 Subject: [PATCH] feat: add global modal activity badge --- README.md | 2 + docs/LEARNINGS.md | 1 + tests/test_web_modal_toggle.py | 10 ++ web/modal_toggle.js | 192 +++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+) diff --git a/README.md b/README.md index b500216..d6927ef 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ While that is happening, the frontend now shows remote-node state directly on th Because the Modal queue route can spend noticeable time hashing, syncing, and creating a remote runtime before the prompt is formally queued, the frontend also emits a temporary synthetic running state into ComfyUI's normal queue/execution UI. Those events now mirror ComfyUI's native websocket payload shapes, including the temporary "Waiting for a machine" initialization notification, so the built-in queue indicators stay alive during the preparatory phase instead of looking idle until the backend finally returns the queued prompt id. +The extension also now renders its own global activity badge in the ComfyUI UI during Modal setup and remote execution. That badge stays visible even when ComfyUI's stock queue state does not fully reflect the pre-queue setup phase. + ### 6. What happens during execution When execution reaches a rewritten component proxy: diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index af6504d..c656e56 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -19,6 +19,7 @@ - A bare `PromptExecutor(_NullPromptServer())` is not equivalent to real ComfyUI startup. The headless worker must pass the same cache configuration that `main.py` uses, or execution eventually crashes when the executor polls caches with `self.cache_args["ram"]`. - If `/modal/queue_prompt` does expensive preparatory work before it returns, the frontend can keep ComfyUI's own queue/execution indicators alive by dispatching temporary synthetic `status`, `execution_start`, and `executing` events, then letting the real websocket events take over once the prompt is actually queued. - The synthetic frontend events have to match ComfyUI's websocket adapter shapes exactly, not just semantically. In practice that means `status` detail must be the inner status payload, `executing` detail should be the display node id string, and queue-list initialization state benefits from a matching `"Waiting for a machine"` notification payload keyed by prompt id. +- Even with correctly-shaped synthetic events, ComfyUI's built-in queue UI may still not present an obvious "workflow running" state during long pre-queue Modal setup. The extension should own a separate global activity badge so users have explicit feedback while the remote runtime is still being prepared. - Synced asset paths cannot stay as abstract `/assets/...` markers inside the Modal worker. Remote execution needs those markers materialized to real absolute files under the mounted storage root, and ComfyUI's `folder_paths` helpers need an absolute-path fast path so loader nodes can still resolve them. - A local filesystem mirror is not enough in `COMFY_MODAL_EXECUTION_MODE=remote`. Queue-time sync must upload assets into the actual named Modal volume via the local SDK, and the remote worker must call `vol.reload()` before execution so warm containers can see newly uploaded files. - Modal volume SDK calls should not run directly on the aiohttp request event loop. Running them in a worker thread avoids `AsyncUsageWarning`, and a missing path from `Volume.listdir()` should be treated as an ordinary "not uploaded yet" cache miss instead of a hard failure. diff --git a/tests/test_web_modal_toggle.py b/tests/test_web_modal_toggle.py index be9969d..7c8df84 100644 --- a/tests/test_web_modal_toggle.py +++ b/tests/test_web_modal_toggle.py @@ -27,3 +27,13 @@ def test_synthetic_execution_events_match_comfyui_execution_shapes() -> None: assert 'dispatchSyntheticApiEvent("executing", displayNode);' in source assert 'dispatchSyntheticApiEvent("notification", {' in source assert "Waiting for a machine on Modal." in source + + +def test_global_modal_status_badge_is_installed() -> None: + """The frontend should expose a dedicated global Modal activity indicator.""" + source = _modal_toggle_source() + + assert 'element.id = "comfy-modal-global-status";' in source + assert "Modal setup running for" in source + assert "Modal workflow running on" in source + assert "installGlobalStatusStyles()" in source diff --git a/web/modal_toggle.js b/web/modal_toggle.js index c665dff..fc305b6 100644 --- a/web/modal_toggle.js +++ b/web/modal_toggle.js @@ -22,8 +22,10 @@ const modalNodeStates = new Map(); const modalNodeClearTimers = new Map(); const modalPromptStates = new Map(); const syntheticPromptUiStates = new Map(); +const modalGlobalStatusStates = new Map(); let animationFrameHandle = null; +let modalGlobalStatusElement = null; /** * Return whether a node should show the Modal toggle. @@ -80,6 +82,158 @@ function nodeId(node) { return String(node?.id ?? ""); } +/** + * Ensure the global Modal execution status badge exists. + * @returns {HTMLDivElement | null} + */ +function ensureGlobalStatusElement() { + if (typeof document === "undefined") { + return null; + } + if (modalGlobalStatusElement?.isConnected) { + return modalGlobalStatusElement; + } + + const element = document.createElement("div"); + element.id = "comfy-modal-global-status"; + element.style.position = "fixed"; + element.style.top = "14px"; + element.style.right = "18px"; + element.style.zIndex = "9999"; + element.style.display = "none"; + element.style.alignItems = "center"; + element.style.gap = "10px"; + element.style.padding = "10px 14px"; + element.style.borderRadius = "999px"; + element.style.border = "1px solid rgba(255, 255, 255, 0.16)"; + element.style.background = "rgba(15, 23, 42, 0.94)"; + element.style.boxShadow = "0 10px 30px rgba(0, 0, 0, 0.28)"; + element.style.color = "#f8fafc"; + element.style.fontFamily = "ui-sans-serif, system-ui, sans-serif"; + element.style.fontSize = "13px"; + element.style.fontWeight = "600"; + element.style.pointerEvents = "none"; + element.innerHTML = + ''; + document.body.appendChild(element); + modalGlobalStatusElement = element; + return element; +} + +/** + * Return the most important active global Modal state. + * @returns {{ phase: string, promptId: string, nodeCount: number } | null} + */ +function currentGlobalStatus() { + if (modalGlobalStatusStates.size === 0) { + return null; + } + + const phases = Array.from(modalGlobalStatusStates.entries()).map(([promptId, state]) => ({ + promptId, + phase: state.phase, + nodeCount: state.nodeCount, + updatedAt: state.updatedAt, + })); + phases.sort((left, right) => right.updatedAt - left.updatedAt); + + return ( + phases.find((state) => state.phase === STATE_ERROR) ?? + phases.find((state) => state.phase === STATE_SETUP) ?? + phases.find((state) => state.phase === STATE_EXECUTING) ?? + phases[0] + ); +} + +/** + * Redraw the global Modal execution badge. + */ +function refreshGlobalStatusElement() { + const element = ensureGlobalStatusElement(); + if (!element) { + return; + } + + const activeState = currentGlobalStatus(); + if (!activeState) { + element.style.display = "none"; + element.dataset.phase = ""; + return; + } + + const dot = element.querySelector(".modal-status-dot"); + const text = element.querySelector(".modal-status-text"); + const nodeLabel = activeState.nodeCount === 1 ? "node" : "nodes"; + + element.style.display = "inline-flex"; + element.dataset.phase = activeState.phase; + + if (activeState.phase === STATE_SETUP) { + element.style.borderColor = "rgba(245, 158, 11, 0.55)"; + element.style.background = "rgba(61, 42, 9, 0.94)"; + dot.style.background = SETUP_BORDER_COLOR; + dot.style.boxShadow = "0 0 0 6px rgba(245, 158, 11, 0.18)"; + dot.style.animation = "modal-status-pulse 1.1s ease-in-out infinite"; + text.textContent = `Modal setup running for ${activeState.nodeCount} ${nodeLabel}`; + } else if (activeState.phase === STATE_EXECUTING) { + element.style.borderColor = "rgba(34, 197, 94, 0.55)"; + element.style.background = "rgba(8, 49, 28, 0.94)"; + dot.style.background = ACTIVE_BORDER_COLOR; + dot.style.boxShadow = "0 0 0 6px rgba(34, 197, 94, 0.18)"; + dot.style.animation = "modal-status-pulse 1.1s ease-in-out infinite"; + text.textContent = `Modal workflow running on ${activeState.nodeCount} ${nodeLabel}`; + } else if (activeState.phase === STATE_ERROR) { + element.style.borderColor = "rgba(239, 68, 68, 0.55)"; + element.style.background = "rgba(69, 10, 10, 0.94)"; + dot.style.background = ERROR_BORDER_COLOR; + dot.style.boxShadow = "0 0 0 6px rgba(239, 68, 68, 0.18)"; + dot.style.animation = "none"; + text.textContent = "Modal workflow failed"; + } else { + element.style.borderColor = "rgba(29, 155, 240, 0.5)"; + element.style.background = "rgba(15, 23, 42, 0.94)"; + dot.style.background = IDLE_BORDER_COLOR; + dot.style.boxShadow = "0 0 0 6px rgba(29, 155, 240, 0.18)"; + dot.style.animation = "none"; + text.textContent = "Modal workflow active"; + } + + dot.style.width = "10px"; + dot.style.height = "10px"; + dot.style.borderRadius = "999px"; + dot.style.display = "inline-block"; +} + +/** + * Record one prompt's global Modal execution phase. + * @param {string} promptId + * @param {string} phase + * @param {number} nodeCount + */ +function setGlobalStatusPhase(promptId, phase, nodeCount) { + if (!promptId) { + return; + } + modalGlobalStatusStates.set(promptId, { + phase, + nodeCount: Math.max(1, Number(nodeCount) || 1), + updatedAt: nowMs(), + }); + refreshGlobalStatusElement(); +} + +/** + * Clear one prompt from the global Modal execution badge. + * @param {string} promptId + */ +function clearGlobalStatusPhase(promptId) { + if (!promptId) { + return; + } + modalGlobalStatusStates.delete(promptId); + refreshGlobalStatusElement(); +} + /** * Return the prompt metadata bucket, creating it if needed. * @param {string} promptId @@ -388,8 +542,11 @@ function handleModalStatus(event) { } if (detail.phase === STATE_SETUP) { beginSyntheticExecutionUi(promptId, (detail.node_ids ?? []).map((value) => String(value))); + setGlobalStatusPhase(promptId, STATE_SETUP, (detail.node_ids ?? []).length); } else if (detail.phase === STATE_ERROR) { endSyntheticExecutionUi(promptId, true); + setGlobalStatusPhase(promptId, STATE_ERROR, (detail.node_ids ?? []).length); + setTimeout(() => clearGlobalStatusPhase(promptId), ERROR_CLEAR_DELAY_MS); } const nodeIds = (detail.node_ids ?? []).map((value) => String(value)); @@ -425,6 +582,12 @@ function handleExecutionPhase(event, phase) { if (!componentNodeIds) { return; } + if (phase === STATE_EXECUTING) { + setGlobalStatusPhase(promptId, STATE_EXECUTING, componentNodeIds.length); + } else if (phase === STATE_ERROR) { + setGlobalStatusPhase(promptId, STATE_ERROR, componentNodeIds.length); + setTimeout(() => clearGlobalStatusPhase(promptId), ERROR_CLEAR_DELAY_MS); + } setNodesPhase(componentNodeIds, phase, promptId, detail.exception_message); } @@ -478,6 +641,7 @@ function beginSyntheticExecutionUi(promptId, remoteNodeIds) { const displayNode = remoteNodeIds[0]; syntheticPromptUiStates.set(promptId, { displayNode }); + setGlobalStatusPhase(promptId, STATE_SETUP, remoteNodeIds.length); dispatchSyntheticApiEvent("status", statusPayload(1)); dispatchSyntheticApiEvent("notification", { id: promptId, @@ -502,6 +666,12 @@ function endSyntheticExecutionUi(promptId, failed = false) { } syntheticPromptUiStates.delete(promptId); + if (failed) { + setGlobalStatusPhase(promptId, STATE_ERROR, 1); + setTimeout(() => clearGlobalStatusPhase(promptId), ERROR_CLEAR_DELAY_MS); + } else { + clearGlobalStatusPhase(promptId); + } dispatchSyntheticApiEvent("notification", { id: promptId, value: "Modal setup finished.", @@ -551,6 +721,7 @@ function registerExecutionListeners() { return; } endSyntheticExecutionUi(promptId); + clearGlobalStatusPhase(promptId); const promptState = modalPromptStates.get(promptId); if (!promptState) { return; @@ -622,10 +793,31 @@ function patchQueuePrompt() { api.__modalQueuePromptPatched = true; } +/** + * Install CSS keyframes used by the global Modal status badge. + */ +function installGlobalStatusStyles() { + if (typeof document === "undefined" || document.getElementById("comfy-modal-status-styles")) { + return; + } + + const style = document.createElement("style"); + style.id = "comfy-modal-status-styles"; + style.textContent = ` + @keyframes modal-status-pulse { + 0% { transform: scale(0.9); opacity: 0.7; } + 50% { transform: scale(1.08); opacity: 1; } + 100% { transform: scale(0.9); opacity: 0.7; } + } + `; + document.head.appendChild(style); +} + app.registerExtension({ name: "Comfy.ModalSync.Toggle", async init() { + installGlobalStatusStyles(); patchQueuePrompt(); registerExecutionListeners(); },