When a Saver's load_saved / prompt_lists were promoted to subgraph inputs, changing them on the subgraph node never ran the Saver's callbacks: the prompt didn't load, the list didn't refilter, and the outer dropdowns kept a stale snapshot of the options. - Add js/subgraph_widgets.js (pure module): find the widget that supplies a value through nested subgraphs, and hook the subgraph node's outer widget callbacks to forward changes to opted-in nodes inside. Re-hooks on widget-promoted and when the frontend silently rebuilds an outer widget (input._widget slot watcher). - Saver mirrors the outer widgets: outer changes run the normal callbacks; inner values and option lists are pushed back out, including to a Primitive feeding the subgraph node. Unchanged values are ignored so a Primitive's per-queue re-apply doesn't reset the selection. - Save / Delete / load use the outer list name when promoted. - Add plain Node tests (tests/), a mock workflow loader and the bug-report workflow as a fixture. - Rename AGENT.md to AGENTS.md; document promoted widgets, testing and README style. - Bump version to 1.8.1.
31 KiB
AGENTS.md
Guidelines for working on this project. Read this before making changes.
Project Goals
Prompt Stash is a small ComfyUI custom node pack for saving prompts into named lists and capturing text from upstream nodes (LLMs, etc.). The philosophy:
- Small and focused -- three nodes, one shared JSON library, no extra dependencies
- Never break saved workflows -- widget order, node class names and the data file format are effectively public API
- Work on both renderers -- classic LiteGraph canvas and the Vue (Nodes 2.0) renderer, plus nodes inside subgraphs
- Robust error handling -- a missing/corrupt data file or an odd frontend version must not crash ComfyUI or the node
Architecture
Layout
| File | Purpose |
|---|---|
__init__.py |
HTTP routes (save/delete/init/get_prompt/list ops/import/export) + node registration |
prompt_stash_saver_node.py |
PromptStashSaver -- prompt box, save/load from lists, text + meta outputs |
prompt_stash_passthrough_node.py |
PromptStashPassthrough -- text capture + "pause to edit"; owns the continue / clear_all routes |
prompt_stash_manager_node.py |
PromptStashManager -- add/delete/sort lists (no outputs, UI-only node) |
prompt_stash_every_nth_node.py |
PromptStashEveryNthQueue -- BOOLEAN True once every N queues; the countdown lives in the frontend |
stash_sync.py |
In-memory carry-forward of captured input text per (tab path, node), /prompt_stash_saver/latest_input route |
data_utils.py |
Data file location/init/migration, import validation + merge, workflow/prompt helpers |
default_prompt_stash_data.json |
Template copied to the user data file on first run |
js/*.js |
Frontend extensions, one per node + multi_button.js widget + shared utils.js + subgraph_widgets.js (promoted widgets) |
node_list.json |
Registry descriptions per node |
example_workflows/ |
Sample workflow (JSON + PNG) |
tests/ |
Plain Node tests for the pure JS modules, mock graphs, workflow fixtures (see tests/README.md) |
Python (Nodes + Routes)
- V1 schema (
INPUT_TYPES,RETURN_TYPES,FUNCTION,NODE_CLASS_MAPPINGSin__init__.py). This pack is NOT on V3 -- don't mix incomfy_api.latest/comfy_entrypointpiecemeal. Addingcomfy_entrypointdoes nothing whileNODE_CLASS_MAPPINGSexists (ComfyUI's loader takes the V1 branch). A V3 migration would be a deliberate, whole-pack change that must keep the class names and widget order - Node class names (
PromptStashSaver,PromptStashPassthrough,PromptStashManager) are the workflowtype-- never rename them - All nodes live in category
utils WEB_DIRECTORY = "./js"- Routes are plain
@PromptServer.instance.routes.post/get(...)decorators at module import. Prefixes:/prompt_stash_saver/*(also hosts list ops:add_list,delete_list,sort_list),/prompt_stash_manager/*(import/export),/prompt_stash_passthrough/*(continue, clear_all) - Route handlers create a fresh node instance per request (
PromptStashSaver()/PromptStashManager()), and__init__re-reads the data file from disk. There is no in-memory cache shared between requests, and no file lock - Server -> client pushes go through
PromptServer.instance.send_sync(...):prompt-stash-update-all{lists}-- after any data change, and also on everyPromptStashSaver()construction (load_data()broadcasts). Saver JS relies on this: it calls/prompt_stash_saver/initand ignores the response, getting its lists from the broadcast that the constructor sends. Don't remove the broadcast fromload_data()without changingprompt_stash_saver.jsto read theinitresponseprompt-stash-update-prompt{node_id, prompt, tab, token}-- captured input text pushed into the node's prompt box. Only the node in the tab whose path equalstabapplies it (handlePushedUpdate); an emptytabfalls back to node-id-only matchingprompt-stash-set-continue{node_id, show}-- show/hide the Passthrough Continue button
- Output messages use
print(...), prefixedPrompt Stash:for normal messages (nologgingmodule in use)
Data File
- Location:
folder_paths.get_user_directory()/prompt_stash/prompt_stash_data.json(get_user_data_directory()). Fallback when that fails:prompt_stash_data.jsonin the node folder (gitignored) init_data_file()handles: one-time migration from the old node-folder location (copy then delete), first-run seeding fromdefault_prompt_stash_data.json(or a built-in minimal default), and adding"version"to old files- Format:
{"version": "1.0", "lists": {list_name: {prompt_name: prompt_text}}}. Dict key order is the display order (Sort A-Z rewrites it withcasefold()) - Unknown list names on save/get fall back to
"default". The last remaining list can't be deleted (len(lists) <= 1);"default"itself is deletable - Import (
validate_import_data+deep_merge_lists): drops invalid entries, strips names, merges into existing lists, renames clashes toname (2),name (3)... Never overwrites. Returns a summary shown in a toast - Export streams the raw file as
prompt_stash_export_<timestamp>.json - Always write with
encoding='utf-8',indent=2-- users hand-edit and diff this file
Node Behaviour
- Saver / Passthrough shared pattern:
use_input_text(BOOLEAN) chooses between thetextinput (lazy,forceInput) and theprompt_textwidgetcheck_lazy_statusrequeststextonly whenuse_input_textis on. Iftextisn't in the prompt at all it raises a clearValueError(prompt_has_input) instead of ComfyUI's "says it needs input text, but there is no input to that node at all"IS_CHANGEDhashes the inputs that affect output;textonly counts whenuse_input_textis on- When input text is used,
process()pushes it to the UI (prompt-stash-update-prompt) and rewrites the saved metadata: inextra_pnginfo.workflow(viaupdate_node_in_workflow, subgraph-aware) setsuse_input_text=Falseandprompt_text=<captured>, and does the same inprompt. Loading an output image therefore reproduces the captured text without re-running the LLM
- Saver: outputs
textandmeta(JSON string:prompt_name,list_name,source=input|saved|manual,save_as_key,load_saved,use_input_text; precedence documented inbuild_meta).load_saved/prompt_listsare COMBOs declared in Python with stub values (["None"]/["default"]) only so the frontend creates Primitive-connectable input sockets; real options are filled client-side.VALIDATE_INPUTStakes those two params so ComfyUI skips static combo validation for them - Passthrough:
pause_to_editblocks the executor thread in atime.sleep(0.1)loop until/prompt_stash_passthrough/continue/{node_id}sets status"continue"(with the edited text). State is in class-level dicts (status_by_id,edited_text_by_id) keyed by the string UNIQUE_ID ("5"or"12:5"in subgraphs). Re-sendsset-continueevery 2s so a refreshed browser gets the button back./clear_all(Manager's "(Clear All Paused)" button) releases every paused node - Manager: no outputs,
process()is a no-op. Theexisting_listscombo is created in JS (not declared in Python) - Every Nth Queue:
every(INT) + hiddenvalue(BOOLEAN, socket removed in JS);process()just returnsvalue. The countdown isnode.properties.countdown(1 = True next run), never sent to the backend. A widgetbeforeQueuedhook (called by the frontend once per queued prompt, beforegraphToPrompt, for nodes in subgraphs too) writesvalueand advances the countdown -- skipped for muted/bypassed nodes, and not advanced on partial execution. Onlyvaluereaches the backend, so off runs share one cache key - Carry-forward (
stash_sync.py/js/stash_sync.js): Saver and Passthrough declarestash_tab/stash_tokenasforceInputSTRINGs (appended last). JS removes their sockets and adds hidden prompt-only widgets (serialize: false,serializeValue) that send the active tab path andnode.properties.prompt_stash_token. A True run stores its text under (tab, UNIQUE_ID) with a fresh token; an off run whose sent token differs uses the stored text (and rewrites metadata as for input text).IS_CHANGEDhashes the carried token -- it runs when the prompt starts executing, so prompts queued ahead pick it up. Tokens are compared for equality only (restart-safe). Keyed by tab path, not workflow id: the id survives saving and re-opening from an image, so two tabs can share it. OnafterLoadGraph(load and tab switch) nodes fetch text they missed. Memory only
JavaScript (Frontend Extensions)
- Plain ES modules, no build step. Imports:
import { app } from "../../scripts/app.js",import { api } from "../../scripts/api.js", shared helpers from./utils.js - Extension names:
phazei.PromptStashSaver,phazei.PromptStashPassthrough,phazei.PromptStashManager,phazei.PromptStashEveryNthQueue,phazei.PromptStashSync,phazei.MULTI_BUTTON - Pattern:
beforeRegisterNodeDef-> wrapnodeType.prototype.onNodeCreated(always call the original first) -> find widgets by.name-> attach callbacks / listeners -> wraponRemovedto remove everyapilistener and interval - Event handlers are stored on the node (
this.handlePromptStash...) soonRemovedcan unregister the same function. Each handler starts withif (isUnassignedNode(this)) { this.onRemoved?.(); return; }to drop listeners of detached/zombie nodes - Match server pushes to nodes with
nodeMatchesUniqueId(node, event.detail.node_id); build ids to send withgetUniqueIdFromNode(node). Both are subgraph-aware - Labels via
setDefaultLabel(node, widget, "Label")-- only changes.label, keeps a user's rename. Never changewidget.name(it must equal the Python input key) - Buttons via the custom
MULTI_BUTTONwidget (js/multi_button.js, registered throughgetCustomWidgets): a row of canvas-drawn buttons,serialize: false, supportsconfirm,setDisabled(i, bool),update(), optional single/multi select. Used by Saver (Save / Delete) and Manager (Sort / Add / Delete, Export / Import, and a decorative easter-egg row namedactions). Guarded withtypeof app.widgets?.MULTI_BUTTON === "function" - Toasts via
app.extensionManager?.toast?.add({...})-- always optional-chained - Global "R" refresh: Saver implements the extension's
refreshComboInNodes()hook, walking all graphs (walkGraph) and callingnode._repopulateCombos()becausereloadNodeDefsresets combos to the Python stubs
Shared Utilities (js/utils.js)
nodeMatchesUniqueId,getUniqueIdFromNode,isUnassignedNode,walkGraph,setDefaultLabel- Primitive-node support:
findGetConfigSymbol,installComboGetConfig,refreshConnectedPrimitives,syncValueToConnectedPrimitive forceWidgetReactiveUpdate(node, widget)-- Vue re-render + clearsvalue_not_in_listerror- Keep
utils.jsfree of imports andapp/ DOM access so tests can load it under Node
Promoted Subgraph Widgets (js/subgraph_widgets.js)
Pure module (imports only utils.js), unit-tested in tests/subgraph_widgets.test.mjs.
getSourceWidget(node, name)-- the widget that supplies the value: the widget itself, or (input linked to a subgraph input) the container's outer widget, followed up through nested containers; null when the outermost container input is fed by another nodefindContainer(graph),findPromotedTargets(container, input)(real nodes + widget names an outer input feeds, through nesting),hostWidgetForInput,isContainerinstallPromotedWidgetHooks(container)-- wraps each outer widget'scallback(original first, then forward) and re-wraps onsubgraph.eventswidget-promotedand whenever a new object lands in an input's_widgetslot; listener dropped inonRemoved. Only inputs that are unlinked and feed a node withonPromotedWidgetChangedare wrapped- Opt-in interface on inner nodes:
onPromotedWidgetChanged(name, value, host)(user changed the outer widget) andonPromotedWidgetBound(name, host)(outer widget (re)created; sync without side effects) - Saver extension hooks:
nodeCreatedandafterConfigureGraphinstall the hooks on every container
LiteGraph + Nodes 2.0 Compatibility
Every UI change must work on both renderers. Both use the same LGraphNode objects, widgets and callbacks. They differ in how nodes are drawn and receive input:
- LiteGraph (classic): nodes and widgets are painted on the graph
<canvas>, and mouse events reach the node'sonMouse*handlers. - Nodes 2.0 (Vue): each node is a Vue DOM component (
[data-node-id="<id>"]) layered over the canvas. Widgets are Vue components that read reactive state, and canvas-drawn custom widgets are hosted in a small per-widget canvas (WidgetLegacy.vue).
The user can switch renderers while a workflow is open, so don't decide the renderer once in onNodeCreated. Check at the moment you act, as handlePromptStashSetContinue does with isVueRenderer(), or do both paths when both are harmless.
Works the same on both (prefer these)
- Reading and writing
widget.value,widget.label,widget.callback, finding widgets by.name apievents, routes, toasts (app.extensionManager?.toast)node.setDirtyCanvas(true, true)/app.graph.setDirtyCanvas(...): needed for LiteGraph and harmless under Vue. Keep calling it- Built-in widget types declared in Python (
STRING,BOOLEAN, COMBO): the frontend renders them natively in both modes. Declaring a widget inINPUT_TYPESis more robust than creating it in JS
Prefer the frontend's own data model (widgets, values, labels) over custom drawing. That way both renderers pick up the change without extra code.
LiteGraph-only. Needs a separate Vue path
- Painting on the node (
onDrawForeground/onDrawBackground): Vue nodes don't use it. Passthrough's Continue button is drawn in the title bar on the canvas and also added as a DOM<button>for Vue (updateDomContinueButton). Any new canvas-drawn decoration needs the same DOM counterpart - Node mouse handlers (
onMouseDown/onMouseMove/onMouseLeavewith node-local hit-testing against stored rects likebuttonArea): these don't drive Vue nodes. Give the DOM element its own listeners computeSizeoverrides: they set the LiteGraph size. Don't assume they have the same effect on Vue nodes; check the layout visually in both modes
Vue-only requirements
- Reactivity of combo options: mutating
widget.options.valuesfrom JS is invisible to Vue, and assigning the same value is skipped (Object.is). After changing options, calltriggerComboReactivity(widget)(own widgets) orforceWidgetReactiveUpdate(node, widget)(also firesnode.onWidgetChangedto clear the redvalue_not_in_listoutline). They writevalue + "\x00", then the real value. The intermediate value is never rendered because Vue batches synchronous updates. This also applies to other nodes you update, such as connected Primitives (refreshConnectedPrimitives) - Custom canvas widgets (
MULTI_BUTTON):- Redraw with both
node.setDirtyCanvas(...)andwidget.triggerDraw?.().triggerDrawis set byWidgetLegacy.vueand is undefined on the classic canvas, so always optional-chain it pointerupcoordinates arrive in a different space frompointerdownin the Vue host canvas. Record the hit onpointerdownand trust that index when thepointeruphit-test misses (seeMULTI_BUTTON.mouse)- Accept both
mouse(event, x, y)andmouse(event, [x, y])signatures - Keep a fixed height from
computeSizeand draw only inside the(width, y)you're given
- Redraw with both
- DOM injection into Vue nodes:
- Find the element with
document.querySelector(`[data-node-id="${node.id}"]`)and null-check it. It doesn't exist yet right after a state change (defer: Continue usessetTimeout(..., 50)), and only nodes in the graph being viewed are rendered - Make creation idempotent: look for your class (
.prompt-stash-continue-btn) before adding - Vue owns the element and can re-create it (re-render, entering/leaving a subgraph, switching workflows), dropping your additions. Re-assert state rather than assuming it persists. The Passthrough backend re-sends
prompt-stash-set-continueevery 2s, and the handler re-adds the button - Style inline (only
.jsis auto-loaded, there's no CSS file) and calle.stopPropagation()in click handlers so the canvas doesn't also handle the click - Remove injected elements in
onRemoved
- Find the element with
- Badges (
node.badges) aren't reactive from extension JS. If live badge text is ever needed, update the DOM directly (see the Enhancement-Utils profiler for the approach)
General rules from the shared frontend
- Positions: write
node.pos = [x, y](goes through the setter that updates Vue's layout store). Nevernode.pos[0] = x. That mutates the array directly and Vue never sees it. Readingnode.pos[0]is fine - IDs:
node.idis a string on current frontends. The[data-node-id]attribute and servernode_idcomparisons must use the string form - Feature-detect, don't version-check:
typeof app.widgets?.MULTI_BUTTON === "function",widget.triggerDraw?.(),node.onWidgetChanged?.(...)
Code Style
Python
- Docstrings on non-trivial functions (newer code has them; add them when touching older code)
- Keep
INPUT_TYPES,IS_CHANGED,check_lazy_statusandprocesssignatures in sync -- every one lists all inputs including hidden ones, with the same defaults - Imports: standard library, then ComfyUI (
server,folder_paths,comfy), then local (.data_utils) - Wrap all file I/O in try/except; return
False/ fallback data rather than raising from routes
JavaScript
- JSDoc on helper functions with
@param/@returns - Section headers:
// ── Section Name ────────── constby default,letonly when reassigned, nevervar- Arrow functions for callbacks; per-node methods attached as
this._name = () => {...}(hoisted no-op first if other code may call it early, e.g._refreshSaveDeleteRow) - Defensive checks everywhere:
this.widgets.find(...)results,node.inputs ?? [],?.on frontend APIs that vary between versions - After changing widget values from code:
this.serialize_widgets = trueandapp.graph.setDirtyCanvas(true, true) - Long explanatory comments for any frontend workaround, including why and the ComfyUI version/issue that caused it
README
The README is for users, not a changelog. Keep the first look short:
- Each section: one or two plain sentences, a picture if there is one, and at most 3-4 bullets.
- Everything else (input/output tables, settings, limitations, edge cases) goes in a collapsed
<details><summary>...</summary>block, with a blank line after<summary>so the markdown renders. - Describe what the user sees and does, not how it's implemented. Implementation notes belong in this file.
- A small tweak gets a word or a table row, or nothing at all. Don't add a sentence for every change.
Key Gotchas
Widget Order Is Saved Workflow Format
widgets_values in saved workflows is positional. forceInput inputs (text) don't take a slot. New inputs must be appended at the end of optional; never reorder or remove existing ones. process() writes metadata by index: Saver 0 = use_input_text, 1 = prompt_text; Passthrough adds 2 = pause_to_edit. Update those indices if the layout ever changes.
Lazy Inputs Still Invalidate the Cache
ComfyUI folds the full signature of every linked upstream node into a node's cache key, even for lazy inputs it never requests (ComfyUI issue #11744). Saver and Passthrough each wrap app.graphToPrompt once (patchGraphToPromptOnce) and delete the text link from the API prompt when use_input_text is known to be false. The decision is made from the API prompt, not the canvas widget -- the widget can be stale (promoted to a subgraph input, driven by a link). If they disagreed, the backend would request a text input that had been stripped. resolveConstantBool (utils.js) accepts a literal false or a link to a constant boolean source (PromptStashEveryNthQueue, core PrimitiveBoolean) whose inputs.value is a literal; any other link or a missing value means unknown, keep it. Note a link-driven bool still keeps every node upstream of it in the cache key.
Combo Options Live Only on the Client
The real load_saved / prompt_lists options are never sent to the backend's node definition. Consequences:
VALIDATE_INPUTSmust keepload_savedandprompt_listsas parameters, or every real selection fails validation as "value not in list"/object_infoand the "R" refresh show the stubs;refreshComboInNodes()repopulates from the node's survivingthis.data- Primitive nodes read combo options via a framework-internal anonymous symbol (GET_CONFIG) on the input's widget. It isn't importable, so
findGetConfigSymboldiscovers it at runtime (symbol-keyed function returning an array) andinstallComboGetConfigoverrides it to return live options, falling back to last-known-good when the live list looks like the stub. Installed onerequestAnimationFrameafter creation because input slots aren't wired yet inonNodeCreated - The value sync is normally one-way (Primitive -> node). When the node changes its own value (save, delete, prompt edit clears selection),
syncValueToConnectedPrimitivepushes it back without calling the Primitive's callback (avoids ping-pong). On delete, set the value before_syncComboInput, orrefreshComboInNodeclamps the Primitive to"None"
Two Renderers
See LiteGraph + Nodes 2.0 Compatibility. The usual failures are:
- Changed combo options that don't show in Vue (no reactivity trigger)
- Canvas-drawn UI that's missing in Vue (no DOM counterpart)
- Buttons that don't respond in Vue (
pointerupcoordinate mismatch)
Node IDs Are Strings
Frontend >= 1.46 brands node.id as a string ("5"); an unassigned node is "-1". Always compare with String(...) (see nodeMatchesUniqueId, isUnassignedNode). On the Python side, UNIQUE_ID is a string and may contain : subgraph paths -- the Passthrough continue route receives it in the URL and must use the same string as the dict key.
Subgraphs
- UNIQUE_ID for nodes inside subgraphs is a path (
"54:62:73").update_node_in_workflowwalksworkflow.nodes->definitions.subgraphs[*].nodesby that path app.graphis the root graph; walk subgraphs withwalkGraph(usesnode.subgraph)- Copies / clones of a subgraph node get their own subgraph definition, so a container's inner nodes belong to it alone
Promoted Widgets Don't Run Our Callbacks
Linking a widget input inside a subgraph to a subgraph input "promotes" it: the subgraph node (container) shows its own widget. On current frontends (checked against 1.57, SubgraphNode._setWidget / promotedWidgetStoreProjection.ts):
- The outer widget keeps its own value in
widgetValueStore, keyed by the container's id. The inner widget keeps a stale value the backend never sees -- the outer value is substituted at queue time - Its options are a
cloneDeepsnapshot taken at promotion. Later changes to the inner options (new saves, list switches) never reach it - Its
callbackonly writes the store. The inner widget's callback never runs, so the Saver's load-prompt / refilter logic didn't either (the subgraph bug report; fixturetests/fixtures/saver_subgraph_promotion.json) - Both renderers call the outer widget's
callbackon a user change (classic:BaseWidget.setValue; Vue:processedWidgetRenderModelupdate handler). Only the classic canvas also calls the container'sonWidgetChanged, so don't hook that - The outer widget object is rebuilt on every (re)promotion, followed by
subgraph.eventswidget-promoted. It is also rebuilt lazily with no event: the container'swidgetsgetter projects a fresh object intoinput._widgetwhenever that is empty (store id migration,promotionUtilsre-registration). Seen on 1.57.6 after a page refresh. Sosubgraph_widgets.jsalso watches each input's_widgetslot with an accessor (trapInputWidget) and binds new objects in a microtask - Assigning
.value/.optionson the outer widget writes the store (no callback). Assign a new options object; don't mutate.options.valuesin place - A Primitive can feed the container's input. It writes the outer widget and calls its
callback(applyFirstWidgetValueToGraph), on change and again on every queue. So such inputs are bound (only inputs fed from an enclosing subgraph's input are skipped),resolveWidgetSourcetreats the container widget as the source, the Saver pushes values/options on to that Primitive (syncValueToConnectedPrimitive/refreshConnectedPrimitiveson the container), andonPromotedWidgetChangedignores unchanged values so the per-queue re-apply doesn't reset the selection
The Saver keeps its inner widgets as a mirror of the outer ones: onPromotedWidgetChanged copies the value in and runs the normal callback; _syncComboInput pushes option lists out (_pushPromotedOptions) and _syncPromotedValues pushes value changes out (resets to "None", loaded prompt, Save / Delete, captured text). Server calls read the list name through _sourceValue (outer widget when promoted). The backend is unchanged -- it still outputs prompt_text.
Pause-to-Edit Blocks Execution
The paused node sleeps in ComfyUI's executor thread -- the whole queue waits. If the browser tab is closed while paused, the only ways out are the Continue button after reopening (re-sent every 2s) or Manager's "(Clear All Paused)". ComfyUI's Interrupt does not break the loop (it doesn't check comfy.model_management.processing_interrupted()). Changing pause state must not change IS_CHANGED (it's deliberately excluded from the hash).
Endpoints Called Two Ways
Most JS uses api.fetchApi('/...') (respects ComfyUI's API base path). Manager's import uses raw fetch('/prompt_stash_manager/import') and export uses an <a href="/prompt_stash_manager/export"> -- these assume ComfyUI is served at the root. Prefer api.fetchApi / api.apiURL(...) for anything new.
Adding New Features
New Widget / Input on an Existing Node
- Append it at the end of
optionalinINPUT_TYPES - Add it (with the same default) to
IS_CHANGED,check_lazy_statusandprocesssignatures; include it in theIS_CHANGEDhash if it affects output - Find it in JS by
.name, label it withsetDefaultLabel - Load an old saved workflow and confirm existing widget values still map correctly
- Check it on both renderers (see Testing)
New UI Element (button, indicator, overlay)
- Prefer a real widget (Python-declared or
MULTI_BUTTON) over painting on the node, so both renderers handle it - If it must be custom-drawn, write the LiteGraph version and the Vue DOM version together, sharing one handler (like
handleContinueClick) - Pick the path at the moment you act (
isVueRenderer()), not inonNodeCreated - Clean up DOM elements, intervals and listeners in
onRemoved
New Node
- Create
prompt_stash_<name>_node.pywith a V1-style class - Import it in
__init__.py, add toNODE_CLASS_MAPPINGSandNODE_DISPLAY_NAME_MAPPINGS, and add a description tonode_list.json - Add
js/prompt_stash_<name>.jswithapp.registerExtension({ name: "phazei.<Name>", ... })following theonNodeCreated/onRemovedpattern above - If it reads/writes the stash, use
init_data_file()and broadcastprompt-stash-update-allafter successful writes - Document it in
README.md
New Route
- Register with
@PromptServer.instance.routes.<method>(...)using one of the existing prefixes - Return
web.json_response(...); give errors a JSON{"error": ...}body and a 4xx/5xx status - Broadcast
prompt-stash-update-allafter any data change so every open node updates
Testing
Automated
Plain Node scripts, no install (details in tests/README.md):
node tests/subgraph_widgets.test.mjs
- Logic worth testing goes in a pure module (no
app/api/ DOM imports) likejs/subgraph_widgets.js; the extension file keeps only the frontend glue - Mock graphs come from
tests/workflowLoader.mjs; real workflows that reproduce a bug go intests/fixtures/with descriptive names (and descriptive node titles, which tests look up)
Manual
Frontend behaviour can't be covered by the mocks. Verify in ComfyUI. Run each UI check twice, once with Nodes 2.0 off and once with it on (toggle in ComfyUI settings). Also switch renderers with the workflow open, and confirm dropdowns, buttons and the Continue button still work without reloading. Check the browser console for errors in both modes. Checks:
- Saving / loading / deleting prompts, switching lists, Manager add / delete / sort, import / export
use_input_texton and off (and on with nothing connected -> clear error)- Every Nth Queue driving
use_input_text: off runs keep the LLM and sampler cached; batch count > 1 advances once per item - Carry-forward: queue several runs ahead across a True run (later off runs use the new text); run in one tab while viewing another (the other tab's prompt is untouched; switching back shows the new text)
- Nodes inside a subgraph (continue button, captured text update), including entering and leaving the subgraph while paused
- Save / Delete / Sort buttons respond to clicks and grey out correctly (
MULTI_BUTTONdisabled state) - Saving a prompt shows it in every Saver's dropdown right away, with no stale options or red outline in Vue
- A Primitive connected to
load_saved/prompt_lists, including after the "R" refresh and after reloading the workflow - Saver inside a subgraph with
load_saved/prompt_listspromoted (loadtests/fixtures/saver_subgraph_promotion.json): picking a prompt outside loads it inside; switching the list outside refilters the outer prompt dropdown; typing in the prompt resets the outer selection toNone; Save / Delete update the outer dropdown; options are live after reloading the workflow and after "R"; also nested two subgraphs deep; also with Primitives on the outside feeding the subgraph node'sload_saved/prompt_lists(one or both), across several queued runs - Reloading an old saved workflow / image metadata
ruff is used for linting (.ruff_cache/ exists; no config, so defaults).
Release
- Version lives in
pyproject.toml. A push tomainthat changespyproject.tomltriggers.github/workflows/publish.yml(Comfy Registry publish). Only bump the version when a release is intended - Commit style: conventional prefixes (
feat:,fix:) for recent commits
Dependencies
None beyond ComfyUI itself (aiohttp, server, folder_paths, comfy are provided by ComfyUI).