Compare commits

...
Author SHA1 Message Date
DumiFlex 6a502e5f26 Merge remote-tracking branch 'origin/dev' into fix/assembler-widget-link
# Conflicts:
#	docs/release-notes/next.md
2026-11-13 01:18:34 +00:00
DumiFlex 1d326a6498 fix(assembler): keep the template editors on the node when their input is wired
A link into the template or negative_template input made ComfyUI hide the
DOM editor (computedDisabled / connectionSuppressed), leaving an empty hole
in the node with the wire ending in blank space, on the classic canvas and
Nodes 2.0. The editors now pin those flags and show 'From the template
input' themselves; the wired text still wins at run time.
2026-11-13 01:10:06 +00:00
9 changed files with 165 additions and 20 deletions
+2 -2
View File
@@ -6,8 +6,8 @@ Write a prompt with `$variables` in it and get the finished prompt, plus a match
## Inputs and outputs
- **context (in)** `context`: The variables from a WP Context (or anything that ends in one).
- **template** `STRING`: Your prompt with $variables in it.
- **negative_template** `STRING`: Optional, in the collapsible negative section. Your standing negative, with $negatives where the collected negative words go. New Assemblers start with $negatives; without it the negative is only your own text (empty = no negative).
- **template** `STRING`: Your prompt with $variables in it. Wire another node's text into it to use that text as the template instead: the box then says "From the template input", and the $variables in the wired text still fill in.
- **negative_template** `STRING`: Optional, in the collapsible negative section. Your standing negative, with $negatives where the collected negative words go. New Assemblers start with $negatives; without it the negative is only your own text (empty = no negative). It takes a wired text the same way the template does.
- **prompt (out)** `STRING`: The finished prompt. Wire it into CLIP Text Encode or a WP Prompt Cleaner.
- **negative (out)** `STRING`: The negative words of the variables this template printed, plus your negative template. Wire it into your negative CLIP Text Encode.
+1
View File
@@ -26,6 +26,7 @@
### Fixes
- **Wiring a text into the Prompt Assembler no longer leaves a hole.** Connecting another node's text to the Assembler's template or negative template made that box vanish and left an empty gap in the node, with the wire ending in blank space (on the classic canvas and on Nodes 2.0). The box now stays and says "From the template input" (or the negative template input), with the wire landing on it, and the `$variables` in the wired text still fill in. Disconnect the wire to type in the box again.
- **The Assembler's template boxes show on a subgraph node.** Wiring the Prompt Assembler's `template` or `negative_template` to a subgraph's input left an empty space on the subgraph node, so the prompt could only be edited from inside. The subgraph node now shows the same editor, with the `$` list of the variables upstream of the Assembler inside, in Nodes 2.0 and the classic canvas; what you type there runs and is saved with the workflow.
- **Bundle headers fit narrow nodes and the Settings preview.** On a narrow Context node, or in the Display settings' live preview, a bundle's header pushed its lock, internal and remove buttons past the frame and stretched the bundle wider than the rows around it. The frame now keeps its width: the name, the state badge and the "3 mods · 1 drifted" summary shorten first, and when the header is narrow the badge word gives way to its coloured dot (point at it for what it means).
- **Toggle negatives show in the Assembler's negative preview.** A toggle's Negative (or Off negative), and the picked toggle's negative in Pick one, went to the negative prompt on Run but never showed in the Assembler's negative preview. The preview shows them now.
+1 -1
View File
@@ -1936,7 +1936,7 @@
},
{
"title": "Inputs and outputs",
"text": "context (in)\nThe variables from a WP Context (or anything that ends in one).\ntemplate\nYour prompt with $variables in it.\nnegative_template\nOptional, in the collapsible negative section. Your standing negative, with $negatives where the collected negative words go. New Assemblers start with $negatives; without it the negative is only your own text (empty = no negative).\nprompt (out)\nThe finished prompt. Wire it into CLIP Text Encode or a WP Prompt Cleaner.\nnegative (out)\nThe negative words of the variables this template printed, plus your negative template. Wire it into your negative CLIP Text Encode."
"text": "context (in)\nThe variables from a WP Context (or anything that ends in one).\ntemplate\nYour prompt with $variables in it. Wire another node's text into it to use that text as the template instead: the box then says \"From the template input\", and the $variables in the wired text still fill in.\nnegative_template\nOptional, in the collapsible negative section. Your standing negative, with $negatives where the collected negative words go. New Assemblers start with $negatives; without it the negative is only your own text (empty = no negative). It takes a wired text the same way the template does.\nprompt (out)\nThe finished prompt. Wire it into CLIP Text Encode or a WP Prompt Cleaner.\nnegative (out)\nThe negative words of the variables this template printed, plus your negative template. Wire it into your negative CLIP Text Encode."
},
{
"title": "Works with",
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import NegativeTemplateBox from "./NegativeTemplateBox.vue";
function box(linked: boolean, collapsed = false) {
return mount(NegativeTemplateBox, {
props: { modelValue: "lowres, $negatives", collapsed, linked, varSuggestions: [], varProducers: new Map() },
global: { stubs: { RichTextInput: { template: '<div data-test="neg-editor" />' } } },
});
}
describe("NegativeTemplateBox", () => {
it("shows the editor while nothing is wired", () => {
const w = box(false);
expect(w.find('[data-test="neg-template-linked"]').exists()).toBe(false);
});
it("says where the negative comes from once its input is wired", () => {
const w = box(true);
const note = w.find('[data-test="neg-template-linked"]');
expect(note.exists()).toBe(true);
expect(note.text()).toContain("From the negative_template input");
});
it("the folded line names the wire too", () => {
const w = box(true, true);
expect(w.find('[data-test="neg-template-summary"]').text()).toBe("driven by the connected input");
});
});
@@ -10,6 +10,7 @@
*/
import { computed, defineAsyncComponent } from "vue";
import ListRow from "@/kit/ListRow.vue";
import EmptyState from "@/kit/EmptyState.vue";
import type { VarProducer } from "../../extension/graph";
const RichTextInput = defineAsyncComponent(() => import("../../manager/components/RichTextInput.vue"));
@@ -56,20 +57,27 @@ const summary = computed(() => {
<span class="wp-negbox__line">
<span class="wp-negbox__label">negative</span>
<span v-if="collapsed" class="wp-negbox__summary" data-test="neg-template-summary">{{ summary }}</span>
<span v-else-if="linked" class="wp-negbox__summary">driven by the connected input</span>
</span>
</ListRow>
<div v-if="!collapsed" class="wp-negbox__body" data-wp-socket="negative_template">
<!-- Linked: the wired text is the negative, so say where it comes from
instead of a greyed copy of text that no longer counts. -->
<EmptyState
v-if="linked"
icon="link"
headline="From the negative_template input"
body="Disconnect it to type here."
size="inline"
data-test="neg-template-linked"
/>
<RichTextInput
v-else
:model-value="modelValue"
surface="assembler"
multiline
:rows="2"
fill
:placeholder="linked
? 'Driven by the connected input — disconnect it to edit here.'
: '$negatives'"
:disabled="linked"
placeholder="$negatives"
:var-suggestions="varSuggestions"
:var-producers="varProducers"
graph-aware
+2 -2
View File
@@ -214,8 +214,8 @@ export const NODE_FIELDS = {
WP_PromptAssembler: {
ports: [
{ term: "context (in)", sockets: ["in:context"], desc: "The variables from a WP Context (or anything that ends in one)." },
{ term: "template", sockets: ["in:template"], desc: "Your prompt with $variables in it." },
{ term: "negative_template", sockets: ["in:negative_template"], desc: "Optional, in the collapsible negative section. Your standing negative, with $negatives where the collected negative words go. New Assemblers start with $negatives; without it the negative is only your own text (empty = no negative)." },
{ term: "template", sockets: ["in:template"], desc: "Your prompt with $variables in it. Wire another node's text into it to use that text as the template instead: the box then says \"From the template input\", and the $variables in the wired text still fill in." },
{ term: "negative_template", sockets: ["in:negative_template"], desc: "Optional, in the collapsible negative section. Your standing negative, with $negatives where the collected negative words go. New Assemblers start with $negatives; without it the negative is only your own text (empty = no negative). It takes a wired text the same way the template does." },
{ term: "prompt (out)", sockets: ["out:prompt"], desc: "The finished prompt. Wire it into CLIP Text Encode or a WP Prompt Cleaner." },
{ term: "negative (out)", sockets: ["out:negative"], desc: "The negative words of the variables this template printed, plus your negative template. Wire it into your negative CLIP Text Encode." },
],
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { keepEditorWhenLinked } from "./linked-editor";
/** The parts of ComfyUI's widget classes this pins: `computedDisabled` is a
* plain field, `connectionSuppressed` a prototype accessor over the widget's
* visibility record, and a DOM widget hides itself while computedDisabled. */
class FakeWidget {
disabled?: boolean;
computedDisabled?: boolean;
suppressedByConnection = false;
get connectionSuppressed(): boolean {
return this.suppressedByConnection;
}
set connectionSuppressed(v: boolean | undefined) {
this.suppressedByConnection = v === true;
}
isVisible(): boolean {
return !this.computedDisabled;
}
}
/** What `LGraphNode.updateComputedDisabled` does on every draw. */
function hostMarks(w: FakeWidget, connected: boolean): void {
w.computedDisabled = w.disabled || connected;
w.connectionSuppressed = connected;
}
describe("keepEditorWhenLinked", () => {
it("a linked stock widget is hidden (the bug's cause)", () => {
const w = new FakeWidget();
hostMarks(w, true);
expect(w.isVisible()).toBe(false);
expect(w.connectionSuppressed).toBe(true);
});
it("keeps the editor visible and unsuppressed while linked", () => {
const w = new FakeWidget();
keepEditorWhenLinked(w);
hostMarks(w, true);
expect(w.computedDisabled).toBe(false);
expect(w.isVisible()).toBe(true);
expect(w.connectionSuppressed).toBe(false);
// The host's write never reached the visibility record either, which is
// what Nodes 2.0 reads.
expect(w.suppressedByConnection).toBe(false);
});
it("still honours the widget's own disabled flag", () => {
const w = new FakeWidget();
keepEditorWhenLinked(w);
w.disabled = true;
hostMarks(w, false);
expect(w.computedDisabled).toBe(true);
expect(w.isVisible()).toBe(false);
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Keep a DOM editor on the node while a link drives its input.
*
* ComfyUI treats a linked widget input as "the wire replaces the control":
* each draw, `updateComputedDisabled` sets `computedDisabled` and
* `connectionSuppressed` on the widget, a DOM widget whose `computedDisabled`
* is set reports `isVisible() === false` (so its element is hidden), and a
* suppressed widget's row only draws its name. For the stock text box that is
* fine. For the Assembler's editors it left a hole the size of the editor in
* the middle of the node, with nothing drawn in it (the host's row label is
* covered by our own layout), and the wire landing in empty space.
*
* Our editors render the linked state themselves ("from the template input",
* `templateEditor.ts` checks the link), so the host should keep drawing them.
* This pins both flags on the widget instance: `computedDisabled` follows only
* the widget's own `disabled`, and `connectionSuppressed` stays false. Writes
* from the host are dropped. Nothing else changes: the input keeps its link,
* its dot and its value, and the linked value still wins at run time.
*/
interface PinnableWidget {
disabled?: boolean;
computedDisabled?: boolean;
connectionSuppressed?: boolean;
}
export function keepEditorWhenLinked(widget: object): void {
const w = widget as PinnableWidget;
Object.defineProperty(w, "computedDisabled", {
configurable: true,
enumerable: true,
get: () => w.disabled === true,
set: () => {},
});
Object.defineProperty(w, "connectionSuppressed", {
configurable: true,
enumerable: true,
get: () => false,
set: () => {},
});
}
+20 -10
View File
@@ -31,6 +31,7 @@ import { app } from "#comfyui/app";
import { createDomWidgetHost, type MountTargetNode } from "./_shared";
import { attachFieldSockets, type FieldSocketNode } from "../extension/field-sockets";
import { attachThemeDetector } from "../extension/theme-detector";
import { keepEditorWhenLinked } from "./linked-editor";
import { templateInsertAtCaret } from "../extension/_stashes";
import {
collectUpstreamProducers,
@@ -53,6 +54,7 @@ type EditorNode = LiteNodeLike & MountTargetNode;
const RichTextInput = defineAsyncComponent(() => import("../manager/components/RichTextInput.vue"));
const NegativeTemplateBox = defineAsyncComponent(() => import("../components/assembler/NegativeTemplateBox.vue"));
const EmptyState = defineAsyncComponent(() => import("@/kit/EmptyState.vue"));
const PLACEHOLDER = "A $style portrait of $subject";
@@ -201,12 +203,11 @@ function buildEditor(
);
// A link into the `template` input overrides whatever is typed here at
// runtime, so the editor must stop accepting edits — the stock
// multiline widget goes read-only in the same situation.
// runtime, so the editor gives way to a "from the input" note (the
// negative box shows the same in its own body).
//
// Derived from the link itself rather than from the widget's
// `computedDisabled`: that flag is recomputed lazily during a draw, so
// it still read `true` a full second after the link was removed.
// `computedDisabled`, which is pinned (see linked-editor.ts).
const linkDriven = reactiveFromGraph<boolean>(
node as unknown as Parameters<typeof reactiveFromGraph>[0],
() => {
@@ -254,8 +255,17 @@ function buildEditor(
});
}
return () =>
h(RichTextInput, {
// Linked: the wired text wins at run time, so show where it comes from
// instead of a greyed copy of text that no longer counts.
return () => linkDriven.value
? h(EmptyState, {
icon: "link",
headline: `From the ${inputName} input`,
body: "The wired text is the template; its $variables still fill in. Disconnect it to type here.",
size: "inline",
"data-test": "template-linked",
})
: h(RichTextInput, {
ref: editorRef,
modelValue: model.value,
// The engine resolves `$var` reads here and nothing else: no
@@ -266,10 +276,7 @@ function buildEditor(
multiline: true,
rows: 5,
fill: true,
placeholder: linkDriven.value
? "Driven by the connected input — disconnect it to edit here."
: PLACEHOLDER,
disabled: linkDriven.value,
placeholder: PLACEHOLDER,
varSuggestions: varNames.value,
varProducers: varProducers.value,
// We walked a graph, so an unattributed `$var` means "nothing
@@ -363,6 +370,9 @@ function buildEditor(
onValueRestored: (v: string) => restored(v),
});
// A link into this input must not hide the editor (see linked-editor.ts).
keepEditorWhenLinked(editorHost.widget);
model.value = editorHost.getValue();
collapsed.value = initialNegativeCollapsed(
(node as { properties?: Record<string, unknown> }).properties,