Compare commits

..

22 Commits

Author SHA1 Message Date
github-actions
304580510a [automated] Update website screenshot expectations 2026-06-18 13:28:18 +00:00
Michael B
6850eb3466 If not pricing features title, don't put the empty p 2026-06-18 09:02:51 -04:00
Michael B
3b8a4edf0c feat(website): clarify API workflow limits as concurrent runs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 21:11:52 -04:00
Michael B
ed1b8e5644 feat(website): add enterprise contact for higher API rate limits
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 20:59:19 -04:00
Michael B
ef6e594204 feat(website): add video captions to learning tutorials and pricing API copy
Wires per-locale VTT caption tracks into the learning tutorial dialog so
the CC toggle in VideoPlayer is now functional. Also updates pricing plan
copy to surface "workflows via API" instead of "concurrent API jobs" and
adds a new "Run Workflows via API" included-feature entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 18:05:56 -04:00
Maanil Verma
0c23e8305f fix: skip templates modal when opening a template from the URL (#12835)
## Summary

On first launch, the templates modal flashed open for a split second
before a deeplinked template (`?template=`) loaded, which felt broken.

## Changes

- **What**: Gate the first-launch templates modal on template URL
intent, alongside the existing shared-workflow (`?share=`) check. When a
template is being opened directly from the URL, the template modal no
longer opens. Behavior is unchanged when no template is in the URL — the
template modal still shows for first-time users.
- Test util: Added browser_tests/fixtures/utils/flashDetector.ts —
installs a pre-navigation requestAnimationFrame sampler that flags if a
[data-testid] element ever renders, even for a single frame. This
catches a brief flash that toBeHidden() (final-state only) cannot.

## Review Focus

`hasTemplateUrlIntent()` mirrors the existing
`hasSharedWorkflowIntent()` (direct `route.query` check plus
preserved-query fallback for the `/user-select` redirect path). Two
regression tests cover both the URL-param and preserved-intent cases.

**Coverage:**

- Unit (useWorkflowPersistenceV2.test.ts): the modal is not opened when
a template param is in the URL, and when template intent is preserved
across the /user-select redirect.
- E2E (templates.spec.ts): templates dialog never flashes when
first-time user opens a template link — verified red-without-fix,
green-with-fix.

Screen Recording 



https://github.com/user-attachments/assets/636094d4-0ef0-4e42-af32-d4e6c7ec5731



closes #12836
2026-06-17 21:00:02 +00:00
Matt Miller
810ada61fb ci: add team-gated Cursor review (thin caller for github-workflows) (#12859)
## Summary

Adds a team-gated, label-triggered multi-model Cursor review as a **thin
caller** for the reusable workflow in `Comfy-Org/github-workflows` — the
single source of truth for the panel, judge, prompts, and scripts. This
repo carries only the ~50-line caller, so there's no review logic to
drift out of sync.

## Changes

- **What**: `.github/workflows/pr-cursor-review.yaml` triggers on the
`cursor-review` label and calls
`Comfy-Org/github-workflows/.github/workflows/cursor-review.yml`, pinned
to `047ca48` (github-workflows#9, current main). Inheriting the reusable
workflow brings severity badges, line-anchored inline comments,
diff-size caps, prompt-injection hardening, and optional Slack DMs.
- **Config**: `diff_excludes` restated (overriding replaces the default
wholesale) with this repo's heavy paths added (Playwright snapshots,
generated manager types). Judge and panel both default to Opus 4.8 via
the reusable workflow — no overrides needed.

## Review Focus

- **Access control (the point).** Two layers, no allowlist: (1) only
triage+ users can apply a label in a public repo; (2) the reusable
workflow's secret-bearing jobs don't run on fork PRs, so
`CURSOR_API_KEY` is reachable only on internal branches.
- **Replaces a standalone draft.** Earlier revisions of this branch
carried a self-contained workflow + review/judge scripts; that
duplicated the reusable workflow, so it's been swapped for the thin
caller.

## Prerequisites (already done)

- `CURSOR_API_KEY` secret set on this repo.
- `cursor-review` label created.
- `SLACK_BOT_TOKEN` already present (enables the DM feature).

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-17 20:38:14 +00:00
Alexander Brown
ec91ecd695 ci: carry forward unit and e2e coverage flags (#12926)
## Summary

Enable Codecov carryforward on the `unit` and `e2e` flags so a missing
or late E2E coverage upload no longer produces a false `codecov/patch`
failure.

## Changes

- **What**: Add a `flags` block to `codecov.yml` marking `unit` and
`e2e` as `carryforward: true`. When a flag's upload is absent for a
commit, Codecov reuses that flag's last known coverage for unchanged
files instead of treating those lines as patch misses.

## Review Focus

The `e2e` flag is uploaded by the separate `ci-tests-e2e-coverage`
`workflow_run` job, which runs after CI and currently fails or skips
intermittently (uploaded with `fail_ci_if_error: false`). When that
upload is missing, the head report holds only the `unit` session, so
E2E-only code paths (canvas, vue-nodes, minimap, glsl, etc.) report as
uncovered and the patch status fails against the full-coverage base.
Carryforward fixes that symptom; the flaky coverage workflow remains a
separate root-cause fix.

Validated with `curl --data-binary @codecov.yml
https://codecov.io/validate` → `Valid!`. Carryforward takes effect after
merge once a `main` build establishes a baseline for both flags.

Co-authored-by: Amp <amp@ampcode.com>
2026-06-17 20:34:46 +00:00
Alexander Brown
c408f39cee test: harden assets media-type filter spec against VirtualGrid flake (#12897)
## Summary

Harden the cloud assets media-type filter spec against a VirtualGrid
virtualization flake that intermittently failed CI at the
`waitForAssets(4)` precondition.

## Changes

- **What**: Replace the 7 `waitForAssets(MIXED_JOBS.length)`
preconditions in `assets-filter.spec.ts` with `waitForAssets()` (first
card visible = data loaded), and document why.

## Review Focus

CI artifact (`playwright-report-cloud`) from the failing run showed only
3 of 4 cards in the DOM — the 3D card was missing and the audio card
rendered taller (inline player). `VirtualGrid.vue` sizes its render
window from a single uniform `itemHeight` measured off the first card,
so the taller audio card pushes the 3D card out of the initial window
and it is virtualized out of the DOM until a re-measure (same cause as
#11635).

Requiring all 4 cards to be mounted simultaneously fights
virtualization. `tab.open()` already waits for the first card (data
loaded), and filtering reads the full asset store regardless of what is
mounted, so the per-filter count assertions still provide the real
coverage. No behavioral change to the tests — only the
readiness/precondition strategy.

Verified `pnpm exec eslint` and `pnpm typecheck:browser` pass. Cloud E2E
could not be run locally (needs a running frontend + backend); relies on
the cloud CI job for confirmation.

Related to #11635

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-06-17 19:38:22 +00:00
Deep Mehta
ccf2f12b25 fix(website): correct top-up credit rollover copy on cloud pricing (#12923)
## Summary

The `/cloud/pricing` page's "Add more credits anytime" feature claims
unused top-up credits **roll over** to the next month. Per product
(Pablo, #cloud), top-up credits **do not** roll over. Fix the copy to
match policy and the docs.

## Changes

- **What**: `pricing.included.feature5.description` (en + zh-CN) in
`apps/website/src/i18n/translations.ts`.
- Before: "Purchase additional credits at any time. Unused top-ups roll
over to the next month automatically for up to 1 year."
- After: "Purchase additional credits at any time. Top-up credits are
valid for 1 year from the date of purchase and do not roll over with
your monthly plan."
- Keeps the accurate 1-year validity window (matches
`docs/interface/credits.mdx`).
- Scope: this is the **only** rollover claim on the page. The tier cards
(lines 1224/1255/1286) only say "top-ups available" — no change needed.
- **Breaking**: none (copy only).

## Deliberately out of scope

The Slack thread surfaced an **unresolved** question: Pablo said top-ups
don't roll over *"as opposed to subscription credits which do,"* but
`docs/interface/credits.mdx` says monthly credits don't roll over
either. This PR makes **no claim about subscription/monthly credit
rollover** (the new copy is neutral: "do not roll over *with your
monthly plan*"). Resolving that — and the optional FAQ entry / docs
alignment Glary-Bot proposed — needs a product decision first. Flagging
for follow-up.
2026-06-17 18:38:56 +00:00
jaeone94
6850d22d99 Redesign error overlay count and toast behavior (#12871)
## Summary

Redesign the run error overlay so its count and copy use the same
grouped error semantics as the redesigned Error tab, while keeping
single-error toast copy precise.

This is a stacked follow-up to #12828. The parent PR redesigns the Error
tab cards and centralizes their grouped/row-based presentation. This PR
applies the same mental model to the compact overlay shown from the Run
button path, so users no longer see one count in the Error tab and a
different count in the overlay.

## Changes

- **What**: Align the error overlay count with the Error tab's grouped
error count.
- The overlay now reads the same grouped error surface that powers the
Error tab hero instead of independently summing raw store error counts.
- Validation/runtime/prompt execution groups use their grouped `count`.
- Missing model/media/node/swap groups keep the row/group count
semantics introduced by the Error tab redesign.
- This avoids cases where the overlay headline says one number while the
panel summarizes another.

- **What**: Preserve the existing single-error toast behavior while
adding explicit multi-row handling.
- A single true leaf still uses the catalog/resolver toast title and
toast message.
- A single grouped execution error with multiple node/input items uses
that group's title/message instead of the generic aggregate copy.
- A single missing model/media group with multiple model/file rows uses
the generic aggregate copy because it represents multiple actionable
rows.
- A single missing model/media row referenced by multiple nodes uses the
group title/message, since there is still only one model/file to
resolve.
- Multiple top-level groups continue to use the aggregate "N errors
found" style copy.

- **What**: Restyle the overlay toast to match the new error-surface
direction.
- Adds a compact dark card with a destructive left accent and a visible
outline for better contrast against the workspace.
  - Keeps the close button in the card header area.
- Keeps the primary "View details" action, but adjusts spacing, size,
and typography to better match the Figma direction.
- Removes the older footer-style dismiss action so the overlay behaves
like a focused status toast rather than a secondary dialog.

- **What**: Share error grouping/count helpers instead of duplicating
local logic.
- Extracts execution item-list detection for reuse between the Error tab
render path and count logic.
- Extracts missing-model grouping/count helpers so missing-model row
count semantics have one implementation.
- Removes `groupedErrorMessages`, which became unused after the overlay
copy decision moved to grouped error state.

- **Breaking**: None.

- **Dependencies**: None.

## Review Focus

- **Stack boundary**: Please review this against
`jaeone/fe-816-error-card-redesign`, not against `main` directly. The
parent PR is #12828 and contains the Error tab card redesign that this
overlay work builds on.

- **Count semantics**: The visible overlay count intentionally changes
from raw store counts to grouped Error tab counts. This is not just a
refactor; it is the intended product behavior so the compact overlay and
the panel hero agree.

- **Overlay message branches**: The overlay can now choose between three
message modes. The goal is to keep precise single-error copy where it is
useful, but avoid showing one node-specific toast message when the
overlay actually represents multiple actionable rows.

| Branch | When it applies | Overlay title source | Overlay message
source | Example output |
| --- | --- | --- | --- | --- |
| Aggregate summary | More than one top-level error group, or one
missing model/media group with multiple actionable model/file rows. |
Generic aggregate title using grouped count. | Generic aggregate
message. | Title: `2 errors found`<br>Message: `Resolve them before
running the workflow.`<br>Example case: one Missing Models group
containing `first.safetensors` and `second.safetensors`. |
| Group summary | Exactly one error group that is not a true single
leaf, but should still be described by the group. This includes one
execution catalog group with multiple items, or one missing model/media
row referenced by multiple nodes. | The group's `displayTitle`. | The
group's `displayMessage`. | Title: `Missing connection`<br>Message:
`Required input slots have no connection feeding them.`<br>Example case:
one validation group with `KSampler - model` and `KSampler - positive`
rows. |
| Single leaf toast | Exactly one group, one actionable row/card, and at
most one node reference. | Resolver/catalog `toastTitle`. |
Resolver/catalog `toastMessage`. | Title: `Model missing`<br>Message:
`CheckpointLoaderSimple is missing missing.safetensors.`<br>Example
case: one missing model file referenced by one node. |

- **Scope control**: This PR intentionally does not redesign the full
Error Overlay flow beyond the compact toast/card. It also does not
revisit the deeper Error tab card layouts already handled in #12828.

- **Accessibility**: The toast keeps `role="status"` for polite
announcement semantics. The duplicate `aria-live` attribute was removed
during cleanup because `role="status"` already implies polite
live-region behavior.

## Validation

- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm knip`
- `pnpm test:unit src/components/error/useErrorOverlayState.test.ts
src/platform/missingModel/missingModelGrouping.test.ts
src/components/error/ErrorOverlay.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts`
- Pre-commit staged checks passed.
- Pre-push `knip` passed.

## Screenshots (if applicable)
<img width="454" height="179" alt="스크린샷 2026-06-16 오후 6 00 10"
src="https://github.com/user-attachments/assets/a85376ba-2b22-4cf8-a6fa-79f83fb8b244"
/>
<img width="453" height="179" alt="스크린샷 2026-06-16 오후 6 00 31"
src="https://github.com/user-attachments/assets/d9a1d4bd-92ab-451a-bb79-e7cfbc3af7c6"
/>
<img width="486" height="148" alt="스크린샷 2026-06-16 오후 6 00 55"
src="https://github.com/user-attachments/assets/b66faf96-65c8-4a22-9ff9-e8ccd450986e"
/>
<img width="395" height="127" alt="스크린샷 2026-06-16 오후 6 01 22"
src="https://github.com/user-attachments/assets/c64443f9-0eba-4b2b-8049-c1887c788b1e"
/>
<img width="384" height="134" alt="스크린샷 2026-06-16 오후 6 01 30"
src="https://github.com/user-attachments/assets/42f4fcae-b003-4df9-8f3a-0fda85a90880"
/>
<img width="376" height="129" alt="스크린샷 2026-06-16 오후 6 01 53"
src="https://github.com/user-attachments/assets/ce9030d0-2a98-4b38-9e7d-7a9c3103960f"
/>
<img width="379" height="128" alt="스크린샷 2026-06-16 오후 6 02 01"
src="https://github.com/user-attachments/assets/3d4ce356-1c22-4e3b-a1d0-fece351d9fbb"
/>
<img width="463" height="133" alt="스크린샷 2026-06-16 오후 6 02 33"
src="https://github.com/user-attachments/assets/6ae13a44-02aa-4167-8878-4906db468ad6"
/>
2026-06-17 13:31:07 +00:00
Dante
543a39a6b0 fix(billing): DES review polish on workspace billing UI (#12917)
### before
<img width="539" height="475" alt="Screenshot 2026-06-17 at 9 52 50 PM"
src="https://github.com/user-attachments/assets/dd562cb4-870c-43de-aca4-81e0118735e9"
/>

### after 
<img width="529" height="592" alt="Screenshot 2026-06-17 at 9 53 40 PM"
src="https://github.com/user-attachments/assets/ba8ed01e-ac91-4654-bfaa-d8f73923f378"
/>


Design-review polish on the team-workspace billing UI (3 of the items
from the Figma 'Team Plan - Workspaces' review). All three components
are on main.

### 1. Remove dead 'Upgrade' badge in account popover
`CurrentUserPopoverWorkspace.vue` — the white badge beside 'Plans &
pricing' was gated on `canUpgrade`, which is hardcoded `false` (PRO is
the only tier), so it never rendered. Removed the badge markup and the
dead computed. (Figma node 2797-724189.)

### 2. Subscribe-to-Run button height
`SubscribeToRun.vue` — used `size="sm"`, which didn't match the sibling
run/queue button (an `h-8` button group it swaps with in the same slot
via `CloudRunButtonWrapper`). Switched to `size="unset"` + `h-8
rounded-lg gap-1.5 px-4` to match.

### 3. Duplicate border/radius on member 'subscription inactive' dialog
`useSubscriptionDialog.ts` — the layout dialog already zeroes the
*content* border (`border-none shadow-none`), but the *root* pt kept
only `bg-transparent`, so the dialog frame's default border+radius
doubled with the card's own `rounded-2xl border`. Added `border-none
rounded-none shadow-none` to root so only the card's single
border/radius shows. (Figma node 3253-19473.)

Surfaced during Billing V1 design review (team workspaces).

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-17 13:18:02 +00:00
Dante
c5eb05a2e9 fix(billing): truncate long workspace names in the switcher (#12918)
The workspace name was hard-clipped by the switcher panel's
`overflow-hidden` instead of truncating with an ellipsis. The name span
had `min-w-0` but its parent row `<button>` (`flex flex-1`) did not, so
the button kept its content width and the long name overflowed.

Add `min-w-0` to the row button (and the name span) so the flex chain
can shrink and the name truncates.

Follow-up to FE-769 (#12763, merged) — the component shipped without
this.

## Before / After
<img width="1370" height="538" alt="fe769-before-after"
src="https://github.com/user-attachments/assets/b05d6faf-9dfe-4c93-9941-3c0a9bbfcc2d"
/>

Verified live in the team-workspaces mock (long-named "Acme Studio
Workspace").

### after
<img width="703" height="302" alt="Screenshot 2026-06-17 at 9 54 34 PM"
src="https://github.com/user-attachments/assets/725c3175-b65f-4224-aca9-3de777c95e85"
/>
2026-06-17 12:56:04 +00:00
Dante
5a846db6cf feat(billing): role-aware run-lock for cancelled/inactive team plans (FE-978) (#12786)
## Summary

Cancelled / inactive team plans keep members but lock runs; the run
button and the subscription-required dialog are now role-aware — owners
are routed to the pricing/subscribe flow, members (who cannot subscribe)
see "contact your workspace owner to resubscribe".

## Changes

- **What**: `SubscribeToRun.vue` becomes a role-aware locked run button
(owner → "Subscribe to Run"; member → neutral locked "Run" +
contact-owner tooltip; both open the subscription dialog).
`SubscriptionRequiredDialogContentWorkspace.vue` branches on role
(member → read-only contact-owner panel, no pricing/subscribe
affordance; owner → existing pricing/preview; member view suppressed for
`out_of_credits` so the active-but-low-credits path is unchanged).
`subscription.inactive.*` i18n keys.
- **Breaking**: none.

## Review Focus

- Role source =
`useWorkspaceUI().permissions.value.canManageSubscription` (owner /
personal = true, member = false) — the same accessor
`SubscriptionPanelContentWorkspace.vue` uses.
- **No BE work**: the run-gate already exists server-side
(`InactiveSubscriptionError`; `is_active` checked before funds). The
lock is gated on `is_active`, the same field the orchestrator uses, so
FE/BE stay consistent; leftover-credits-while-inactive remains blocked
by design.
- Complements #12785 (FE-878 precondition→modal routing); disjoint file
sets. Design: DES-197, Figma 3253-18670 / 3253-18671 / 3246-13962.
- Tests: `SubscribeToRun` (4) / `CloudRunButtonWrapper` (3) /
`SubscriptionRequiredDialogContentWorkspace` role cases — member sees
contact-owner (no subscribe), owner sees pricing, run locks on
`!is_active` and unlocks when active (22 total); full `test:unit` green.

Fixes FE-978
2026-06-17 01:52:06 +00:00
Alexander Brown
e994e4df58 refactor: store-backed WidgetId subgraph host widgets; delete widgetValueIO layer (#12617)
## Summary

Make `WidgetId` (`graphId:nodeId:name`) the single canonical widget
identity and represent subgraph promoted host widgets as ordinary
store-backed widgets addressed by it. This deletes two whole indirection
layers — the `world/*` widget-entity-IO layer and the
`PromotedWidgetView` runtime — leaving one model: a widget's data lives
in `widgetValueStore` keyed by `WidgetId`, and a `SubgraphNode` input
references it via `input.widgetId`.

**Net +304 lines across 107 files** (5,044 added / 4,740 deleted):
production code is net **−798** (1,521 added / 2,319 deleted) while
tests are net **+1,102** (3,523 added / 2,421 deleted). 10 files deleted
outright, 14 added.

## What got deleted

The old design wrapped every promoted subgraph widget in a synthetic
`IBaseWidget` "view" object with live getters that followed the source
widget, plus a manager to keep view identities stable, plus an IO
indirection layer over the store. All of it is gone:

- `promotedWidgetView.ts` — the `PromotedWidgetView` class (draw /
pointer / DOM-sync / projection / deepest-source resolution getters)
- `PromotedWidgetViewManager.ts` — view reconciliation/caching
- `world/widgetValueIO.ts` — the IO wrapper over `widgetValueStore`
- `world/entityIds.ts` + `world/brand.ts` — the `WidgetEntityId`
branded-id layer and the `entityId` field
- `widgetNodeTypeGuard.ts` — only used by the deleted view
- the per-`SubgraphNode` view machinery (`_promotedViewManager`,
`_cacheVersion`, view-key generation, DOM position-override cleanup) and
every now-dead `isPromotedWidgetView` branch across the panel, menu,
store, and util consumers
- `domWidgetStore` position-override APIs (`setPositionOverride` /
`clearPositionOverride`), only used to render a promoted DOM widget on a
different host node

## Why it's simpler

- One source of truth. A promoted host widget is `WidgetState` in the
store, seeded from the source at promotion (`registerWidget` with a
deep-cloned snapshot) and independent thereafter. No synthetic widget
objects, no runtime source-following, no view cache to invalidate.
- Resolution is data-driven. `resolveConcretePromotedWidget` walks
`SubgraphNode` inputs (`input.widgetId` + `resolveSubgraphInputTarget`)
instead of chasing view objects through `node.widgets`. This also
**fixes two-layer nested promotion** — the previously-skipped parity
test now passes and resolves through to the deepest concrete widget.
- The right-panel Parameters tab renders a subgraph node's promoted
widgets through the **same** store-backed path as ordinary node widgets:
display reads `WidgetState` via `widget.widgetId`, and value writes go
through `widgetValueStore.setValue(widgetId)`.

## Changes

- **What**:
- `WidgetId` branded type + `widgetId()` / `parseWidgetId()` /
`isWidgetId()` and a `WidgetState` type; `widgetValueStore` is
`WidgetId`-native (`registerWidget` / `getWidget` / `setValue` /
`deleteWidget`).
- Promotion creates host `WidgetState` then an input projection
(`input.widgetId`); demotion clears it; serialization and legacy
`proxyWidgets` migration round-trip through `input.widgetId`.
- `promotedInputWidget.ts` projects a store-backed ordinary widget from
an input slot; `SubgraphNode.widgets` is now a projected getter over
inputs (kept Litegraph-shaped so the canvas renderer and extensions
still read `node.widgets`). `invalidatePromotedViews()` is retained as a
no-op for extension compatibility.
- `promotedWidgetControl.ts` applies `control_after_generate` (e.g. seed
increment) on the host node, since the interior control widget is
link-fed and its value is dead; `syncPromotedComboHostOptions` mirrors
interior combo options onto host state.
- `multilineTextarea.ts` extracts the reusable multiline DOM-widget
behavior out of `useStringWidget` and adds promoted multiline
materialization via a `createPromotedHostWidget` app-layer hook (keeping
Litegraph core free of Vue/Pinia/DOM).
- Late-bound `LiteGraph` singleton holder (`litegraphInstance.ts`) to
break a widget-init import cycle.
- **Breaking**:
  - `IBaseWidget.entityId` removed — use `widgetId`.
- `SubgraphNode.widgets` no longer exposes the old `PromotedWidgetView`
objects; promoted state lives in `widgetValueStore` keyed by
`input.widgetId` and `widgets` is a projection of inputs. The
`widget-promoted` event now carries the concrete interior widget.
Extension code reading `entityId` or relying on `PromotedWidgetView` is
affected.

## Review Focus

- Promotion / demotion / serialization round-tripping through
`input.widgetId` + `widgetValueStore`, incl. the legacy `proxyWidgets`
migration.
- Snapshot-at-promotion semantics (host widget does not follow the
source after creation), and the combo-options exception via
`syncPromotedComboHostOptions`.
- Two-layer nested resolution in `resolveConcretePromotedWidget` +
`SubgraphNode` nested-source resolution.
- The unified Parameters tab (`TabSubgraphInputs` → `SectionWidgets`):
value edit / rename / favorite / hide / reorder for promoted inputs are
wired through the store but warrant a visual/e2e pass.
- Litegraph-compat seams worth a careful read: projected
`SubgraphNode.widgets`, the canvas-edit `callback` bridge back to the
store, host-level `control_after_generate`, and the late-bound
`LiteGraph` holder / `domWidget.ts` import ordering.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-06-16 23:19:12 +00:00
Comfy Org PR Bot
2a7340ec6c 1.47.1 (#12862)
Patch version increment to 1.47.1

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-06-16 23:02:15 +00:00
AustinMroz
cb52a3821b fix groups dragging children with control held (#12867)
When control is held, an active drag operation should cease applying
movements to nodes contained by selected groups. This functionality was
broken in vue mode because of unnecessary reimplementation of the code
for calculating items contained by groups during drag operations
2026-06-16 22:32:31 +00:00
Dante
7a877d0715 refactor(assets): extract getAssetStoredFilename helper (FE-733) (#12287)
## Summary

L1 prerequisite cleanup, scoped to a single type-preserving refactor:
extract `getAssetStoredFilename(asset)` to collapse the duplicated
`isCloud && asset.asset_hash ? asset.asset_hash : asset.name` branch
from `useMediaAssetActions.ts` into one helper in
`assetMetadataUtils.ts`. No behavior change.

Once BE-933/934 emit `file_path` and the cloud spec sync brings the
field into generated types, only the helper internals change (collapse
to `asset.file_path ?? asset.name`).

## Scope change (per review)

The `mockFeatureFlags` test util and the exported `FeatureFlags` type
that this PR originally also added have been **split out**. They had no
live consumer in the open stack — FE-729~732 (#12322 / #12335 / #12375 /
#12417) don't use them, and FE-780 / FE-781 (#12485 / #12486) still
hand-roll inline `vi.hoisted` mocks — so shipping them here would add a
public surface with no caller. They will be reintroduced bundled with
the first PR that actually adopts the util, where `featureFlag`'s return
type and the "all flags off vs. production defaults" semantics can be
validated against a real consumer.

## Review fixes carried in this PR

- Mock `@/platform/distribution/types` via an `importOriginal` spread so
`isDesktop` / `isNightly` survive the wholesale replacement (only
`isCloud` was re-hoisted before).
- Trimmed the `getAssetStoredFilename` JSDoc; the BE-933/934
future-collapse is now a one-line `TODO` rather than a design-doc
paragraph.

## Review Focus

- The helper is intentionally named `getAssetStoredFilename` to
disambiguate from the existing `getAssetFilename` (which targets
`user_metadata.filename` / `metadata.filename` for serialized-identifier
contexts — missing-model matching, filename schema validation) and
`getAssetDisplayFilename` (UI labels). Folding the `isCloud &&
asset_hash` fallback into either of those would regress
display/identifier sites where the cloud hash is never meant to surface.

- Fixes FE-733
- Parent: FE-601 (L1 umbrella)
- RFC: [Asset Identity
Semantics](https://www.notion.so/comfy-org/RFC-Asset-Identity-Semantics-35a6d73d365080e59d59c98cebae779b)
- Survey: [Asset FE Divergence Survey M1
Scope](https://www.notion.so/comfy-org/Assets-FE-Divergence-Survey-M1-Scope-3616d73d365080d0a9cbf5f2394c12f8)
- Slack thread:
https://comfy-organization.slack.com/archives/C0AUUTS2RQV/p1778815571949519

## Screenshots (if applicable)

N/A — no UI change.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12287-refactor-assets-extract-getAssetStoredFilename-helper-add-mockFeatureFlags-test-util-3616d73d365081c9a1c6e1982728a38a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Matt Miller <matt@miller-media.com>
2026-06-16 22:16:38 +00:00
Alexis Rolland
ac4105cca8 fix: Add missing save nodes in text replacement module (CORE-301) (#12837)
## Summary

Fix issue where variables typed in the `filename_prefix` of the save
nodes were not interpreted. See issue raised by users here:
https://github.com/Comfy-Org/ComfyUI/pull/13850#issuecomment-4700771342

## Changes

- **What**: Added the following nodes to
`src/extensions/core/saveImageExtraOutput.ts`
  - `SaveImageAdvanced`
  - `SaveSVGNode`
  - `SaveAudioMP3`
  - `SaveAudioOpus`  
  - `SaveAudioAdvanced`
2026-06-16 22:04:20 +00:00
pythongosssss
36b57f1e83 feat: implement customer.io SDK & telemetry provider (#12878)
## Summary

Adds a cloud-only Customer.io telemetry provider that forwards key
frontend lifecycle events and registers the in-app messaging plugin,
enabling low-latency intent-moment campaigns.

## Changes

- **What**: 
- new `CustomerIoTelemetryProvider` (matching impl. and registration of
other telemetry, dynamic import, tree shaken)
- Triggered from 9 initial sources
- Update telemetry scanner
- **Dependencies**: `@customerio/cdp-analytics-browser`

## Review Focus

- Matches other telemetry providers & is correctly removed from OSS
builds

## Screenshots (if applicable)
2026-06-16 21:55:48 +00:00
jaeone94
8c04f3261a Fix undated failed runs in job history grouping (#12879)
## Summary

Fixes FE-874 by preventing terminal jobs that do not have an
`execution_end_time` from being grouped under the `Undated` section in
the expanded job history / queue UI.

The production change is intentionally small: when grouping completed or
failed jobs by date, the UI now falls back from `executionEndTimestamp`
to `createTime`.

```ts
ts = task.executionEndTimestamp ?? task.createTime
```

This keeps the existing preference for execution completion time when it
is available, while still giving pre-execution terminal jobs a
meaningful date bucket based on when the job was created.

## Root Cause

Some terminal jobs, especially failures that happen before execution
actually starts, can legitimately arrive without `execution_end_time`.
This matches the backend semantics: if there is no execution start,
there may be no execution end timestamp either.

Before this change, the frontend grouping logic treated missing
`executionEndTimestamp` as if there were no usable date at all for
terminal jobs. That caused failed jobs with a valid `create_time` to
appear in the `Undated` group.

At the same time, the list sorting logic already used `createTime`, so
those jobs could appear near the top of the list while still being
labeled as `Undated`. That mismatch made recent failed jobs look like
they had no date, even though the job creation timestamp was present.

## What Changed

- Updated `useJobList` date grouping for terminal jobs:
- `completed` and `failed` jobs still use `executionEndTimestamp` when
available.
- If `executionEndTimestamp` is missing, they now fall back to
`createTime`.
- Added regression coverage for terminal jobs without an execution end
timestamp:
- A failed job without `executionEndTimestamp` is grouped by
`createTime`.
- A completed job without `executionEndTimestamp` is also covered
because the production fallback applies to both terminal states in the
same code path.
- Cleaned up the `useJobList` test harness by replacing the mocked
`vue-i18n` module with a real `createI18n` instance per mount.
  - This follows the repo testing guidance to avoid mocking `vue-i18n`.
- Each composable mount now receives a fresh i18n instance, avoiding
shared mutable i18n state between tests.

## User Impact

Failed jobs that never reached execution will no longer show up under
`Undated` when they still have a valid creation timestamp. They will
instead appear under the correct date group, such as `Today`,
`Yesterday`, or a localized month/day label.

This should make the expanded job history easier to scan and avoid the
confusing case where recent failed runs appear at the top while also
being labeled as undated.

## E2E Regression Coverage Rationale

I did not add a Playwright regression test under `browser_tests/` for
this fix because the regression is isolated to the timestamp selection
used by `useJobList` when it builds date groups. There is no changed
user interaction, navigation flow, API request shape, route handling, or
browser-only behavior.

The existing browser coverage already verifies that the Job History
sidebar opens, renders active and terminal jobs, and filters
completed/failed jobs using the mocked jobs route fixture. Adding an E2E
for this specific case would require creating another mocked `/api/jobs`
response with a terminal job that has `create_time` but no
`execution_end_time`, opening the sidebar, and asserting the rendered
date header. That would mostly duplicate the composable-level assertion
through the DOM while adding extra moving parts around relative date
labels, locale/timezone formatting, and the virtualized job list.

The regression is therefore covered more directly and deterministically
at the unit level in `src/composables/queue/useJobList.test.ts`. The new
test drives the same grouping pipeline that the UI consumes and asserts
that terminal jobs without `executionEndTimestamp` are grouped by
`createTime` instead of falling into `Undated`. I also verified the test
fails against the pre-fix implementation with `['Undated']` and passes
with the fallback.

## Notes

This PR does not attempt to synthesize an execution end time. The
backend can validly omit `execution_end_time` for jobs that never
started execution. The frontend fix is limited to display grouping: if
there is no execution end timestamp, use the already-present creation
timestamp as the grouping date.

If product requirements later need the exact terminal failure timestamp
for pre-execution failures, that would require a separate backend/API
timestamp such as a terminal-state or update timestamp. This PR only
fixes the current display fallback.

## Validation

Local validation run before publishing:

```bash
pnpm test:unit src/composables/queue/useJobList.test.ts
pnpm exec eslint src/composables/queue/useJobList.ts src/composables/queue/useJobList.test.ts
pnpm exec oxlint src/composables/queue/useJobList.ts src/composables/queue/useJobList.test.ts --type-aware
git diff --check -- src/composables/queue/useJobList.ts src/composables/queue/useJobList.test.ts
```

The commit hook also ran successfully during the final amend and passed:

```bash
pnpm exec oxfmt --write ...
pnpm exec oxlint --type-aware --fix ...
pnpm exec eslint --cache --fix ...
pnpm typecheck
```

## Screenshots

Before
<img width="346" height="624" alt="Screenshot 2026-06-17 1:35:06 AM"
src="https://github.com/user-attachments/assets/02269f57-038a-4f06-9892-0758ad84d2c7"
/>

After
<img width="352" height="632" alt="Screenshot 2026-06-17 1:35:37 AM"
src="https://github.com/user-attachments/assets/251cd762-2c88-4af6-8218-4af1915727b6"
/>
2026-06-16 18:38:17 +00:00
jaeone94
941f220582 fix: bind replacement node widgets to reused id (#12872)
## Summary

Fixes a Nodes 2.0 node replacement regression where widgets that only
exist on the replacement node were not registered with the widget value
store, causing their Vue-rendered controls to fall back to component
defaults such as `0` instead of the replacement node's real widget
default.

The root cause is that `replaceWithMapping()` replaces the placeholder
node in-place by writing directly to `graph._nodes` and
`graph._nodes_by_id`. That path intentionally preserves the old node id,
but it also bypasses the normal `LGraph.add()` flow that binds widgets
to their owning node id. As a result, newly introduced bindable widgets
on the replacement node could exist on the LiteGraph node object while
remaining absent from `useWidgetValueStore`, which is the state Vue
Nodes reads from when rendering widget controls.

## Changes

- **What**: Bind every bindable widget on the replacement node to the
reused node id inside `replaceWithMapping()` after the replacement node
is inserted into the graph maps and before widget values are
transferred.
- **What**: Preserve the existing widget value transfer behavior for
mapped widgets. Because widgets are now bound before `newWidget.value =
oldValue` runs, transferred values are written through the normal widget
store state instead of only mutating the unbound widget object.
- **What**: Add a focused unit regression check that verifies
replacement-only widgets are bound with the reused node id during node
replacement.
- **What**: Extend the existing node replacement Playwright coverage to
assert the Vue Nodes rendered input for `KSampler.denoise` keeps the
expected replacement value after the replacement flow.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Please focus on the placement of the widget binding in
`replaceWithMapping()`. The binding happens after the new node has been
assigned the reused id and inserted into the graph's node maps, but
before mapped widget values are copied over from the old node. This
mirrors the important part of the normal graph add flow for widgets
while keeping the in-place replacement behavior intact.

The tests intentionally avoid asserting replacement-node fixture
defaults in isolation. The unit test verifies the actual new side effect
that prevents the regression: `setNodeId()` is called for a bindable
widget that was not present on the old node. The Playwright assertion
then covers the user-visible Nodes 2.0 symptom: the replacement widget
is rendered from the widget store instead of falling back to the Vue
numeric default.

Linear: FE-1070

## Validation

- `pnpm vitest run
src/platform/nodeReplacement/useNodeReplacement.test.ts`
- `pnpm typecheck:browser`
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5174 pnpm
exec playwright test --project=chromium
browser_tests/tests/nodeReplacement.spec.ts -g "Widget values are
preserved after replacement"`
- `pnpm lint`
- `pnpm typecheck`
- Commit hook also reran staged formatting/linting and `pnpm typecheck`
during the final amend.

## Screenshots (if applicable)

Before 


https://github.com/user-attachments/assets/dc4e8137-d8aa-4a70-9973-5559ed84b90e

After


https://github.com/user-attachments/assets/4c70b9e4-d971-4e94-8d2f-12b0f2b00a09
2026-06-16 17:11:43 +00:00
223 changed files with 7654 additions and 8116 deletions

View File

@@ -109,3 +109,27 @@ jobs:
exit 1
fi
echo '✅ No PostHog references found'
- name: Scan dist for Customer.io telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Customer.io references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e 'CustomerIoTelemetryProvider' \
-e '@customerio/cdp-analytics-browser' \
-e 'customerio-gist-web' \
-e '(?i)cdp\.customer\.io' \
-e 'Comfy\.CustomerIo' \
dist; then
echo '❌ ERROR: Customer.io references found in dist assets!'
echo 'Customer.io must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Customer.io references found'

55
.github/workflows/pr-cursor-review.yaml vendored Normal file
View File

@@ -0,0 +1,55 @@
# Description: Team-gated multi-model Cursor review — a thin caller for the
# reusable workflow in Comfy-Org/github-workflows, which is the single source of
# truth for the panel, judge, prompts, and scripts. Triggered by the
# 'cursor-review' label.
#
# Access control (team-only, two layers):
# 1. Only users with triage permission or higher can apply a label in a public
# repo, so the public cannot trigger this.
# 2. The reusable workflow's secret-bearing jobs do not run on fork PRs (forks
# get no secrets), so CURSOR_API_KEY is reachable only on internal branches.
name: 'PR: Cursor Review'
on:
pull_request:
types: [labeled, unlabeled]
permissions:
contents: read
pull-requests: write
concurrency:
# Re-labeling cancels an in-flight run for the same PR + label.
group: cursor-review-pr-${{ github.event.pull_request.number }}-${{ github.event.label.name }}
cancel-in-progress: true
jobs:
cursor-review:
if: github.event.action == 'labeled' && github.event.label.name == 'cursor-review'
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
# from the same commit as the workflow definition.
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
with:
# Overriding diff_excludes replaces the reusable default wholesale, so
# this restates the generated/vendored defaults and adds this repo's heavy
# paths (Playwright snapshots, generated manager types).
diff_excludes: >-
:!**/package-lock.json
:!**/yarn.lock
:!**/pnpm-lock.yaml
:!**/node_modules/**
:!**/.claude/**
:!**/dist/**
:!**/vendor/**
:!**/*.generated.*
:!**/*.min.js
:!**/*.min.css
:!**/*-snapshots/**
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
# Load the prompts/scripts from the same ref as `uses:`.
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
secrets:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
# Optional — enables start/complete Slack DMs to the triggerer.
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -15,7 +15,7 @@ import { t } from '../../i18n/translations'
import type { Locale } from '../../i18n/translations'
import PlayPauseButton from './PlayPauseButton.vue'
type VideoTrack = {
export type VideoTrack = {
src: string
kind: 'subtitles' | 'captions' | 'descriptions'
srclang: string
@@ -35,7 +35,7 @@ const {
locale?: Locale
src?: string
poster?: string
tracks?: VideoTrack[]
tracks?: readonly VideoTrack[]
autoplay?: boolean
loop?: boolean
minimal?: boolean

View File

@@ -64,6 +64,7 @@ onUnmounted(() => {
:locale
:src="tutorial.videoSrc"
:poster="tutorial.poster"
:tracks="tutorial.caption"
autoplay
class="w-full"
/>

View File

@@ -68,7 +68,8 @@ const plans: PricingPlan[] = [
: undefined,
features: [
{ text: 'pricing.plan.standard.feature1' },
{ text: 'pricing.plan.standard.feature2' }
{ text: 'pricing.plan.standard.feature2' },
{ text: 'pricing.plan.standard.feature3' }
]
},
{
@@ -122,11 +123,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Header -->
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
<h1
class="text-primary-comfy-canvas font-formula text-4xl font-light lg:text-5xl"
class="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
>
{{ t('pricing.title', locale) }}
</h1>
<p class="text-primary-comfy-canvas mt-3 text-base">
<p class="mt-3 text-base text-primary-comfy-canvas">
{{ t('pricing.subtitle', locale) }}
</p>
</div>
@@ -156,7 +157,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink flex items-center px-2 text-sm font-bold tracking-wider"
class="bg-primary-comfy-yellow font-formula-narrow flex items-center px-2 text-sm font-bold tracking-wider text-primary-comfy-ink"
>
<span class="ppformula-text-center">
{{ t('pricing.badge.popular', locale) }}
@@ -172,18 +173,18 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
</div>
<!-- Summary -->
<p class="text-primary-comfy-canvas px-6 text-sm">
<p class="px-6 text-sm text-primary-comfy-canvas">
{{ t(plan.summaryKey, locale) }}
</p>
<!-- Price -->
<div v-if="plan.priceKey" class="flex items-baseline gap-1 px-6 pt-2">
<span
class="text-primary-comfy-canvas font-formula text-5xl font-light"
class="font-formula text-5xl font-light text-primary-comfy-canvas"
>
{{ t(plan.priceKey, locale) }}
</span>
<span class="text-primary-comfy-canvas text-sm">
<span class="text-sm text-primary-comfy-canvas">
{{ t('pricing.plan.period', locale) }}
</span>
</div>
@@ -192,7 +193,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Credits -->
<p
v-if="plan.creditsKey"
class="text-primary-comfy-canvas px-6 text-sm"
class="px-6 text-sm text-primary-comfy-canvas"
>
{{ t(plan.creditsKey, locale) }}
</p>
@@ -201,7 +202,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Estimate -->
<p
v-if="plan.estimateKey"
class="text-primary-comfy-canvas/80 px-6 text-xs"
class="px-6 text-xs text-primary-comfy-canvas/80"
>
{{ t(plan.estimateKey, locale) }}
</p>
@@ -211,17 +212,10 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<div v-if="plan.features.length" class="px-6 py-3">
<p
v-if="plan.featureIntroKey"
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
class="mb-2 text-sm font-semibold text-primary-comfy-canvas"
>
{{ t(plan.featureIntroKey, locale) }}
</p>
<p
v-else
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
aria-hidden="true"
>
&nbsp;
</p>
<ul class="space-y-2">
<li
v-for="feature in plan.features"
@@ -229,7 +223,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
class="flex items-start gap-2"
>
<span class="text-primary-comfy-yellow mt-0.5 text-sm"></span>
<span class="text-primary-comfy-canvas text-sm">
<span class="text-sm text-primary-comfy-canvas">
{{ t(feature.text, locale) }}
</span>
</li>
@@ -269,7 +263,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex items-center px-2 text-[10px] font-bold tracking-wider"
class="bg-primary-comfy-yellow flex items-center px-2 text-[10px] font-bold tracking-wider text-primary-comfy-ink"
>
<span class="ppformula-text-center">
{{ t('pricing.badge.popular', locale) }}
@@ -287,13 +281,13 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<!-- Enterprise heading -->
<h2
v-if="plan.isEnterprise"
class="text-primary-comfy-canvas mt-3 text-2xl font-light"
class="mt-3 text-2xl font-light text-primary-comfy-canvas"
>
{{ t('pricing.enterprise.heading', locale) }}
</h2>
<!-- Summary -->
<p class="text-primary-comfy-canvas mt-2 text-sm">
<p class="mt-2 text-sm text-primary-comfy-canvas">
{{ t(plan.summaryKey, locale) }}
</p>
@@ -301,25 +295,25 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
<template v-if="plan.priceKey">
<div class="mt-6 flex items-baseline gap-1">
<span
class="text-primary-comfy-canvas font-formula text-5xl font-light"
class="font-formula text-5xl font-light text-primary-comfy-canvas"
>
{{ t(plan.priceKey, locale) }}
</span>
<span class="text-primary-comfy-canvas/55 text-sm">
<span class="text-sm text-primary-comfy-canvas/55">
{{ t('pricing.plan.period', locale) }}
</span>
</div>
<p
v-if="plan.creditsKey"
class="text-primary-comfy-canvas mt-4 text-xs font-medium"
class="mt-4 text-xs font-medium text-primary-comfy-canvas"
>
{{ t(plan.creditsKey, locale) }}
</p>
<p
v-if="plan.estimateKey"
class="text-primary-comfy-canvas mt-2 text-xs"
class="mt-2 text-xs text-primary-comfy-canvas"
>
{{ t(plan.estimateKey, locale) }}
</p>
@@ -368,7 +362,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
>
<!-- Left side -->
<div
class="bg-primary-comfy-ink rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 p-8"
class="rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 bg-primary-comfy-ink p-8"
>
<div>
<span
@@ -377,11 +371,11 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
{{ t(enterprisePlan.labelKey, locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-3 text-2xl font-light lg:text-3xl"
class="mt-3 text-2xl font-light text-primary-comfy-canvas lg:text-3xl"
>
{{ t('pricing.enterprise.heading', locale) }}
</h2>
<p class="text-primary-comfy-canvas mt-3 text-sm">
<p class="mt-3 text-sm text-primary-comfy-canvas">
{{ t(enterprisePlan.summaryKey, locale) }}
</p>
</div>
@@ -392,7 +386,7 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
</div>
<!-- Footnote -->
<p class="text-primary-comfy-canvas/70 mt-12 text-xs">
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
{{ t('pricing.footnote', locale) }}
</p>
</section>

View File

@@ -54,7 +54,11 @@ const features: IncludedFeature[] = [
},
{
titleKey: 'pricing.included.feature11.title',
descriptionKey: 'pricing.included.feature11.description',
descriptionKey: 'pricing.included.feature11.description'
},
{
titleKey: 'pricing.included.feature12.title',
descriptionKey: 'pricing.included.feature12.description',
isComingSoon: true
}
]
@@ -65,10 +69,10 @@ const features: IncludedFeature[] = [
<div class="mx-auto w-full lg:grid lg:grid-cols-[280px_1fr] lg:gap-x-16">
<!-- Heading -->
<div
class="bg-primary-comfy-ink sticky top-20 mb-10 py-2 lg:top-28 lg:mb-0 lg:self-start"
class="sticky top-20 mb-10 bg-primary-comfy-ink py-2 lg:top-28 lg:mb-0 lg:self-start"
>
<h2
class="text-primary-comfy-canvas text-3xl/tight font-light whitespace-pre-line"
class="text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
>
{{ t('pricing.included.heading', locale) }}
</h2>
@@ -81,7 +85,7 @@ const features: IncludedFeature[] = [
:key="feature.titleKey"
:class="
index < features.length - 1
? 'border-primary-comfy-canvas/15 border-b border-solid'
? 'border-b border-solid border-primary-comfy-canvas/15'
: ''
"
class="py-8 first:pt-0 lg:grid lg:grid-cols-[200px_1fr] lg:gap-x-10"
@@ -99,14 +103,14 @@ const features: IncludedFeature[] = [
v-else
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
/>
<p class="text-primary-comfy-canvas text-sm font-medium">
<p class="text-sm font-medium text-primary-comfy-canvas">
{{ t(feature.titleKey, locale) }}
</p>
</div>
<!-- Description -->
<p
class="text-primary-comfy-canvas/55 mt-3 text-sm/relaxed lg:mt-0"
class="mt-3 text-sm/relaxed text-primary-comfy-canvas/55 lg:mt-0"
v-html="t(feature.descriptionKey, locale)"
/>
</div>

View File

@@ -1,3 +1,4 @@
import type { VideoTrack } from '../components/common/VideoPlayer.vue'
import type { LocalizedText, TranslationKey } from '../i18n/translations'
export interface LearningTutorial {
@@ -7,6 +8,7 @@ export interface LearningTutorial {
videoSrc: string
href?: string
poster?: string
caption?: readonly VideoTrack[]
posterTime?: number
}
@@ -28,6 +30,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
caption: [
{
src: 'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
// href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
@@ -38,7 +48,15 @@ export const learningTutorials: readonly LearningTutorial[] = [
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
poster:
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
href: 'https://comfy.org/workflows/93f286fbc2c8-93f286fbc2c8/',
caption: [
{
src: 'https://media.comfy.org/website/learning/deaging_workflow_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag]
},
{
@@ -49,6 +67,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
poster:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
caption: [
{
src: 'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag]
},
{
@@ -59,6 +85,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
poster:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=be0889296f65',
caption: [
{
src: 'https://media.comfy.org/website/learning/mattes_and_utilities_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag]
},
{
@@ -69,6 +103,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
poster:
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
caption: [
{
src: 'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag]
},
{
@@ -79,6 +121,14 @@ export const learningTutorials: readonly LearningTutorial[] = [
poster:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
caption: [
{
src: 'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag]
}
] as const

View File

@@ -1244,6 +1244,10 @@ const translations = {
en: 'Add more credits anytime',
'zh-CN': '可随时增加积分'
},
'pricing.plan.standard.feature3': {
en: 'Run 1 workflow concurrently (via API)',
'zh-CN': '通过 API 并发运行 1 个工作流'
},
'pricing.plan.creator.label': { en: 'CREATOR', 'zh-CN': '创作者版' },
'pricing.plan.creator.summary': {
@@ -1272,8 +1276,8 @@ const translations = {
'zh-CN': '导入你自己的 LoRA'
},
'pricing.plan.creator.feature2': {
en: '3 concurrent API jobs',
'zh-CN': '3 个并发 API 任务'
en: 'Run up to 3 workflows concurrently (via API)',
'zh-CN': '通过 API 最多并发运行 3 个工作流'
},
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
@@ -1300,8 +1304,8 @@ const translations = {
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.plan.pro.feature2': {
en: '5 concurrent API jobs',
'zh-CN': '5 个并发 API 任务'
en: 'Run up to 5 workflows concurrently (via API)',
'zh-CN': '通过 API 最多并发运行 5 个工作流'
},
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
@@ -1385,9 +1389,9 @@ const translations = {
'zh-CN': '随时加购积分'
},
'pricing.included.feature5.description': {
en: 'Purchase additional credits at any time. Unused top-ups roll over to the next month automatically for up to 1 year.',
en: 'Purchase additional credits at any time. Top-up credits are valid for 1 year from the date of purchase and do not roll over with your monthly plan.',
'zh-CN':
'可随时购买额外积分。未使用的充值积分自动结转至下月,最长保留 1 年。'
'可随时购买额外积分。充值积分自购买之日起 1 年内有效,且不会随月度计划结转。'
},
'pricing.included.feature6.title': {
en: 'Pre-installed models',
@@ -1433,10 +1437,19 @@ const translations = {
'Creator 或 Pro 计划用户可从 CivitAI 或 Huggingface 导入自己的模型和 LoRA打造专属风格。'
},
'pricing.included.feature11.title': {
en: 'Run Workflows via API',
'zh-CN': '通过 API 运行工作流'
},
'pricing.included.feature11.description': {
en: 'Run Comfy workflows programmatically via API, with concurrency limits based on your plan. Perfect for integrating ComfyUI into your applications, automating batch processing, or building production pipelines. For higher rate limits, reach out to <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>.',
'zh-CN':
'通过 API 以编程方式运行 Comfy 工作流,并发上限由您的计划决定。非常适合将 ComfyUI 集成到您的应用、自动化批量处理或构建生产级流水线。如需更高的速率限制,请联系 <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>。'
},
'pricing.included.feature12.title': {
en: 'Parallel job execution',
'zh-CN': '并行任务执行'
},
'pricing.included.feature11.description': {
'pricing.included.feature12.description': {
en: 'Run multiple workflows in parallel to speed up your pipeline.',
'zh-CN': '并行运行多个工作流,加速你的流程。'
},

View File

@@ -0,0 +1,436 @@
{
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
"revision": 0,
"last_node_id": 10,
"last_link_id": 15,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [497.59999999999985, 468.79999999999995],
"size": [510.328125, 216.71875],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [499.9999999999999, 225.1999633789062],
"size": [507.40625, 197.171875],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [11]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [569.5999633789061, 732.7998535156249],
"size": [378, 144],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1452.7999999999997, 227.59999999999997],
"size": [252, 72],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [1743.1999999999998, 228.79999999999995],
"size": [252, 84],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [33.20003662109363, 570.8],
"size": [378, 130.65625],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [10]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [3, 5]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [8]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "5526b801-03ef-4797-9052-cbc171512972",
"pos": [1145.277734375, 340.85618896484374],
"size": [225, 184],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 10
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 11
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 12
},
{
"name": "latent_image",
"type": "LATENT",
"link": 13
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [14]
}
],
"properties": {
"proxyWidgets": [["-1", "seed"]]
},
"widgets_values": [1]
}
],
"links": [
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
[10, 4, 0, 10, 0, "MODEL"],
[11, 6, 0, 10, 1, "CONDITIONING"],
[12, 7, 0, 10, 2, "CONDITIONING"],
[13, 5, 0, 10, 3, "LATENT"],
[14, 10, 0, 8, 0, "LATENT"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "5526b801-03ef-4797-9052-cbc171512972",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [1031.12, 372.62745605468746, 120, 140]
},
"outputNode": {
"id": -20,
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
},
"inputs": [
{
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [1131.12, 392.62745605468746]
},
{
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "positive",
"pos": [1131.12, 412.62745605468746]
},
{
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "negative",
"pos": [1131.12, 432.62745605468746]
},
{
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
"name": "latent_image",
"type": "LATENT",
"linkIds": [2],
"localized_name": "latent_image",
"pos": [1131.12, 452.62745605468746]
},
{
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
"name": "seed",
"type": "INT",
"linkIds": [15],
"pos": [1131.12, 472.62745605468746]
}
],
"outputs": [
{
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
"name": "LATENT",
"type": "LATENT",
"linkIds": [7],
"localized_name": "LATENT",
"pos": [1792.7199999999998, 428.62745605468746]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "KSampler",
"pos": [1247.1199707031249, 272.23994140624995],
"size": [453.59375, 380.765625],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"increment",
20,
8,
"euler",
"normal",
1
]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 6,
"origin_id": -10,
"origin_slot": 2,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": -10,
"origin_slot": 3,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 4,
"target_id": 3,
"target_slot": 4,
"type": "INT"
}
],
"extra": {
"workflowRendererVersion": "Vue"
}
}
]
},
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"workflowRendererVersion": "Vue",
"frontendVersion": "1.40.0"
},
"version": 0.4
}

View File

@@ -0,0 +1,404 @@
{
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
"revision": 0,
"last_node_id": 10,
"last_link_id": 14,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [497.59999999999985, 468.79999999999995],
"size": [510.328125, 216.71875],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [499.9999999999999, 225.1999633789062],
"size": [507.40625, 197.171875],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [11]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [569.5999633789061, 732.7998535156249],
"size": [378, 144],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1452.7999999999997, 227.59999999999997],
"size": [252, 72],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [1743.1999999999998, 228.79999999999995],
"size": [252, 84],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [33.20003662109363, 570.8],
"size": [378, 130.65625],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [10]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [3, 5]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [8]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "5526b801-03ef-4797-9052-cbc171512972",
"pos": [1145.277734375, 340.85618896484374],
"size": [225, 184],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 10
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 11
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 12
},
{
"name": "latent_image",
"type": "LATENT",
"link": 13
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [14]
}
],
"properties": {
"proxyWidgets": [["3", "seed"]]
},
"widgets_values": []
}
],
"links": [
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
[10, 4, 0, 10, 0, "MODEL"],
[11, 6, 0, 10, 1, "CONDITIONING"],
[12, 7, 0, 10, 2, "CONDITIONING"],
[13, 5, 0, 10, 3, "LATENT"],
[14, 10, 0, 8, 0, "LATENT"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "5526b801-03ef-4797-9052-cbc171512972",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 14,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [1031.12, 372.62745605468746, 120, 120]
},
"outputNode": {
"id": -20,
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
},
"inputs": [
{
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [1131.12, 392.62745605468746]
},
{
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "positive",
"pos": [1131.12, 412.62745605468746]
},
{
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "negative",
"pos": [1131.12, 432.62745605468746]
},
{
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
"name": "latent_image",
"type": "LATENT",
"linkIds": [2],
"localized_name": "latent_image",
"pos": [1131.12, 452.62745605468746]
}
],
"outputs": [
{
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
"name": "LATENT",
"type": "LATENT",
"linkIds": [7],
"localized_name": "LATENT",
"pos": [1792.7199999999998, 428.62745605468746]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "KSampler",
"pos": [1247.1199707031249, 272.23994140624995],
"size": [453.59375, 380.765625],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [1, "increment", 20, 8, "euler", "normal", 1]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 6,
"origin_id": -10,
"origin_slot": 2,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": -10,
"origin_slot": 3,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
}
],
"extra": {
"workflowRendererVersion": "Vue"
}
}
]
},
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"workflowRendererVersion": "Vue",
"frontendVersion": "1.40.0"
},
"version": 0.4
}

View File

@@ -0,0 +1,439 @@
{
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
"revision": 0,
"last_node_id": 10,
"last_link_id": 15,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [498.26665242513025, 471.46666463216144],
"size": [510.328125, 252.71875],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [500.66667683919275, 227.8666280110677],
"size": [507.40625, 233.171875],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [11]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [570.266591389974, 735.4665120442708],
"size": [378, 216],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1453.466512044271, 230.26666768391925],
"size": [252, 138],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [1743.866658528646, 231.46666463216144],
"size": [252, 148],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [33.866689046223996, 573.4666951497395],
"size": [378, 196],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [10]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [3, 5]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [8]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "5526b801-03ef-4797-9052-cbc171512972",
"pos": [1145.9444173177085, 343.52284749348956],
"size": [225, 220],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 10
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 11
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 12
},
{
"name": "latent_image",
"type": "LATENT",
"link": 13
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [14]
}
],
"properties": {
"proxyWidgets": [
["-1", "seed"],
["3", "control_after_generate"]
]
},
"widgets_values": [1]
}
],
"links": [
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
[10, 4, 0, 10, 0, "MODEL"],
[11, 6, 0, 10, 1, "CONDITIONING"],
[12, 7, 0, 10, 2, "CONDITIONING"],
[13, 5, 0, 10, 3, "LATENT"],
[14, 10, 0, 8, 0, "LATENT"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "5526b801-03ef-4797-9052-cbc171512972",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [1031.12, 372.62745605468746, 120, 140]
},
"outputNode": {
"id": -20,
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
},
"inputs": [
{
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [1131.12, 392.62745605468746]
},
{
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "positive",
"pos": [1131.12, 412.62745605468746]
},
{
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "negative",
"pos": [1131.12, 432.62745605468746]
},
{
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
"name": "latent_image",
"type": "LATENT",
"linkIds": [2],
"localized_name": "latent_image",
"pos": [1131.12, 452.62745605468746]
},
{
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
"name": "seed",
"type": "INT",
"linkIds": [15],
"pos": [1131.12, 472.62745605468746]
}
],
"outputs": [
{
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
"name": "LATENT",
"type": "LATENT",
"linkIds": [7],
"localized_name": "LATENT",
"pos": [1792.7199999999998, 428.62745605468746]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "KSampler",
"pos": [1247.1199707031249, 272.23994140624995],
"size": [453.59375, 380.765625],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"increment",
20,
8,
"euler",
"normal",
1
]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 6,
"origin_id": -10,
"origin_slot": 2,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": -10,
"origin_slot": 3,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 4,
"target_id": 3,
"target_slot": 4,
"type": "INT"
}
],
"extra": {
"workflowRendererVersion": "Vue"
}
}
]
},
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"workflowRendererVersion": "Vue",
"frontendVersion": "1.35.0"
},
"version": 0.4
}

View File

@@ -1,14 +1,10 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
export class ContextMenu {
public readonly primeVueMenu: Locator
public readonly litegraphMenu: Locator
public readonly litegraphContextMenu: Locator
public readonly linkReleaseMenu: Locator
public readonly linkReleaseMenuSearch: Locator
public readonly menuItems: Locator
protected readonly anyMenu: Locator
@@ -16,10 +12,6 @@ export class ContextMenu {
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
this.litegraphMenu = page.locator('.litemenu')
this.litegraphContextMenu = page.locator('.litecontextmenu')
this.linkReleaseMenu = page.getByTestId(TestIds.linkReleaseMenu.root)
this.linkReleaseMenuSearch = page.getByTestId(
TestIds.linkReleaseMenu.search
)
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
this.anyMenu = this.primeVueMenu
.or(this.litegraphMenu)

View File

@@ -4,10 +4,6 @@
*/
export const TestIds = {
linkReleaseMenu: {
root: 'link-release-context-menu',
search: 'link-release-search'
},
sidebar: {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',

View File

@@ -0,0 +1,45 @@
import type { Page } from '@playwright/test'
function flagAttributeFor(testId: string) {
const encoded = Array.from(testId, (ch) =>
ch.charCodeAt(0).toString(16)
).join('')
return `data-flashed-${encoded}`
}
/**
* Flags the first time an element matching `[data-testid="<testId>"]` is
* present and rendered, sampled every frame via `requestAnimationFrame` from
* page load. Catches a dialog that mounts and unmounts within a few frames,
* which `toBeHidden()` (final state only) cannot.
*
* Must be called before navigation (e.g. before `comfyPage.setup()`).
*/
export async function trackElementFlash(
page: Page,
testId: string
): Promise<{ hasFlashed: () => Promise<boolean> }> {
const flagAttribute = flagAttributeFor(testId)
await page.addInitScript(
({ id, attribute }: { id: string; attribute: string }) => {
const sample = () => {
const el = document.querySelector(`[data-testid="${CSS.escape(id)}"]`)
if (el instanceof HTMLElement) {
const rect = el.getBoundingClientRect()
if (rect.width > 0 && rect.height > 0) {
document.documentElement.setAttribute(attribute, 'true')
}
}
requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
},
{ id: testId, attribute: flagAttribute }
)
return {
hasFlashed: async () =>
(await page.locator('html').getAttribute(flagAttribute)) === 'true'
}
}

View File

@@ -1,6 +1,5 @@
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
@@ -8,8 +7,13 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
interface ResolvedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
}
function widgetSourceToEntry(
source: PromotedWidgetSource
source: ResolvedWidgetSource
): PromotedWidgetEntry {
return [source.sourceNodeId, source.sourceWidgetName]
}
@@ -20,23 +24,22 @@ function previewExposureToEntry(
return [exposure.sourceNodeId, exposure.sourcePreviewName]
}
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
return (
!!value &&
typeof value === 'object' &&
'sourceNodeId' in value &&
'sourceWidgetName' in value &&
typeof value.sourceNodeId === 'string' &&
typeof value.sourceWidgetName === 'string'
)
}
function isNodeProperty(value: unknown): value is NodeProperty {
if (value === null || value === undefined) return false
const t = typeof value
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
}
/**
* Reads the promoted widgets of a subgraph host node from the live graph.
*
* Promoted widgets are now store-backed: a host input is promoted iff it
* carries a `widgetId`, and its interior source identity is resolved on demand
* by walking the subgraph input link (mirroring `resolveSubgraphInputTarget`).
* This intentionally avoids the removed `widget.sourceNodeId`/`sourceWidgetName`
* denormalization, so the helper reflects the real projection rather than a
* deleted widget-object contract.
*/
export async function getPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
@@ -44,21 +47,49 @@ export async function getPromotedWidgets(
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
(id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
return []
return [
{
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
const previewExposures = node?.serialize()?.properties?.previewExposures
if (!node?.isSubgraphNode?.())
return { widgetSources: [], previewExposures }
const { subgraph } = node
const resolveSource = (
inputName: string
): ResolvedWidgetSource | undefined => {
const inputSlot = subgraph.inputNode.slots.find(
(slot) => slot.name === inputName
)
if (!inputSlot) return undefined
for (const linkId of inputSlot.linkIds) {
const link = subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraph)
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find(
(entry) => entry.link === linkId
)
if (!targetInput) continue
if (inputNode.isSubgraphNode?.()) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
}
}
]
})
const serializedNode = node?.serialize()
return {
widgetSources,
previewExposures: serializedNode?.properties?.previewExposures
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: widget.name
}
}
return undefined
}
const widgetSources = (node.inputs ?? []).flatMap((input) => {
if (!input.widgetId) return []
const source = resolveSource(input.name)
return source ? [source] : []
})
return { widgetSources, previewExposures }
},
nodeId
)
@@ -67,7 +98,7 @@ export async function getPromotedWidgets(
? parsePreviewExposures(previewExposures)
: []
return [
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
...widgetSources.map(widgetSourceToEntry),
...exposures.map(previewExposureToEntry)
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -21,7 +21,7 @@ test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
await expect(comfyPage.searchBoxV2.input).toBeVisible()
})
test('"context menu" opens the link-release context menu on link release', async ({
test('"context menu" opens litegraph connection menu on link release', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
@@ -29,7 +29,7 @@ test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
'context menu'
)
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
})
test('"no action" suppresses both search box and context menu', async ({
@@ -41,7 +41,7 @@ test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
)
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.searchBoxV2.input).toBeHidden()
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeHidden()
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

After

Width:  |  Height:  |  Size: 324 KiB

View File

@@ -131,6 +131,14 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
'normal',
1
])
if (mode.vueNodesEnabled) {
await expect(
comfyPage.vueNodes
.getWidgetByName('KSampler', 'denoise')
.locator('input')
).toHaveValue(/^1(?:\.0+)?$/)
}
})
test('Success toast is shown after replacement', async ({

View File

@@ -296,7 +296,12 @@ test.describe('Release context menu', { tag: '@node' }, () => {
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
const contextMenu = comfyPage.page.locator('.litecontextmenu')
// Wait for context menu with correct title (slot name | slot type)
// The title shows the output slot name and type from the disconnected link
await expect(contextMenu.locator('.litemenu-title')).toContainText(
'CLIP | CLIP'
)
await comfyPage.page.mouse.move(10, 10)
await comfyPage.expectScreenshot(
comfyPage.canvas,
@@ -308,20 +313,14 @@ test.describe('Release context menu', { tag: '@node' }, () => {
test(
'Can search and add node from context menu',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
async ({ comfyPage, comfyMouse }) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
await comfyPage.contextMenu.linkReleaseMenuSearch.fill('CLIP Prompt')
await expect(
comfyPage.contextMenu.linkReleaseMenu.getByRole('menuitem').first()
).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialNodeCount + 1)
await comfyMouse.move({ x: 10, y: 10 })
await comfyPage.contextMenu.clickMenuItem('Search')
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode('CLIP Prompt')
await waitForSearchInsertion(comfyPage, initialNodeCount)
await expect(comfyPage.canvas).toHaveScreenshot(
'link-context-menu-search.png'
)
@@ -344,7 +343,8 @@ test.describe('Release context menu', { tag: '@node' }, () => {
await comfyPage.canvasOps.disconnectEdge()
// Context menu should appear, search box should not
await expect(comfyPage.searchBox.input).toHaveCount(0)
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
})
test('Explicit setting overrides versioned defaults', async ({
@@ -366,6 +366,7 @@ test.describe('Release context menu', { tag: '@node' }, () => {
await comfyPage.canvasOps.disconnectEdge()
// Context menu should appear due to explicit setting, not search box
await expect(comfyPage.searchBox.input).toHaveCount(0)
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -15,6 +15,10 @@ import { createMixedMediaJobs } from '@e2e/fixtures/helpers/AssetsHelper'
// fixtures — Playwright runs auto fixtures before the `comfyPage` fixture's
// internal `setup()`, so the page first-loads with mocks already in place.
// See cloud-asset-default.spec.ts for the same pattern.
//
// Use `waitForAssets()` not `waitForAssets(MIXED_JOBS.length)`: VirtualGrid can
// virtualize the 3D card out of the initial render (#11635). Filtering reads the
// full store, so the per-filter count assertions still cover the behavior.
const MIXED_JOBS = createMixedMediaJobs(['images', 'video', 'audio', '3D'])
@@ -113,7 +117,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
@@ -136,7 +140,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
@@ -153,7 +157,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('video')
@@ -167,7 +171,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('audio')
@@ -179,7 +183,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
test('Selecting only "3D" hides non-3D assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('3d')
@@ -193,7 +197,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')
@@ -211,7 +215,7 @@ test.describe('Assets sidebar - media type filter', { tag: '@cloud' }, () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets(MIXED_JOBS.length)
await tab.waitForAssets()
await tab.openFilterMenu()
await tab.toggleMediaTypeFilter('image')

View File

@@ -217,6 +217,14 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
}
})
// Each promoted input must surface its own source value, so assert the
// name->value mapping rather than the first textbox in DOM order.
const EXPECTED_VALUE_BY_INPUT: Record<string, RegExp> = {
value: /Inner 1/,
value_1: /Inner 2/,
value_1_1: /Inner 3/
}
test('Promoted widgets from inner SubgraphNode are visible with correct values', async ({
comfyPage
}) => {
@@ -228,11 +236,16 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
await comfyExpect(widgets).toHaveCount(4)
const valueWidget = outerNode
.getByRole('textbox', { name: 'value' })
.first()
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
for (const [inputName, expectedValue] of Object.entries(
EXPECTED_VALUE_BY_INPUT
)) {
const valueWidget = outerNode.getByRole('textbox', {
name: inputName,
exact: true
})
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(expectedValue)
}
})
test('Promoted widgets from inner SubgraphNode carry correct source identity', async ({
@@ -271,11 +284,16 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
await comfyExpect(widgetsAfter).toHaveCount(initialCount)
const valueWidget = outerNodeAfter
.getByRole('textbox', { name: 'value' })
.first()
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
for (const [inputName, expectedValue] of Object.entries(
EXPECTED_VALUE_BY_INPUT
)) {
const valueWidget = outerNodeAfter.getByRole('textbox', {
name: inputName,
exact: true
})
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(expectedValue)
}
})
}
)

View File

@@ -53,6 +53,22 @@ test.describe(
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
test('Promoted textarea materializes once when a node is converted to a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
await clipNode.click('title')
const subgraphNode = await clipNode.convertToSubgraph()
const promotedTextarea = comfyPage.vueNodes
.getNodeLocator(String(subgraphNode.id))
.getByRole('textbox', { name: 'text', exact: true })
await expect(promotedTextarea).toHaveCount(1)
await expect(promotedTextarea).toBeVisible()
})
test.describe(
'Promoted Text Widget Lifecycle',
{ tag: ['@vue-nodes'] },

View File

@@ -0,0 +1,50 @@
import { mergeTests } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
wstest(
'Seed handling',
{ tag: '@vue-nodes' },
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
async function verifySeedControl(initializeState = true) {
const seedWidget = comfyPage.vueNodes.getWidgetByName('', 'seed')
const { input, valueControl } =
comfyPage.vueNodes.getInputNumberControls(seedWidget)
if (initializeState) {
await input.fill('1')
await valueControl.click()
await comfyPage.page.getByRole('radio', { name: 'increment' }).click()
await comfyPage.keyboard.press('Escape')
}
await execution.run()
await expect.soft(input).toHaveValue('2')
}
await test.step('seed updates on generation', async () => {
await verifySeedControl()
})
await test.step('subgraph seed updates on generation', async () => {
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await verifySeedControl()
})
for (const w of ['link-seed', 'proxy-seed', 'zit-seed']) {
await test.step(`seed updates for old workflow: ${w}`, async () => {
await comfyPage.workflow.loadWorkflow('subgraphs/' + w)
await verifySeedControl(false)
})
}
}
)

View File

@@ -484,6 +484,14 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
'subgraphs/subgraph-with-promoted-text-widget'
)
// Assert against the visible textbox the user sees, not the internal
// graph/widget projection.
const promotedTextWidgets = comfyPage.page.getByRole('textbox', {
name: 'text',
exact: true
})
await comfyExpect(promotedTextWidgets).toHaveCount(1)
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
const originalPos = await originalNode.getPosition()
@@ -497,31 +505,58 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await comfyPage.page.keyboard.up('Alt')
}
async function collectSubgraphNodeIds() {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph.nodes
.filter(
(n) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((n) => String(n.id))
})
}
await expect
.poll(async () => (await collectSubgraphNodeIds()).length)
.toBeGreaterThan(1)
const subgraphNodeIds = await collectSubgraphNodeIds()
for (const nodeId of subgraphNodeIds) {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
}
await comfyExpect(promotedTextWidgets).toHaveCount(2)
})
test(
'Cloning a subgraph node preserves edited promoted widget values on original and clone',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const editedValue = 'Edited prompt that must survive cloning'
const originalTextbox = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(originalTextbox).toBeVisible()
await expect(originalTextbox).toHaveValue('')
await originalTextbox.fill(editedValue)
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
await originalNode.click('title')
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
async function collectSubgraphNodeIds() {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph.nodes
.filter(
(n) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((n) => String(n.id))
})
}
await expect
.poll(async () => (await collectSubgraphNodeIds()).length)
.toBeGreaterThan(1)
const subgraphNodeIds = await collectSubgraphNodeIds()
for (const nodeId of subgraphNodeIds) {
const textbox = comfyPage.vueNodes
.getNodeLocator(nodeId)
.getByRole('textbox', { name: 'text' })
await expect(
textbox,
`node ${nodeId} promoted text widget reset to default after clone`
).toHaveValue(editedValue)
}
}
)
})
test.describe('Duplicate ID Remapping', () => {

View File

@@ -5,6 +5,7 @@ import type { WorkflowTemplates } from '@/platform/workflow/templates/types/temp
import { getWav } from '@e2e/fixtures/components/AudioPreview'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { trackElementFlash } from '@e2e/fixtures/utils/flashDetector'
async function checkTemplateFileExists(
page: Page,
@@ -505,3 +506,32 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
expect(popup.url()).toEqual(tutorialUrl)
})
})
test.describe(
'Templates deeplink (new user)',
{ tag: ['@slow', '@workflow'] },
() => {
test('templates dialog never flashes when first-time user opens a template link', async ({
comfyPage
}) => {
const templatesFlash = await trackElementFlash(
comfyPage.page,
TestIds.templates.content
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({
clearStorage: true,
url: '/?template=default'
})
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
expect(await templatesFlash.hasFlashed()).toBe(false)
await expect(comfyPage.templates.content).toBeHidden()
})
}
)

View File

@@ -177,6 +177,30 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
}).toPass({ timeout: 5000 })
})
test('does not drag contents when control is held', async ({ comfyPage }) => {
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
await expect.poll(groupCount, 'create group').toBe(1)
await comfyPage.page.mouse.click(100, 100)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const initialNodeBounds = await ksampler.boundingBox()
expect(initialNodeBounds).toBeTruthy()
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
await comfyPage.page.mouse.move(groupPos.x, groupPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.move(groupPos.x + 100, groupPos.y)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Control')
await expect
.poll(() => getGroupTitlePosition(comfyPage, 'Group'))
.not.toEqual(groupPos)
expect(await ksampler.boundingBox()).toEqual(initialNodeBounds)
})
test('should keep groups aligned after loading legacy Vue workflows', async ({
comfyPage
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -4,3 +4,14 @@ comment:
require_changes: false
require_base: false
require_head: true
# Carry forward the last known coverage for a flag when its upload is missing or
# late. The `e2e` flag is uploaded by a separate workflow_run job that can fail
# or arrive after Codecov has already computed the patch status; without this,
# E2E-only code paths show up as patch misses and the patch status fails. See
# https://docs.codecov.com/docs/carryforward-flags
flags:
unit:
carryforward: true
e2e:
carryforward: true

5
global.d.ts vendored
View File

@@ -49,6 +49,11 @@ interface Window {
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Record<string, unknown>
customer_io?: {
write_key?: string
site_id?: string
user_id?: string
}
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.47.0",
"version": "1.47.1",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -66,6 +66,7 @@
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@customerio/cdp-analytics-browser": "catalog:",
"@formkit/auto-animate": "catalog:",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",

137
pnpm-lock.yaml generated
View File

@@ -21,6 +21,9 @@ catalogs:
'@comfyorg/comfyui-electron-types':
specifier: 0.6.2
version: 0.6.2
'@customerio/cdp-analytics-browser':
specifier: ^0.5.3
version: 0.5.3
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1
@@ -447,6 +450,9 @@ importers:
'@comfyorg/tailwind-utils':
specifier: workspace:*
version: link:packages/tailwind-utils
'@customerio/cdp-analytics-browser':
specifier: 'catalog:'
version: 0.5.3
'@formkit/auto-animate':
specifier: 'catalog:'
version: 0.9.0
@@ -1441,6 +1447,15 @@ packages:
peerDependencies:
postcss-selector-parser: ^7.0.0
'@customerio/cdp-analytics-browser@0.5.3':
resolution: {integrity: sha512-P4lBz+P2iCekq+DOETiAtSfdMyNVQd7OjXhocjffjPtyBJ0ADhpvYuNZAT9R+q2AFrDTx0m8cuyJAtEG+qiXPQ==}
'@customerio/cdp-analytics-core@0.5.3':
resolution: {integrity: sha512-mjR0dyzsX8UjMAh22bT5ByiIEYwtpnNhc9TlHTk2nGPhFnMctSsn9KuMXD9BmfSFcjdmTPg+iABOq68yyPBPHg==}
'@customerio/jist@0.1.8':
resolution: {integrity: sha512-MPiAm5rxu6+wQiEPwY+nV/5i7y67vJ0TvQpeQrOuATzWC45kgpu4YAJm+RlrpDOq35CK1C3utlPG/wI1F6ycXg==}
'@cyberalien/svg-utils@1.2.15':
resolution: {integrity: sha512-ZbKU6npzW5PNocdoLVJYfKzaP+c/RpT6JUkoaKrW1DOcw6lyXub8XtcNpI3xok6FnyNjS6ZbsrrtjTnS9yeZAQ==}
@@ -2382,6 +2397,14 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@lukeed/csprng@1.1.0':
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@lukeed/uuid@2.0.1':
resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==}
engines: {node: '>=8'}
'@mdx-js/react@3.1.1':
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
peerDependencies:
@@ -3275,6 +3298,18 @@ packages:
'@rushstack/ts-command-line@5.3.1':
resolution: {integrity: sha512-mid/JIZSJafwy3x9e4v0wVLuAqSSYYErEHV0HXPALYLSBN13YNkR5caOk0hf97lSRKrxhtvQjGaDKSEelR3sMg==}
'@segment/analytics.js-video-plugins@0.2.1':
resolution: {integrity: sha512-lZwCyEXT4aaHBLNK433okEKdxGAuyrVmop4BpQqQSJuRz0DglPZgd9B/XjiiWs1UyOankg2aNYMN3VcS8t4eSQ==}
'@segment/facade@3.4.10':
resolution: {integrity: sha512-xVQBbB/lNvk/u8+ey0kC/+g8pT3l0gCT8O2y9Z+StMMn3KAFAQ9w8xfgef67tJybktOKKU7pQGRPolRM1i1pdA==}
'@segment/isodate-traverse@1.1.1':
resolution: {integrity: sha512-+G6e1SgAUkcq0EDMi+SRLfT48TNlLPF3QnSgFGVs0V9F3o3fq/woQ2rHFlW20W0yy5NnCUH0QGU3Am2rZy/E3w==}
'@segment/isodate@1.0.3':
resolution: {integrity: sha512-BtanDuvJqnACFkeeYje7pWULVv8RgZaqKHWwGFnL/g/TH/CcZjkIVTfGDp/MAxmilYHUkrX70SqwnYSTNEaN7A==}
'@sentry-internal/browser-utils@10.32.1':
resolution: {integrity: sha512-sjLLep1es3rTkbtAdTtdpc/a6g7v7bK5YJiZJsUigoJ4NTiFeMI5uIDCxbH/tjJ1q23YE1LzVn7T96I+qBRjHA==}
engines: {node: '>=18'}
@@ -5037,6 +5072,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
customerio-gist-web@3.23.2:
resolution: {integrity: sha512-oCM7WNEx/3cmEG1qQCKWrMwOtU+h41TTKJICNEb7Wj/1jR6+RJsj3b+3N+5u9TxgvUMusmLFvnVvqshU017eHA==}
cva@1.0.0-beta.4:
resolution: {integrity: sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==}
peerDependencies:
@@ -6255,6 +6293,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
js-cookie@3.0.1:
resolution: {integrity: sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==}
engines: {node: '>=12'}
js-cookie@3.0.7:
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
engines: {node: '>=20'}
@@ -6887,6 +6929,9 @@ packages:
resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
engines: {node: '>= 10'}
new-date@1.0.3:
resolution: {integrity: sha512-0fsVvQPbo2I18DT2zVHpezmeeNYV2JaJSrseiHLc17GNOxJzUdx5mvSigPu8LtIfZSij5i1wXnXFspEs2CD6hA==}
nlcst-to-string@4.0.0:
resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
@@ -6936,6 +6981,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
obj-case@0.2.1:
resolution: {integrity: sha512-PquYBBTy+Y6Ob/O2574XHhDtHJlV1cJHMCgW+rDRc9J5hhmRelJB3k5dTK/3cVmFVtzvAKuENeuLpoyTzMzkOg==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -7776,6 +7824,9 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
@@ -8175,6 +8226,12 @@ packages:
unescape-js@1.1.4:
resolution: {integrity: sha512-42SD8NOQEhdYntEiUQdYq/1V/YHwr1HLwlHuTJB5InVVdOSbgI6xu8jK5q65yIzuFCfczzyDF/7hbGzVbyCw0g==}
unfetch@3.1.2:
resolution: {integrity: sha512-L0qrK7ZeAudGiKYw6nzFjnJ2D5WHblUBwmHIqtPS6oKUd+Hcpk7/hKsSmcHsTlpd1TbTNsiRBUKRq3bHLNIqIw==}
unfetch@4.2.0:
resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -8360,6 +8417,10 @@ packages:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
valibot@1.2.0:
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
peerDependencies:
@@ -8640,8 +8701,8 @@ packages:
vue-component-type-helpers@3.3.2:
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
vue-component-type-helpers@3.3.4:
resolution: {integrity: sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==}
vue-component-type-helpers@3.3.5:
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -9490,6 +9551,30 @@ snapshots:
dependencies:
postcss-selector-parser: 7.1.1
'@customerio/cdp-analytics-browser@0.5.3':
dependencies:
'@customerio/cdp-analytics-core': 0.5.3
'@lukeed/uuid': 2.0.1
'@segment/analytics.js-video-plugins': 0.2.1
'@segment/facade': 3.4.10
customerio-gist-web: 3.23.2
dset: 3.1.4
js-cookie: 3.0.1
node-fetch: 2.7.0
spark-md5: 3.0.2
tslib: 2.8.1
unfetch: 4.2.0
transitivePeerDependencies:
- encoding
'@customerio/cdp-analytics-core@0.5.3':
dependencies:
'@lukeed/uuid': 2.0.1
dset: 3.1.4
tslib: 2.8.1
'@customerio/jist@0.1.8': {}
'@cyberalien/svg-utils@1.2.15':
dependencies:
'@iconify/types': 2.0.0
@@ -10450,6 +10535,12 @@ snapshots:
- ws
- zod
'@lukeed/csprng@1.1.0': {}
'@lukeed/uuid@2.0.1':
dependencies:
'@lukeed/csprng': 1.1.0
'@mdx-js/react@3.1.1(@types/react@19.1.9)(react@19.2.4)':
dependencies:
'@types/mdx': 2.0.13
@@ -11079,6 +11170,23 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@segment/analytics.js-video-plugins@0.2.1':
dependencies:
unfetch: 3.1.2
'@segment/facade@3.4.10':
dependencies:
'@segment/isodate-traverse': 1.1.1
inherits: 2.0.4
new-date: 1.0.3
obj-case: 0.2.1
'@segment/isodate-traverse@1.1.1':
dependencies:
'@segment/isodate': 1.0.3
'@segment/isodate@1.0.3': {}
'@sentry-internal/browser-utils@10.32.1':
dependencies:
'@sentry/core': 10.32.1
@@ -11323,7 +11431,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.34(typescript@5.9.3)
vue-component-type-helpers: 3.3.4
vue-component-type-helpers: 3.3.5
'@swc/helpers@0.5.21':
dependencies:
@@ -13103,6 +13211,11 @@ snapshots:
csstype@3.2.3: {}
customerio-gist-web@3.23.2:
dependencies:
'@customerio/jist': 0.1.8
uuid: 14.0.0
cva@1.0.0-beta.4(typescript@5.9.3):
dependencies:
clsx: 2.1.1
@@ -14475,6 +14588,8 @@ snapshots:
js-cookie: 3.0.7
nopt: 7.2.1
js-cookie@3.0.1: {}
js-cookie@3.0.7: {}
js-stringify@1.0.2: {}
@@ -15281,6 +15396,10 @@ snapshots:
neotraverse@0.6.18: {}
new-date@1.0.3:
dependencies:
'@segment/isodate': 1.0.3
nlcst-to-string@4.0.0:
dependencies:
'@types/nlcst': 2.0.3
@@ -15323,6 +15442,8 @@ snapshots:
pathe: 2.0.3
tinyexec: 1.0.4
obj-case@0.2.1: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -16440,6 +16561,8 @@ snapshots:
space-separated-tokens@2.0.2: {}
spark-md5@3.0.2: {}
speakingurl@14.0.1: {}
sprintf-js@1.0.3: {}
@@ -16854,6 +16977,10 @@ snapshots:
dependencies:
string.fromcodepoint: 0.2.1
unfetch@3.1.2: {}
unfetch@4.2.0: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@@ -17044,6 +17171,8 @@ snapshots:
uuid@11.1.1: {}
uuid@14.0.0: {}
valibot@1.2.0(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@@ -17469,7 +17598,7 @@ snapshots:
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.4: {}
vue-component-type-helpers@3.3.5: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:

View File

@@ -15,6 +15,7 @@ catalog:
'@astrojs/sitemap': ^3.7.3
'@astrojs/vue': ^6.0.1
'@comfyorg/comfyui-electron-types': 0.6.2
'@customerio/cdp-analytics-browser': ^0.5.3
'@eslint/js': ^10.0.1
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178

View File

@@ -0,0 +1,69 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
const mockIsActiveSubscription = ref(true)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription
})
}))
vi.mock('@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue', () => ({
default: {
name: 'ComfyQueueButton',
template: '<div data-testid="queue-button" />'
}
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeToRun.vue', () => ({
default: {
name: 'SubscribeToRun',
template: '<div data-testid="subscribe-to-run-button" />'
}
}))
function renderWrapper() {
return render(CloudRunButtonWrapper)
}
describe('CloudRunButtonWrapper', () => {
beforeEach(() => {
mockIsActiveSubscription.value = true
})
it('renders the runnable queue button when the subscription is active', () => {
renderWrapper()
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
expect(
screen.queryByTestId('subscribe-to-run-button')
).not.toBeInTheDocument()
})
it('locks the run button when the subscription is inactive', () => {
mockIsActiveSubscription.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
expect(screen.queryByTestId('queue-button')).not.toBeInTheDocument()
})
it('unlocks the run button once the subscription becomes active again', async () => {
mockIsActiveSubscription.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
mockIsActiveSubscription.value = true
await nextTick()
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
expect(
screen.queryByTestId('subscribe-to-run-button')
).not.toBeInTheDocument()
})
})

View File

@@ -10,7 +10,7 @@ import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/types/widgetId'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -110,8 +110,8 @@ function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
}
}
function removeSelectedEntityId(entityId: WidgetEntityId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
function removeSelectedWidgetId(widgetId: WidgetId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
}
@@ -139,11 +139,11 @@ function handleClick(e: MouseEvent) {
}
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
const entityId = widget.entityId
if (!entityId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
const widgetId = widget.widgetId
if (!widgetId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
if (index === -1)
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
appModeStore.selectedInputs.push([widgetId, widget.name, undefined])
else appModeStore.selectedInputs.splice(index, 1)
}
@@ -172,7 +172,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
resolvedInputs.value.map(
(entry) =>
[entry.entityId, getWidgetBounding(entry)] as [
[entry.widgetId, getWidgetBounding(entry)] as [
string,
MaybeRef<BoundStyle> | undefined
]
@@ -220,7 +220,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<template v-for="entry in resolvedInputs" :key="entry.entityId">
<template v-for="entry in resolvedInputs" :key="entry.widgetId">
<IoItem
v-if="entry.status === 'resolved'"
:class="
@@ -239,7 +239,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
"
:title="entry.displayName"
:sub-title="t('linearMode.builder.unknownWidget')"
:remove="() => removeSelectedEntityId(entry.entityId)"
:remove="() => removeSelectedWidgetId(entry.widgetId)"
/>
</template>
</DraggableList>

View File

@@ -60,7 +60,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { entityId, node, widget, config } = entry
const { widgetId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
@@ -70,7 +70,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.entityId === entityId
return vueWidget.widgetId === widgetId
})
if (!matchingWidget) return []
@@ -79,7 +79,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
return [
{
key: entityId,
key: widgetId,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,

View File

@@ -1,12 +1,13 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/types/widgetId'
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
@@ -22,18 +23,29 @@ vi.mock('@/scripts/app', () => ({
}))
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
const entitySeed = `${rootGraphId}:1:seed` as WidgetId
function makeNode(id: number, widgetNames: string[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
inputs: [],
isSubgraphNode: () => false,
widgets: widgetNames.map((name) => ({
name,
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
widgetId: `${rootGraphId}:${id}:${name}` as WidgetId
}))
})
}
function makeSubgraphNode(id: number, inputs: INodeInputSlot[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
inputs,
isSubgraphNode: () => true,
widgets: []
})
}
function setRootGraphNodes(nodes: LGraphNode[]) {
vi.mocked(app.rootGraph).nodes = nodes
vi.mocked(app.rootGraph).getNodeById = vi.fn(
@@ -88,4 +100,27 @@ describe('useResolvedSelectedInputs', () => {
expect(resolved.value[0]?.status).toBe('unknown')
})
it('resolves promoted subgraph inputs from their host input widgetId', () => {
const node = makeSubgraphNode(1, [
fromPartial<INodeInputSlot>({
name: 'seed',
label: 'renamed_seed',
widgetId: entitySeed
})
])
setRootGraphNodes([node])
const appModeStore = useAppModeStore()
appModeStore.selectedInputs = [[entitySeed, 'seed']]
const resolved = useResolvedSelectedInputs()
expect(resolved.value[0]).toMatchObject({
status: 'resolved',
node,
displayName: 'seed',
widget: { name: 'seed', label: 'renamed_seed', widgetId: entitySeed }
})
})
})

View File

@@ -1,18 +1,19 @@
import { useEventListener } from '@vueuse/core'
import { computed, shallowRef, triggerRef } from 'vue'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/types/widgetId'
import { isWidgetId, parseWidgetId } from '@/types/widgetId'
export type ResolvedSelection =
| {
status: 'resolved'
entityId: WidgetEntityId
widgetId: WidgetId
node: LGraphNode
widget: IBaseWidget
displayName: string
@@ -20,7 +21,7 @@ export type ResolvedSelection =
}
| {
status: 'unknown'
entityId: WidgetEntityId
widgetId: WidgetId
displayName: string
config?: InputWidgetConfig
}
@@ -54,16 +55,19 @@ export function useResolvedSelectedInputs() {
if (!rootGraph) return []
return appModeStore.selectedInputs.flatMap(
([entityId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetEntityId(entityId)) return []
const { nodeId, name } = parseWidgetEntityId(entityId)
([widgetId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetId(widgetId)) return []
const { nodeId, name } = parseWidgetId(widgetId)
const node = rootGraph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.name === name)
const widgets = node?.isSubgraphNode()
? promotedInputWidgets(node)
: node?.widgets
const widget = widgets?.find((w) => w.name === name)
if (!node || !widget) {
return [{ status: 'unknown', entityId, displayName, config }]
return [{ status: 'unknown', widgetId, displayName, config }]
}
return [
{ status: 'resolved', entityId, node, widget, displayName, config }
{ status: 'resolved', widgetId, node, widget, displayName, config }
]
}
)

View File

@@ -7,12 +7,26 @@ import { createI18n } from 'vue-i18n'
import ErrorOverlay from './ErrorOverlay.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeError } from '@/schemas/apiSchema'
import type {
MissingPackGroup,
SwapNodeGroup
} from '@/components/rightSidePanel/errors/useErrorGroups'
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
const mockErrorGroups = vi.hoisted(() => ({
allErrorGroups: { value: [] as ErrorGroup[] },
missingPackGroups: { value: [] as MissingPackGroup[] },
missingModelGroups: { value: [] as MissingModelGroup[] },
missingMediaGroups: { value: [] as MissingMediaGroup[] },
swapNodeGroups: { value: [] as SwapNodeGroup[] }
}))
const mockAllErrorGroups = mockErrorGroups.allErrorGroups
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
useErrorGroups: () => mockErrorGroups
}))
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
@@ -62,7 +76,6 @@ function createTestI18n() {
dismiss: 'Dismiss'
},
errorOverlay: {
errorCount: '{count} ERROR | {count} ERRORS',
multipleErrorCount: '{count} error found | {count} errors found',
multipleErrorsMessage: 'Resolve them before running the workflow.',
viewDetails: 'View details'
@@ -108,6 +121,10 @@ function renderOverlay(props: { appMode?: boolean } = {}) {
describe('ErrorOverlay', () => {
beforeEach(() => {
mockAllErrorGroups.value = []
mockErrorGroups.missingPackGroups.value = []
mockErrorGroups.missingModelGroups.value = []
mockErrorGroups.missingMediaGroups.value = []
mockErrorGroups.swapNodeGroups.value = []
mockOpenPanel.mockClear()
mockCanvasStore.linearMode = false
mockCanvasStore.canvas = null
@@ -116,17 +133,12 @@ describe('ErrorOverlay', () => {
})
it('renders a single overlay message without list markup', async () => {
renderOverlay()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
count: 1,
priority: 0,
cards: [
{
@@ -137,6 +149,12 @@ describe('ErrorOverlay', () => {
]
}
]
renderOverlay()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -145,21 +163,19 @@ describe('ErrorOverlay', () => {
expect(screen.getByTestId('error-overlay-see-errors')).toHaveTextContent(
'View details'
)
expect(screen.getByTestId('error-overlay-dismiss')).toHaveAccessibleName(
'Close'
)
expect(screen.queryByRole('list')).not.toBeInTheDocument()
})
it('keeps the app mode button label', async () => {
renderOverlay({ appMode: true })
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
count: 1,
priority: 0,
cards: [
{
@@ -170,6 +186,12 @@ describe('ErrorOverlay', () => {
]
}
]
renderOverlay({ appMode: true })
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
executionErrorStore.showErrorOverlay()
await nextTick()

View File

@@ -7,47 +7,35 @@
<div v-if="isVisible" class="pointer-events-none flex w-full justify-end">
<div
role="status"
aria-live="polite"
data-testid="error-overlay"
class="pointer-events-auto flex w-fit max-w-120 min-w-80 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
class="pointer-events-auto relative flex w-fit max-w-120 min-w-80 flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
>
<!-- Header -->
<div class="flex h-12 items-center gap-2 px-4">
<span class="flex-1 text-sm font-bold text-destructive-background">
<div class="flex w-full items-start gap-2 pr-8">
<i
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
/>
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
{{ overlayTitle }}
</span>
<Button
variant="muted-textonly"
size="icon-sm"
:aria-label="t('g.close')"
@click="dismiss"
>
<i class="icon-[lucide--x] block size-5 leading-none" />
</Button>
</div>
<!-- Body -->
<div class="px-4 pb-3" data-testid="error-overlay-messages">
<div
class="flex w-full items-start gap-2 pr-8"
data-testid="error-overlay-messages"
>
<span class="size-4 shrink-0" aria-hidden="true" />
<p
class="m-0 line-clamp-3 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ overlayMessage }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-3">
<Button
variant="muted-textonly"
size="unset"
data-testid="error-overlay-dismiss"
@click="dismiss"
>
{{ t('g.dismiss') }}
</Button>
<div class="flex w-full items-center justify-end pt-2">
<Button
variant="secondary"
size="lg"
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="error-overlay-see-errors"
@click="seeErrors"
>
@@ -58,6 +46,17 @@
}}
</Button>
</div>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-2 right-2 size-6 rounded-sm"
data-testid="error-overlay-dismiss"
:aria-label="t('g.close')"
@click="dismiss"
>
<i class="icon-[lucide--x] block size-4 leading-none" />
</Button>
</div>
</div>
</Transition>

View File

@@ -8,12 +8,26 @@ import { useErrorOverlayState } from './useErrorOverlayState'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type { NodeError } from '@/schemas/apiSchema'
import type {
MissingPackGroup,
SwapNodeGroup
} from '@/components/rightSidePanel/errors/useErrorGroups'
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
const mockAllErrorGroups = vi.hoisted(() => ({ value: [] as ErrorGroup[] }))
const mockErrorGroups = vi.hoisted(() => ({
allErrorGroups: { value: [] as ErrorGroup[] },
missingPackGroups: { value: [] as MissingPackGroup[] },
missingModelGroups: { value: [] as MissingModelGroup[] },
missingMediaGroups: { value: [] as MissingMediaGroup[] },
swapNodeGroups: { value: [] as SwapNodeGroup[] }
}))
const mockAllErrorGroups = mockErrorGroups.allErrorGroups
vi.mock('@/components/rightSidePanel/errors/useErrorGroups', () => ({
useErrorGroups: () => ({ allErrorGroups: mockAllErrorGroups })
useErrorGroups: () => mockErrorGroups
}))
vi.mock('@/composables/graph/useNodeErrorFlagSync', () => ({
@@ -44,7 +58,6 @@ function createTestI18n() {
messages: {
en: {
errorOverlay: {
errorCount: '{count} ERROR | {count} ERRORS',
multipleErrorCount: '{count} error found | {count} errors found',
multipleErrorsMessage: 'Resolve them before running the workflow.'
}
@@ -92,20 +105,19 @@ function mountOverlayState() {
describe('useErrorOverlayState', () => {
beforeEach(() => {
mockAllErrorGroups.value = []
mockErrorGroups.missingPackGroups.value = []
mockErrorGroups.missingModelGroups.value = []
mockErrorGroups.missingMediaGroups.value = []
mockErrorGroups.swapNodeGroups.value = []
})
it('uses the raw message for a single uncataloged execution error', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
count: 1,
priority: 0,
cards: [
{
@@ -116,6 +128,12 @@ describe('useErrorOverlayState', () => {
]
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
}
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -125,17 +143,12 @@ describe('useErrorOverlayState', () => {
})
it('uses toast copy for a single validation error', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Required input is missing'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Required input is missing',
count: 1,
priority: 0,
cards: [
{
@@ -152,6 +165,12 @@ describe('useErrorOverlayState', () => {
]
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Required input is missing'])
}
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -164,17 +183,12 @@ describe('useErrorOverlayState', () => {
})
it('uses display copy before raw copy when toast copy is absent', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Raw validation error'])
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Friendly validation title',
count: 1,
priority: 0,
cards: [
{
@@ -190,6 +204,12 @@ describe('useErrorOverlayState', () => {
]
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Raw validation error'])
}
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -202,24 +222,12 @@ describe('useErrorOverlayState', () => {
})
it('uses toast copy for a single runtime error', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastExecutionError = {
prompt_id: 'prompt',
node_id: 1,
node_type: 'KSampler',
executed: [],
exception_message: 'CUDA out of memory',
exception_type: 'torch.OutOfMemoryError',
traceback: [],
timestamp: Date.now()
}
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Generation failed',
count: 1,
priority: 0,
cards: [
{
@@ -237,6 +245,19 @@ describe('useErrorOverlayState', () => {
]
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastExecutionError = {
prompt_id: 'prompt',
node_id: 1,
node_type: 'KSampler',
executed: [],
exception_message: 'CUDA out of memory',
exception_type: 'torch.OutOfMemoryError',
traceback: [],
timestamp: Date.now()
}
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -247,6 +268,44 @@ describe('useErrorOverlayState', () => {
})
it('uses group toast copy for a single missing media error', async () => {
mockErrorGroups.missingMediaGroups.value = [
{
mediaType: 'image',
items: [
{
name: 'image.png',
mediaType: 'image',
representative: {
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'image.png',
isMissing: true
},
referencingNodes: [
{
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image'
}
]
}
]
}
]
mockAllErrorGroups.value = [
{
type: 'missing_media',
groupKey: 'missing_media',
displayTitle: 'Media input missing',
displayMessage: 'A required media input has no file selected.',
toastTitle: 'Media input missing',
toastMessage: 'Load Image is missing a required media file.',
count: 1,
priority: 3
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
@@ -261,17 +320,6 @@ describe('useErrorOverlayState', () => {
isMissing: true
}
])
mockAllErrorGroups.value = [
{
type: 'missing_media',
groupKey: 'missing_media',
displayTitle: 'Media input missing',
displayMessage: 'A required media input has no file selected.',
toastTitle: 'Media input missing',
toastMessage: 'Load Image is missing a required media file.',
priority: 3
}
]
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -281,6 +329,147 @@ describe('useErrorOverlayState', () => {
)
})
it('uses group copy for one missing model referenced by multiple nodes', async () => {
mockErrorGroups.missingModelGroups.value = [
{
directory: 'checkpoints',
isAssetSupported: true,
models: [
{
name: 'missing.safetensors',
representative: {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'missing.safetensors',
directory: 'checkpoints',
isAssetSupported: true,
isMissing: true
},
referencingNodes: [
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
]
}
]
}
]
mockAllErrorGroups.value = [
{
type: 'missing_model',
groupKey: 'missing_model',
displayTitle: 'Missing Models',
displayMessage: 'Import a model, or open the node to replace it.',
toastTitle: 'Model missing',
toastMessage: 'CheckpointLoaderSimple is missing missing.safetensors.',
count: 1,
priority: 2
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent('Missing Models')
expect(screen.getByTestId('message')).toHaveTextContent(
'Import a model, or open the node to replace it.'
)
})
it('uses group copy for one execution group with multiple errors', async () => {
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:required_input_missing',
displayTitle: 'Missing connection',
displayMessage: 'Required input slots have no connection feeding them.',
count: 2,
priority: 1,
cards: [
{
id: '1',
title: 'KSampler',
errors: [
{ message: 'KSampler is missing model' },
{ message: 'KSampler is missing positive' }
]
}
]
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent('Missing connection')
expect(screen.getByTestId('message')).toHaveTextContent(
'Required input slots have no connection feeding them.'
)
})
it('uses aggregate copy for one missing model group with multiple rows', async () => {
mockErrorGroups.missingModelGroups.value = [
{
directory: 'checkpoints',
isAssetSupported: true,
models: [
{
name: 'first.safetensors',
representative: {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'first.safetensors',
directory: 'checkpoints',
isAssetSupported: true,
isMissing: true
},
referencingNodes: [{ nodeId: '1', widgetName: 'ckpt_name' }]
},
{
name: 'second.safetensors',
representative: {
nodeId: '2',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'second.safetensors',
directory: 'checkpoints',
isAssetSupported: true,
isMissing: true
},
referencingNodes: [{ nodeId: '2', widgetName: 'ckpt_name' }]
}
]
}
]
mockAllErrorGroups.value = [
{
type: 'missing_model',
groupKey: 'missing_model',
displayTitle: 'Missing Models',
displayMessage: 'Import a model, or open the node to replace it.',
toastTitle: 'Missing models',
toastMessage: '2 model files are missing.',
count: 2,
priority: 2
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('title')).toHaveTextContent('2 errors found')
expect(screen.getByTestId('message')).toHaveTextContent(
'Resolve them before running the workflow.'
)
})
it('does not show when a raw error has no resolved overlay message', async () => {
mountOverlayState()
@@ -295,26 +484,14 @@ describe('useErrorOverlayState', () => {
expect(screen.getByTestId('message')).toBeEmptyDOMElement()
})
it('uses aggregate copy for multiple errors', async () => {
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError([
'First error',
'Second error',
'Third error',
'Fourth error',
'Fifth error',
'Sixth error',
'Seventh error'
])
}
it('uses grouped error counts for aggregate copy', async () => {
mockAllErrorGroups.value = [
{
type: 'execution',
groupKey: 'execution:KSampler',
displayTitle: 'Execution failed',
displayMessage: 'First group message',
count: 2,
priority: 0,
cards: [
{
@@ -323,13 +500,31 @@ describe('useErrorOverlayState', () => {
errors: [{ message: 'First error' }]
}
]
},
{
type: 'execution',
groupKey: 'execution:CLIPTextEncode',
displayTitle: 'Invalid CLIP input',
displayMessage: 'Second group message',
count: 3,
priority: 1,
cards: [
{
id: '2',
title: 'CLIPTextEncode',
errors: [{ message: 'Second error' }]
}
]
}
]
mountOverlayState()
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.showErrorOverlay()
await nextTick()
expect(screen.getByTestId('visible')).toHaveTextContent('true')
expect(screen.getByTestId('title')).toHaveTextContent('7 errors found')
expect(screen.getByTestId('title')).toHaveTextContent('5 errors found')
expect(screen.getByTestId('message')).toHaveTextContent(
'Resolve them before running the workflow.'
)

View File

@@ -4,11 +4,17 @@ import { storeToRefs } from 'pinia'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
import type {
MissingPackGroup,
SwapNodeGroup
} from '@/components/rightSidePanel/errors/useErrorGroups'
import type { ErrorGroup } from '@/components/rightSidePanel/errors/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
function resolveSingleOverlayCopy(
group: ErrorGroup
): { title?: string; message: string } | undefined {
type OverlayCopy = { title?: string; message: string }
function resolveSingleOverlayCopy(group: ErrorGroup): OverlayCopy | undefined {
if (group.type === 'execution') {
const [card] = group.cards
const [error] = card?.errors ?? []
@@ -37,27 +43,119 @@ function resolveSingleOverlayCopy(
}
}
function resolveGroupOverlayCopy(group: ErrorGroup): OverlayCopy | undefined {
const message =
group.displayMessage ?? group.toastMessage ?? group.displayTitle
if (!message) return undefined
return {
title: group.displayTitle,
message
}
}
function countMissingNodeReferences(groups: MissingPackGroup[]): number {
return groups.reduce((count, group) => count + group.nodeTypes.length, 0)
}
function countSwapNodeReferences(groups: SwapNodeGroup[]): number {
return groups.reduce((count, group) => count + group.nodeTypes.length, 0)
}
function getMissingModelRows(groups: MissingModelGroup[]) {
return groups.flatMap((group) => group.models)
}
function getMissingMediaRows(groups: MissingMediaGroup[]) {
return groups.flatMap((group) => group.items)
}
function hasSingleRowWithAtMostOneReference(
rows: Array<{ referencingNodes: readonly unknown[] }>
): boolean {
const row = rows[0]
return (
rows.length === 1 && row !== undefined && row.referencingNodes.length <= 1
)
}
interface OverlayGroupContext {
missingPackGroups: MissingPackGroup[]
missingModelGroups: MissingModelGroup[]
missingMediaGroups: MissingMediaGroup[]
swapNodeGroups: SwapNodeGroup[]
}
function isSingleLeafGroup(
group: ErrorGroup,
context: OverlayGroupContext
): boolean {
if (group.type === 'execution') {
return group.cards.length === 1 && group.cards[0]?.errors.length === 1
}
if (group.type === 'missing_node') {
return (
context.missingPackGroups.length === 1 &&
countMissingNodeReferences(context.missingPackGroups) === 1
)
}
if (group.type === 'swap_nodes') {
return (
context.swapNodeGroups.length === 1 &&
countSwapNodeReferences(context.swapNodeGroups) === 1
)
}
if (group.type === 'missing_model') {
return hasSingleRowWithAtMostOneReference(
getMissingModelRows(context.missingModelGroups)
)
}
return hasSingleRowWithAtMostOneReference(
getMissingMediaRows(context.missingMediaGroups)
)
}
function shouldUseAggregateCopyForSingleGroup(
group: ErrorGroup,
context: OverlayGroupContext
): boolean {
if (group.type === 'missing_node') {
return context.missingPackGroups.length > 1
}
if (group.type === 'swap_nodes') {
return context.swapNodeGroups.length > 1
}
if (group.type === 'missing_model') {
return getMissingModelRows(context.missingModelGroups).length > 1
}
if (group.type === 'missing_media') {
return getMissingMediaRows(context.missingMediaGroups).length > 1
}
return false
}
export function useErrorOverlayState() {
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const { totalErrorCount, isErrorOverlayOpen } =
storeToRefs(executionErrorStore)
const { allErrorGroups } = useErrorGroups('')
const { isErrorOverlayOpen } = storeToRefs(executionErrorStore)
const {
allErrorGroups,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups
} = useErrorGroups('')
const hasExactlyOneError = computed(() => totalErrorCount.value === 1)
const hasMultipleErrors = computed(() => totalErrorCount.value > 1)
const singleErrorGroup = computed(() =>
hasExactlyOneError.value && allErrorGroups.value.length === 1
? allErrorGroups.value[0]
: undefined
)
const errorCountLabel = computed(() =>
t(
'errorOverlay.errorCount',
{ count: totalErrorCount.value },
totalErrorCount.value
)
const totalErrorCount = computed(() =>
allErrorGroups.value.reduce((sum, group) => sum + group.count, 0)
)
const multipleErrorCountLabel = computed(() =>
@@ -68,25 +166,38 @@ export function useErrorOverlayState() {
)
)
const singleOverlayCopy = computed(() =>
singleErrorGroup.value
? resolveSingleOverlayCopy(singleErrorGroup.value)
: undefined
)
const aggregateOverlayCopy = computed<OverlayCopy>(() => ({
title: multipleErrorCountLabel.value,
message: t('errorOverlay.multipleErrorsMessage')
}))
const overlayMessage = computed(() => {
if (hasMultipleErrors.value) {
return t('errorOverlay.multipleErrorsMessage')
const overlayCopy = computed<OverlayCopy | undefined>(() => {
const groups = allErrorGroups.value
if (groups.length === 0) return undefined
if (groups.length > 1) return aggregateOverlayCopy.value
const [group] = groups
const context = {
missingPackGroups: missingPackGroups.value,
missingModelGroups: missingModelGroups.value,
missingMediaGroups: missingMediaGroups.value,
swapNodeGroups: swapNodeGroups.value
}
return singleOverlayCopy.value?.message ?? ''
if (shouldUseAggregateCopyForSingleGroup(group, context)) {
return aggregateOverlayCopy.value
}
if (isSingleLeafGroup(group, context)) {
return resolveSingleOverlayCopy(group) ?? resolveGroupOverlayCopy(group)
}
return resolveGroupOverlayCopy(group)
})
const overlayTitle = computed(() =>
hasMultipleErrors.value
? multipleErrorCountLabel.value
: (singleOverlayCopy.value?.title ?? errorCountLabel.value)
)
const overlayMessage = computed(() => overlayCopy.value?.message ?? '')
const overlayTitle = computed(() => overlayCopy.value?.title ?? '')
const isVisible = computed(
() =>

View File

@@ -57,154 +57,85 @@ function drawFrame(canvas: LGraphCanvas) {
canvas.onDrawForeground?.({} as CanvasRenderingContext2D, new Rectangle())
}
describe('DomWidgets transition grace characterization', () => {
describe('DomWidgets positioning', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('applies transition grace for exactly one frame when override exists but is not active', () => {
it('positions an active visible widget relative to its owning node', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [100, 200])
const overrideNode = createNode(graphB, 2, 'override', [600, 700])
const widget = createWidget('widget-transition', interiorNode, 14)
const overrideWidget = createWidget('override-widget', overrideNode, 22)
const graph = new LGraph()
const node = createNode(graph, 1, 'host', [100, 200])
const widget = createWidget('widget-pos', node, 14)
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: overrideNode,
widget: overrideWidget
const canvas = createCanvas(graph)
canvasStore.canvas = canvas
render(DomWidgets, {
global: { stubs: { DomWidget: true } }
})
drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
expect(widgetState.visible).toBe(true)
expect(widgetState.pos).toEqual([110, 224])
})
it('hides a widget whose owning node is in a different graph', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const currentGraph = new LGraph()
const otherGraph = new LGraph()
const node = createNode(otherGraph, 1, 'host', [100, 200])
const widget = createWidget('widget-other-graph', node, 14)
domWidgetStore.registerWidget(widget)
const canvas = createCanvas(currentGraph)
canvasStore.canvas = canvas
render(DomWidgets, {
global: { stubs: { DomWidget: true } }
})
drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
expect(widgetState.visible).toBe(false)
})
it('hides an inactive widget', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graph = new LGraph()
const node = createNode(graph, 1, 'host', [0, 0])
const widget = createWidget('widget-inactive', node, 10)
domWidgetStore.registerWidget(widget)
domWidgetStore.deactivateWidget(widget.id)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
widgetState.visible = true
widgetState.pos = [321, 654]
const canvas = createCanvas(graphA)
const canvas = createCanvas(graph)
canvasStore.canvas = canvas
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
global: { stubs: { DomWidget: true } }
})
drawFrame(canvas)
expect(widgetState.visible).toBe(true)
expect(widgetState.pos).toEqual([321, 654])
drawFrame(canvas)
expect(widgetState.visible).toBe(false)
})
it('uses override positioning while override node is in current graph even when widget is inactive', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [10, 20])
const overrideNode = createNode(graphB, 2, 'override', [300, 400])
const widget = createWidget('widget-override-active', interiorNode, 8)
const overrideWidget = createWidget(
'override-position-source',
overrideNode,
18
)
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(widget.id)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
const canvas = createCanvas(graphB)
canvasStore.canvas = canvas
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
})
drawFrame(canvas)
expect(widgetState.visible).toBe(true)
expect(widgetState.pos).toEqual([310, 428])
})
it('cleans orphaned transition-grace ids after widget removal', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [0, 0])
const overrideNode = createNode(graphB, 2, 'override', [200, 200])
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
})
const oldWidget = createWidget('shared-widget-id', interiorNode, 10)
const overrideWidget = createWidget(
'shared-override-widget',
overrideNode,
14
)
domWidgetStore.registerWidget(oldWidget)
domWidgetStore.setPositionOverride(oldWidget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(oldWidget.id)
drawFrame(canvas)
domWidgetStore.unregisterWidget(oldWidget.id)
drawFrame(canvas)
const replacementWidget = createWidget('shared-widget-id', interiorNode, 10)
domWidgetStore.registerWidget(replacementWidget)
domWidgetStore.setPositionOverride(replacementWidget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(replacementWidget.id)
const replacementState = domWidgetStore.widgetStates.get(
replacementWidget.id
)
if (!replacementState) throw new Error('Replacement widget missing state')
replacementState.visible = true
replacementState.pos = [999, 999]
drawFrame(canvas)
expect(replacementState.visible).toBe(true)
expect(replacementState.pos).toEqual([999, 999])
})
})

View File

@@ -21,7 +21,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
const domWidgetStore = useDomWidgetStore()
const overrideTransitionGrace = new Set<string>()
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
@@ -31,47 +30,16 @@ const updateWidgets = () => {
const lowQuality = lgCanvas.low_quality
const currentGraph = lgCanvas.graph
const seenWidgetIds = new Set<string>()
for (const widgetState of widgetStates.value) {
const widget = widgetState.widget
seenWidgetIds.add(widget.id)
// Use position override only when the override node (SubgraphNode) is
// in the current graph. When the user enters the subgraph, the override
// node is no longer visible — fall back to the widget's own node.
// Use graph reference equality (IDs are not unique across graphs).
const override = widgetState.positionOverride
const useOverride = !!override && currentGraph === override.node.graph
const inOverrideTransitionGap =
!!override && !useOverride && !widgetState.active
const useTransitionGrace =
inOverrideTransitionGap && !overrideTransitionGrace.has(widget.id)
if (useTransitionGrace) {
overrideTransitionGrace.add(widget.id)
} else if (!inOverrideTransitionGap) {
overrideTransitionGrace.delete(widget.id)
}
// Early exit for non-visible widgets.
// When a position override is active (widget promoted to SubgraphNode),
// the interior widget's `active` flag is false (its node is in the
// subgraph, not the current graph) — bypass that check.
if (
!widget.isVisible() ||
(!widgetState.active && !useOverride && !useTransitionGrace)
) {
if (!widget.isVisible() || !widgetState.active) {
widgetState.visible = false
continue
}
// During graph transitions, hold the previous position for one frame
// so promoted widgets don't briefly disappear before activation flips.
if (useTransitionGrace) continue
const posNode = useOverride ? override.node : widget.node
const posWidget = useOverride ? override.widget : widget
const posNode = widget.node
const isInCorrectGraph = posNode.graph === currentGraph
const nodeVisible = lgCanvas.isNodeVisible(posNode)
@@ -85,22 +53,16 @@ const updateWidgets = () => {
const margin = widget.margin
widgetState.pos = [
posNode.pos[0] + margin,
posNode.pos[1] + margin + posWidget.y
posNode.pos[1] + margin + widget.y
]
widgetState.size = [
(posWidget.width ?? posNode.width) - margin * 2,
(posWidget.computedHeight ?? 50) - margin * 2
(widget.width ?? posNode.width) - margin * 2,
(widget.computedHeight ?? 50) - margin * 2
]
widgetState.zIndex = getDomWidgetZIndex(posNode, currentGraph)
widgetState.readonly = lgCanvas.read_only
}
}
for (const widgetId of overrideTransitionGrace) {
if (!seenWidgetIds.has(widgetId)) {
overrideTransitionGrace.delete(widgetId)
}
}
}
const canvasStore = useCanvasStore()

View File

@@ -54,7 +54,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
})
}))
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
function createWidgetState(disabled: boolean): DomWidgetState {
const domWidgetStore = useDomWidgetStore()
const node = createMockLGraphNode({
id: 1,
@@ -70,14 +70,10 @@ function createWidgetState(overrideDisabled: boolean): DomWidgetState {
value: '',
options: {},
node,
computedDisabled: false
computedDisabled: disabled
})
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: createMockLGraphNode({ id: 2 }),
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
})
const state = domWidgetStore.widgetStates.get(widget.id)
if (!state) throw new Error('Expected registered DomWidgetState')
@@ -98,7 +94,7 @@ describe('DomWidget disabled style', () => {
vi.clearAllMocks()
})
it('uses disabled style when promoted override widget is computedDisabled', async () => {
it('uses disabled style when widget is computedDisabled', async () => {
const widgetState = createWidgetState(true)
const { container } = render(DomWidget, {
props: {

View File

@@ -69,11 +69,7 @@ const updateDomClipping = () => {
return
}
const override = widgetState.positionOverride
const overrideInGraph =
override && lgCanvas.graph?.getNodeById(override.node.id)
const ownerNode = overrideInGraph ? override.node : widgetState.widget.node
const isSelected = selectedNode === ownerNode
const isSelected = selectedNode === widgetState.widget.node
const renderArea = selectedNode?.renderArea
const offset = lgCanvas.ds.offset
const scale = lgCanvas.ds.scale
@@ -104,10 +100,7 @@ const updateDomClipping = () => {
const { left, top } = useElementBounding(canvasStore.getCanvas().canvas)
function composeStyle() {
const override = widgetState.positionOverride
const isDisabled = override
? (override.widget.computedDisabled ?? widget.computedDisabled)
: widget.computedDisabled
const isDisabled = widget.computedDisabled
style.value = {
...positionStyle.value,
@@ -167,13 +160,7 @@ onMounted(() => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const override = widgetState.positionOverride
const overrideInGraph =
override && lgCanvas.graph?.getNodeById(override.node.id)
const ownerNode = overrideInGraph
? override.node
: widgetState.widget.node
const ownerNode = widgetState.widget.node
lgCanvas.selectNode(ownerNode)
lgCanvas.bringToFront(ownerNode)
}

View File

@@ -333,6 +333,9 @@ describe('TabErrors.vue', () => {
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
1
)
expect(
within(screen.getByTestId('errors-summary-hero')).getByText('1')
).toBeInTheDocument()
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
})
@@ -550,6 +553,73 @@ describe('TabErrors.vue', () => {
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('4')
})
it('sums the summary hero count across error types', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'Node'
} as ReturnType<typeof getNodeByExecutionId>)
renderComponent({
executionError: {
lastNodeErrors: {
'1': {
class_type: 'KSampler',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: model',
extra_info: { input_name: 'model' }
},
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: positive',
extra_info: { input_name: 'positive' }
}
]
},
'2': {
class_type: 'CLIPTextEncode',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: clip',
extra_info: { input_name: 'clip' }
}
]
}
}
},
missingMedia: {
missingMediaCandidates: [
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'a.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'b.png',
isMissing: true
}
]
} satisfies { missingMediaCandidates: MissingMediaCandidate[] }
})
// 3 validation items + 2 missing media references
expect(
within(screen.getByTestId('errors-summary-hero')).getByText('5')
).toBeInTheDocument()
})
it('renders swap node rows below the section display message', () => {
const swapNode = {
type: 'OldSampler',

View File

@@ -61,7 +61,7 @@
:key="group.groupKey"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:title="group.displayTitle"
:count="getGroupCount(group)"
:count="group.count"
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
class="border-t border-secondary-background first:border-t-0"
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
@@ -328,7 +328,6 @@ import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
@@ -339,6 +338,7 @@ import { useErrorActions } from './useErrorActions'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
import { isExecutionItemListGroup } from './executionItemList'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
interface ExecutionItemListEntry {
@@ -372,21 +372,6 @@ const searchQuery = ref('')
const expandedExecutionItemDetailKeys = ref(new Set<string>())
const isSearching = computed(() => searchQuery.value.trim() !== '')
function isExecutionItemListGroup(group: ErrorGroup) {
return (
group.type === 'execution' &&
group.cards.length > 0 &&
group.cards.every(
(card) =>
card.nodeId &&
card.errors.length > 0 &&
card.errors.every(
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
)
)
)
}
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
if (group.type !== 'execution') return []
@@ -418,14 +403,6 @@ function compareExecutionItemListEntry(
)
}
function getExecutionGroupCount(group: ErrorGroup) {
if (group.type !== 'execution') return 0
if (isExecutionItemListGroup(group)) {
return group.cards.reduce((count, card) => count + card.errors.length, 0)
}
return group.cards.length
}
function isExecutionItemDetailExpanded(key: string) {
return expandedExecutionItemDetailKeys.value.has(key)
}
@@ -458,26 +435,8 @@ const {
swapNodeGroups
} = useErrorGroups(searchQuery)
function getGroupCount(group: ErrorGroup): number {
switch (group.type) {
case 'execution':
return getExecutionGroupCount(group)
case 'missing_node':
return missingPackGroups.value.length
case 'swap_nodes':
return swapNodeGroups.value.length
case 'missing_model':
return missingModelGroups.value.reduce(
(total, modelGroup) => total + modelGroup.models.length,
0
)
case 'missing_media':
return countMissingMediaReferences(missingMediaGroups.value)
}
}
const totalErrorCount = computed(() =>
filteredGroups.value.reduce((sum, group) => sum + getGroupCount(group), 0)
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
)
const showMissingModelHeaderRefresh = computed(

View File

@@ -0,0 +1,21 @@
import type { ErrorCardData, ErrorGroup } from './types'
export function shouldRenderExecutionItemList(cards: ErrorCardData[]): boolean {
return (
cards.length > 0 &&
cards.every(
(card) =>
card.nodeId &&
card.errors.length > 0 &&
card.errors.every(
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
)
)
)
}
export function isExecutionItemListGroup(group: ErrorGroup): boolean {
return (
group.type === 'execution' && shouldRenderExecutionItemList(group.cards)
)
}

View File

@@ -24,6 +24,7 @@ interface ErrorGroupBase extends Omit<ResolvedErrorMessage, 'displayTitle'> {
groupKey: string
/** Human-friendly title resolved for UI display. */
displayTitle: string
count: number
priority: number
}

View File

@@ -793,53 +793,6 @@ describe('useErrorGroups', () => {
})
})
describe('groupedErrorMessages', () => {
it('returns empty array when no errors', () => {
const { groups } = createErrorGroups()
expect(groups.groupedErrorMessages.value).toEqual([])
})
it('collects unique display messages from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{ type: 'err_a', message: 'Error A', details: '' },
{ type: 'err_b', message: 'Error B', details: '' }
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [{ type: 'err_a', message: 'Error A', details: '' }]
}
}
await nextTick()
const messages = groups.groupedErrorMessages.value
expect(messages).toEqual([unknownValidationMessage])
})
it('includes missing node group display message', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
expect(groups.groupedErrorMessages.value).toContain(
missingGroup!.displayMessage
)
})
})
describe('missingModelGroups', () => {
it('returns empty array when no missing models', () => {
const { groups } = createErrorGroups()

View File

@@ -25,14 +25,15 @@ import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import { shouldRenderExecutionItemList } from './executionItemList'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type {
MissingModelCandidate,
MissingModelGroup
} from '@/platform/missingModel/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import {
countMissingModels,
groupMissingModelCandidates
} from '@/platform/missingModel/missingModelGrouping'
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import {
@@ -49,9 +50,6 @@ const PROMPT_CARD_ID = '__prompt__'
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
/** Sentinel key for grouping non-asset-supported missing models. */
const UNSUPPORTED = Symbol('unsupported')
export interface MissingPackGroup {
packId: string | null
nodeTypes: MissingNodeType[]
@@ -152,16 +150,28 @@ function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
return compareExecutionId(a.nodeId, b.nodeId)
}
function countExecutionCards(cards: ErrorCardData[]): number {
if (shouldRenderExecutionItemList(cards)) {
return cards.reduce((count, card) => count + card.errors.length, 0)
}
return cards.length
}
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([rawGroupKey, groupData]) => ({
type: 'execution' as const,
groupKey: `execution:${rawGroupKey}`,
displayTitle: groupData.displayTitle,
displayMessage: groupData.displayMessage,
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
priority: groupData.priority
}))
.map(([rawGroupKey, groupData]) => {
const cards = Array.from(groupData.cards.values()).sort(compareNodeId)
return {
type: 'execution' as const,
groupKey: `execution:${rawGroupKey}`,
displayTitle: groupData.displayTitle,
displayMessage: groupData.displayMessage,
count: countExecutionCards(cards),
cards,
priority: groupData.priority
}
})
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority
return a.displayTitle.localeCompare(b.displayTitle)
@@ -220,11 +230,13 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
return groups
.map((group, gi) => {
if (group.type !== 'execution') return group
const cards = group.cards.filter((_: ErrorCardData, ci: number) =>
matchedCardKeys.has(`${gi}:${ci}`)
)
return {
...group,
cards: group.cards.filter((_: ErrorCardData, ci: number) =>
matchedCardKeys.has(`${gi}:${ci}`)
)
cards,
count: countExecutionCards(cards)
}
})
.filter((group) => group.type !== 'execution' || group.cards.length > 0)
@@ -591,6 +603,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
groups.push({
type: 'swap_nodes' as const,
groupKey: 'swap_nodes',
count: swapNodeGroups.value.length,
priority: 0,
...resolveMissingErrorMessage({
kind: 'swap_nodes',
@@ -605,6 +618,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
groups.push({
type: 'missing_node' as const,
groupKey: 'missing_node',
count: missingPackGroups.value.length,
priority: 1,
...resolveMissingErrorMessage({
kind: 'missing_node',
@@ -618,60 +632,21 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return groups.sort((a, b) => a.priority - b.priority)
}
/** Groups missing models. Asset-supported models group by directory; others go into a separate group.
* Within each group, candidates with the same model name are merged into a single view model. */
const missingModelGroups = computed<MissingModelGroup[]>(() => {
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
type GroupKey = string | null | typeof UNSUPPORTED
const map = new Map<
GroupKey,
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
>()
for (const c of candidates) {
const groupKey: GroupKey =
c.isAssetSupported || !isCloud ? c.directory || null : UNSUPPORTED
const existing = map.get(groupKey)
if (existing) {
existing.candidates.push(c)
} else {
// All candidates in the same directory share the same isAssetSupported
// value in practice (a directory is either asset-supported or not).
map.set(groupKey, {
candidates: [c],
isAssetSupported: c.isAssetSupported
})
}
}
return Array.from(map.entries())
.sort(([dirA], [dirB]) => {
if (dirA === UNSUPPORTED) return 1
if (dirB === UNSUPPORTED) return -1
if (dirA === null) return 1
if (dirB === null) return -1
return dirA.localeCompare(dirB)
})
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
directory: typeof key === 'string' ? key : null,
models: groupCandidatesByName(groupCandidates),
isAssetSupported
}))
return groupMissingModelCandidates(
missingModelStore.missingModelCandidates,
isCloud
)
})
function buildMissingModelGroups(): ErrorGroup[] {
if (!missingModelGroups.value.length) return []
const count = missingModelGroups.value.reduce(
(total, group) => total + group.models.length,
0
)
const count = countMissingModels(missingModelGroups.value)
return [
{
type: 'missing_model' as const,
groupKey: 'missing_model',
count,
priority: 2,
...resolveMissingErrorMessage({
kind: 'missing_model',
@@ -696,6 +671,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
{
type: 'missing_media' as const,
groupKey: 'missing_media',
count: totalRows,
priority: 3,
...resolveMissingErrorMessage({
kind: 'missing_media',
@@ -737,37 +713,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
)
if (!filtered.length) return []
const map = new Map<
string | null | typeof UNSUPPORTED,
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
>()
for (const c of filtered) {
const groupKey =
c.isAssetSupported || !isCloud ? c.directory || null : UNSUPPORTED
const existing = map.get(groupKey)
if (existing) {
existing.candidates.push(c)
} else {
map.set(groupKey, {
candidates: [c],
isAssetSupported: c.isAssetSupported
})
}
}
return Array.from(map.entries())
.sort(([dirA], [dirB]) => {
if (dirA === UNSUPPORTED) return 1
if (dirB === UNSUPPORTED) return -1
if (dirA === null) return 1
if (dirB === null) return -1
return dirA.localeCompare(dirB)
})
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
directory: typeof key === 'string' ? key : null,
models: groupCandidatesByName(groupCandidates),
isAssetSupported
}))
return groupMissingModelCandidates(filtered, isCloud)
})
const filteredMissingMediaGroups = computed(() => {
@@ -783,14 +729,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingModelGroups.value.length) return []
const count = filteredMissingModelGroups.value.reduce(
(total, group) => total + group.models.length,
0
)
const count = countMissingModels(filteredMissingModelGroups.value)
return [
{
type: 'missing_model' as const,
groupKey: 'missing_model',
count,
priority: 2,
...resolveMissingErrorMessage({
kind: 'missing_model',
@@ -811,6 +755,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
{
type: 'missing_media' as const,
groupKey: 'missing_media',
count: totalRows,
priority: 3,
...resolveMissingErrorMessage({
kind: 'missing_media',
@@ -865,22 +810,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return searchErrorGroups(tabErrorGroups.value, query)
})
const groupedErrorMessages = computed<string[]>(() => {
const messages = new Set<string>()
for (const group of allErrorGroups.value) {
if (group.type === 'execution') {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.displayMessage ?? err.message)
}
}
} else {
messages.add(group.displayMessage ?? group.displayTitle)
}
}
return Array.from(messages)
})
return {
allErrorGroups,
tabErrorGroups,
@@ -889,7 +818,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
isSingleNodeSelected,
errorNodeCache,
missingNodeCache,
groupedErrorMessages,
missingPackGroups,
missingModelGroups,
missingMediaGroups,

View File

@@ -12,14 +12,15 @@ import {
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@comfyorg/tailwind-utils'
@@ -146,16 +147,17 @@ function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
): boolean {
const source = widgetPromotedSource(widgetNode, widget)
return parents.some((parent) => {
if (isPromotedWidgetView(widget)) {
if (source) {
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? widget.sourceNodeId
? source.nodeId
: String(widgetNode.id)
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName
sourceWidgetName: source.widgetName
})
}
return isWidgetPromotedOnSubgraphNode(parent, {
@@ -234,7 +236,10 @@ function navigateToErrorTab() {
rightSidePanelStore.openPanel('errors')
}
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
function setWidgetValue(widget: IBaseWidget, value: WidgetValue) {
// Store-backed widgets (interior node widgets and promoted subgraph inputs)
// are addressed by widgetId; writing there keeps the displayed value in sync.
if (widget.widgetId) useWidgetValueStore().setValue(widget.widgetId, value)
widget.value = value
widget.callback?.(value)
canvasStore.canvas?.setDirty(true, true)
@@ -245,18 +250,18 @@ function handleResetAllWidgets() {
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
const defaultValue = getWidgetDefaultValue(spec)
if (defaultValue !== undefined) {
writeWidgetValue(widget, defaultValue)
setWidgetValue(widget, defaultValue)
}
}
}
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
if (newValue === undefined) return
writeWidgetValue(widget, newValue)
setWidgetValue(widget, newValue)
}
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
writeWidgetValue(widget, newValue)
setWidgetValue(widget, newValue)
}
defineExpose({

View File

@@ -0,0 +1,127 @@
import { render } from '@testing-library/vue'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import TabSubgraphInputs from './TabSubgraphInputs.vue'
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: vi.fn() })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { rightSidePanel: { inputs: 'Inputs', inputsNone: 'None' } } }
})
const captured: { rows: { node: LGraphNode; widget: IBaseWidget }[] } = {
rows: []
}
const SectionWidgetsStub = {
props: ['widgets', 'node', 'parents'],
setup(props: Record<string, unknown>) {
captured.rows = props.widgets as {
node: LGraphNode
widget: IBaseWidget
}[]
return () => null
}
}
function buildHostWithPromotedSeed(): {
host: SubgraphNode
sourceNode: LGraphNode
} {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const graph = host.graph as LGraph
graph.add(host)
const sourceNode = new LGraphNode('Sampler')
const input = sourceNode.addInput('seed', 'INT')
const seedWidget = sourceNode.addWidget('number', 'seed', 42, () => {})
input.widget = { name: seedWidget.name }
subgraph.add(sourceNode)
promoteValueWidgetViaSubgraphInput(host, sourceNode, seedWidget)
return { host, sourceNode }
}
function renderPanel(node: SubgraphNode) {
return render(TabSubgraphInputs, {
props: { node },
global: {
plugins: [i18n],
stubs: {
SectionWidgets: SectionWidgetsStub,
AsyncSearchInput: true,
CollapseToggleButton: true
}
}
})
}
describe('TabSubgraphInputs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
captured.rows = []
vi.clearAllMocks()
})
it('lists a subgraph node promoted widget as a store-backed parameter row', () => {
const { host } = buildHostWithPromotedSeed()
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
expect(seedRow).toBeDefined()
expect(seedRow?.node.id).toBe(host.id)
expect(seedRow?.widget.type).toBe('number')
expect(seedRow?.widget.widgetId).toBe(
widgetId(host.rootGraph.id, host.id, 'seed')
)
expect(seedRow?.widget.value).toBe(42)
})
it('reflects the current host widget value from the store', () => {
const { host } = buildHostWithPromotedSeed()
const id = widgetId(host.rootGraph.id, host.id, 'seed')
useWidgetValueStore().setValue(id, 7)
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
expect(seedRow?.widget.value).toBe(7)
})
it('reflects value changes through the same descriptor without rebuilding it', () => {
const { host } = buildHostWithPromotedSeed()
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')!
expect(seedRow.widget.value).toBe(42)
// A value edit must not require a new descriptor object: the same row
// reflects the store change via its live getter, keeping render keys stable.
useWidgetValueStore().setValue(
widgetId(host.rootGraph.id, host.id, 'seed'),
100
)
expect(seedRow.widget.value).toBe(100)
})
})

View File

@@ -3,14 +3,13 @@ import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import {
getWidgetName,
isWidgetPromotedOnSubgraphNode,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
@@ -45,32 +44,6 @@ const isAllCollapsed = computed({
})
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
return (
isPromotedWidgetView(a) &&
isPromotedWidgetView(b) &&
a.sourceNodeId === b.sourceNodeId &&
a.sourceWidgetName === b.sourceWidgetName
)
}
function getPromotedWidgets(): IBaseWidget[] {
const inputWidgets = node.inputs
.map((input) => input._widget)
.filter((widget): widget is IBaseWidget =>
Boolean(widget && isPromotedWidgetView(widget))
)
const extraWidgets = (node.widgets ?? []).filter(
(widget) =>
isPromotedWidgetView(widget) &&
!inputWidgets.some((inputWidget) =>
isSamePromotedWidget(inputWidget, widget)
)
)
return [...inputWidgets, ...extraWidgets]
}
watch(
focusedSection,
async (section) => {
@@ -93,7 +66,7 @@ watch(
)
const widgetsList = computed((): NodeWidgetsList => {
return getPromotedWidgets().map((widget) => ({ node, widget }))
return promotedInputWidgets(node).map((widget) => ({ node, widget }))
})
const advancedInputsWidgets = computed((): NodeWidgetsList => {

View File

@@ -5,8 +5,9 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import {
demotePromotedInput,
demoteWidget,
isLinkedPromotion,
promoteWidget
@@ -16,6 +17,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -45,8 +47,10 @@ const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
const isLinked = computed(() => {
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
if (!node.isSubgraphNode()) return false
const source = widgetPromotedSource(node, widget)
if (!source) return false
return isLinkedPromotion(node, source.nodeId, source.widgetName)
})
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
const favoriteNode = computed(() =>
@@ -64,9 +68,16 @@ const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
const hasDefault = computed(() => defaultValue.value !== undefined)
const currentValue = computed(
() =>
(widget.widgetId &&
useWidgetValueStore().getWidget(widget.widgetId)?.value) ??
widget.value
)
const isCurrentValueDefault = computed(() => {
if (!hasDefault.value) return true
return isEqual(widget.value, defaultValue.value)
return isEqual(currentValue.value, defaultValue.value)
})
async function handleRename() {
@@ -77,21 +88,15 @@ async function handleRename() {
function handleHideInput() {
if (!parents?.length) return
if (isPromotedWidgetView(widget)) {
const source = widgetPromotedSource(node, widget)
if (source) {
for (const parent of parents) {
const sourceNodeId =
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id)
demoteWidget(
{
id: sourceNodeId,
title: node.title,
type: node.type
},
widget,
[parent]
)
String(node.id) === String(parent.id) ? source.nodeId : String(node.id)
demotePromotedInput(parent, {
sourceNodeId,
sourceWidgetName: source.widgetName
})
}
canvasStore.canvas?.setDirty(true, true)
} else {

View File

@@ -7,6 +7,8 @@ import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
@@ -42,10 +44,6 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
getControlWidget: vi.fn(() => undefined)
}))
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
resolvePromotedWidgetSource: vi.fn(() => undefined)
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
() => ({
@@ -96,43 +94,6 @@ function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
} as IBaseWidget
}
/**
* Creates a mock PromotedWidgetView that mirrors the real class:
* properties like name, type, value, options are prototype getters,
* NOT own properties — so object spread loses them.
*/
function createMockPromotedWidgetView(
sourceOptions: IBaseWidget['options'] = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
): IBaseWidget {
class MockPromotedWidgetView {
readonly sourceNodeId = '42'
readonly sourceWidgetName = 'ckpt_name'
readonly serialize = false
get name(): string {
return 'ckpt_name'
}
get type(): string {
return 'combo'
}
get value(): unknown {
return 'model_a.safetensors'
}
get options(): IBaseWidget['options'] {
return sourceOptions
}
get label(): string | undefined {
return undefined
}
get y(): number {
return 0
}
}
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
}
function renderWidgetItem(
widget: IBaseWidget,
node: LGraphNode = createMockNode()
@@ -167,7 +128,7 @@ describe('WidgetItem', () => {
vi.clearAllMocks()
})
describe('promoted widget options', () => {
describe('widget state rendering', () => {
it('passes options from a regular widget to the widget component', () => {
const widget = createMockWidget({
options: { values: ['a', 'b', 'c'] }
@@ -180,35 +141,63 @@ describe('WidgetItem', () => {
})
})
it('passes options from a PromotedWidgetView to the widget component', () => {
it('passes options from widget state to the widget component', () => {
const expectedOptions = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
const widget = createMockPromotedWidgetView(expectedOptions)
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'ckpt_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: expectedOptions
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.options).toEqual(expectedOptions)
})
it('passes type from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
it('passes type from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, type: 'string' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.type).toBe('combo')
})
it('passes name from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
it('passes name from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'source_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.name).toBe('ckpt_name')
})
it('passes value from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
it('passes value from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)

View File

@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -17,11 +16,12 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
useWidgetValueStore,
stripGraphPrefix
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { renameWidget } from '@/utils/widgetUtil'
@@ -67,35 +67,32 @@ const widgetComponent = computed(() => {
return component || WidgetLegacy
})
function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
const source = resolvePromotedWidgetSource(node, widget)
return source ?? { node, widget }
}
const simplifiedWidget = computed((): SimplifiedWidget => {
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
: undefined
const bareNodeId = stripGraphPrefix(String(node.id))
const widgetState = widget.widgetId
? useWidgetValueStore().getWidget(widget.widgetId)
: graphId
? widgetValueStore.getWidget(widgetId(graphId, bareNodeId, widget.name))
: undefined
const widgetName = widgetState?.name ?? widget.name
const widgetType = widgetState?.type ?? widget.type
return {
name: widget.name,
type: widget.type,
name: widgetName,
type: widgetType,
value: widgetState?.value ?? widget.value,
label: widgetState?.label ?? widget.label,
options: widgetState?.options ?? widget.options,
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
controlWidget: getControlWidget(sourceWidget)
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),
controlWidget: getControlWidget(widget)
}
})
const sourceNodeName = computed((): string | null => {
const sourceNode = resolvePromotedWidgetSource(node, widget)?.node ?? node
if (!sourceNode) return null
const displayNodeName = computed((): string | null => {
if (!node) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(sourceNode, {
return resolveNodeDisplayName(node, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
@@ -167,10 +164,10 @@ const displayLabel = customRef((track, trigger) => {
/>
<span
v-if="(showNodeName || hasParents) && sourceNodeName"
v-if="(showNodeName || hasParents) && displayNodeName"
class="mx-1 my-0 min-w-10 flex-1 truncate p-0 text-right text-xs text-muted-foreground"
>
{{ sourceNodeName }}
{{ displayNodeName }}
</span>
<div
v-if="!hiddenWidgetActions"

View File

@@ -14,8 +14,9 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import SubgraphEditor from './SubgraphEditor.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import type DraggableList from '@/components/common/DraggableList.vue'
@@ -167,11 +168,20 @@ describe('SubgraphEditor', () => {
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
const reversed = [
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
] as PromotedRow[]
const rowFor = (sourceNode: LGraphNode) => {
const input = host.inputs.find((input) => {
if (!input.widgetId) return false
const target = resolveSubgraphInputTarget(host, input.name)
return target?.nodeId === String(sourceNode.id)
})!
return {
kind: 'promoted',
node: sourceNode,
input,
widget: promotedInputWidget(input)!
}
}
const reversed = [rowFor(secondNode), rowFor(firstNode)] as PromotedRow[]
listSetter?.(reversed)
await nextTick()
@@ -182,6 +192,42 @@ describe('SubgraphEditor', () => {
).toEqual(['second', 'first'])
})
it('moves a widget to shown when promoted from the hidden section', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const sourceNode = new LGraphNode('SourceNode')
subgraph.add(sourceNode)
const sourceInput = sourceNode.addInput('first', 'STRING')
const sourceWidget = sourceNode.addWidget('text', 'first', '', () => {})
sourceInput.widget = { name: sourceWidget.name }
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
const hidden = screen.getByTestId('subgraph-editor-hidden-section')
await userEvent.click(within(hidden).getByTestId('subgraph-widget-toggle'))
await nextTick()
const shown = screen.getByTestId('subgraph-editor-shown-section')
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first'])
})
it('demotes linked promoted widgets when "Hide all" is clicked', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
@@ -213,13 +259,13 @@ describe('SubgraphEditor', () => {
}
})
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(2)
const shown = screen.getByTestId('subgraph-editor-shown-section')
const hideAllLink = within(shown).getByText('Hide all')
await userEvent.click(hideAllLink)
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(0)
})
it('removes the exposure when a preview row without a real source widget is demoted', async () => {

View File

@@ -5,9 +5,8 @@ import { computed, onMounted, shallowRef, watch } from 'vue'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demotePromotedInput,
demoteWidget,
getPromotableWidgets,
isLinkedPromotion,
@@ -16,8 +15,14 @@ import {
pruneDisconnected,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import {
promotedInputSource,
promotedInputWidget
} from '@/core/graph/subgraph/promotedInputWidget'
import type { PromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -33,7 +38,8 @@ import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
type PromotedRow = {
kind: 'promoted'
node: LGraphNode
widget: PromotedWidgetView
input: INodeInputSlot
widget: IBaseWidget
}
type PreviewRow = {
kind: 'preview'
@@ -54,11 +60,23 @@ const activeNode = computed(() => {
return undefined
})
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
function refreshPromotedWidgets() {
promotedWidgets.value = activeNode.value?.widgets ?? []
const promotedRows = shallowRef<readonly PromotedRow[]>([])
function buildPromotedRows(node: SubgraphNode): PromotedRow[] {
return node.inputs.flatMap((input): PromotedRow[] => {
const widget = promotedInputWidget(input)
if (!widget) return []
const source = promotedInputSource(node, input)
if (!source) return []
const sourceNode = node.subgraph._nodes_by_id[source.nodeId]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, input, widget }]
})
}
watch(activeNode, refreshPromotedWidgets, { immediate: true })
function refreshPromotedRows() {
const node = activeNode.value
promotedRows.value = node ? buildPromotedRows(node) : []
}
watch(activeNode, refreshPromotedRows, { immediate: true })
useEventListener(
() => activeNode.value?.subgraph.events,
[
@@ -68,34 +86,29 @@ useEventListener(
'removing-input',
'inputs-reordered'
],
refreshPromotedWidgets
refreshPromotedRows
)
function promotedRowSource(row: PromotedRow): PromotedSource | undefined {
const node = activeNode.value
return node ? promotedInputSource(node, row.input) : undefined
}
const activeRows = computed<ActiveRow[]>(() => {
const node = activeNode.value
if (!node) return []
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
return [...promotedRows.value, ...getActivePreviewRows(node)]
})
const activePromotedRows = computed<PromotedRow[]>({
get() {
const node = activeNode.value
return node ? getActivePromotedRows(node) : []
return [...promotedRows.value]
},
set(value: PromotedRow[]) {
updateActivePromotedRows(value, activePromotedRows.value)
}
})
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
if (!isPromotedWidgetView(widget)) return []
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, widget }]
})
}
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
const hostLocator = String(node.id)
const rootGraphId = node.rootGraph.id
@@ -130,7 +143,7 @@ function updateActivePromotedRows(
if (currentKeys.size === nextKeys.size) {
reorderSubgraphInputsByWidgetOrder(
node,
value.map((row) => row.widget)
value.map((row) => ({ widgetId: row.widget.widgetId }))
)
}
refreshPromotedWidgetRendering()
@@ -151,9 +164,11 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
})
function activeRowSourceKey(row: ActiveRow): string {
return row.kind === 'promoted'
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
if (row.kind !== 'promoted')
return `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
const source = promotedRowSource(row)
return `${source?.nodeId ?? row.node.id}:${source?.widgetName ?? ''}`
}
const candidateWidgets = computed<WidgetItem[]>(() => {
@@ -228,18 +243,16 @@ function rowDisplayName(row: ActiveRow): string {
function isRowLinked(row: ActiveRow): boolean {
if (row.kind !== 'promoted') return false
if (row.node.id === -1) return true
const source = promotedRowSource(row)
return (
!!activeNode.value &&
isLinkedPromotion(
activeNode.value,
String(row.node.id),
row.widget.sourceWidgetName
)
!!source &&
isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName)
)
}
function promotedRowKey(row: PromotedRow): string {
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
return `${row.node.id}: ${row.widget.name}`
}
function rowKey(row: ActiveRow): string {
@@ -256,7 +269,14 @@ function demoteRow(row: ActiveRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
if (row.kind === 'promoted') {
demoteWidget(row.node, row.widget, [subgraphNode])
const source = promotedRowSource(row)
if (source) {
demotePromotedInput(subgraphNode, {
sourceNodeId: source.nodeId,
sourceWidgetName: source.widgetName
})
}
refreshPromotedWidgetRendering()
return
}
if (row.realWidget) {
@@ -274,13 +294,18 @@ function demoteRow(row: ActiveRow) {
function promotePromotedRow(row: PromotedRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(row.node, row.widget, [subgraphNode])
const source = promotedRowSource(row)
const sourceWidget = source
? row.node.widgets?.find((widget) => widget.name === source.widgetName)
: undefined
if (sourceWidget) promoteWidget(row.node, sourceWidget, [subgraphNode])
}
function promoteCandidate([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(node, widget, [subgraphNode])
refreshPromotedRows()
}
function showAll() {

View File

@@ -1,180 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import LinkReleaseContextMenu from './LinkReleaseContextMenu.vue'
import type {
LinkReleaseNodeCategory,
LinkReleaseSearchResultGroup
} from './linkReleaseMenuModel'
const { groups } = vi.hoisted(() => ({
groups: {
suggestions: [] as ComfyNodeDefImpl[],
categories: [] as LinkReleaseNodeCategory[],
searchResultGroups: [] as LinkReleaseSearchResultGroup[]
}
}))
vi.mock('./linkReleaseMenuModel', () => ({
getLinkReleaseHeaderLabel: () => '',
getLinkReleaseSuggestions: () => groups.suggestions,
buildLinkReleaseNodeCategories: () => groups.categories,
groupLinkReleaseSearchResults: () => groups.searchResultGroups,
searchLinkReleaseNodes: () =>
groups.searchResultGroups.flatMap((group) =>
group.nodes.map((node) => ({ category: group.category, node }))
),
filterNodesByName: () => []
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
const stubs = {
DropdownMenuRoot: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div role="menu"><slot /></div>' },
DropdownMenuLabel: { template: '<div><slot /></div>' },
DropdownMenuItem: {
emits: ['select'],
template:
'<div role="menuitem" tabindex="-1" @click="$emit(\'select\')"><slot /></div>'
},
DropdownMenuSeparator: { template: '<hr data-testid="menu-separator" />' },
LinkReleaseNodeSubmenu: { template: '<div data-testid="submenu" />' },
MiddleTruncate: { template: '<span>{{ text }}</span>', props: ['text'] }
}
function suggestion(name: string): ComfyNodeDefImpl {
return { name, display_name: name } as ComfyNodeDefImpl
}
function nodeCategory(
key: 'comfy' | 'extensions' | 'partner',
labelKey: string = key
): LinkReleaseNodeCategory {
return { key, labelKey, icon: '', nodes: [suggestion('Node')] }
}
function renderMenu() {
return render(LinkReleaseContextMenu, {
props: { context: null },
global: { plugins: [i18n, createTestingPinia()], stubs }
})
}
describe('LinkReleaseContextMenu group divider', () => {
beforeEach(() => {
groups.suggestions = []
groups.categories = []
groups.searchResultGroups = []
})
it('renders a divider between the suggestions and categories groups', () => {
groups.suggestions = [suggestion('KSampler')]
groups.categories = [nodeCategory('comfy')]
renderMenu()
expect(screen.getAllByTestId('menu-separator')).toHaveLength(3)
})
it('omits the group divider when only one group is present', () => {
groups.suggestions = []
groups.categories = [nodeCategory('comfy')]
renderMenu()
expect(screen.getAllByTestId('menu-separator')).toHaveLength(2)
})
it('renders a divider between search result groups', async () => {
groups.suggestions = []
groups.categories = []
groups.searchResultGroups = [
{
category: nodeCategory('extensions', 'contextMenu.Extensions'),
nodes: [suggestion('Ext Node')]
},
{
category: nodeCategory('partner', 'contextMenu.Partner Nodes'),
nodes: [suggestion('Partner Node')]
}
]
renderMenu()
await userEvent.type(screen.getByRole('textbox'), 'na')
expect(screen.getAllByTestId('menu-separator')).toHaveLength(2)
})
})
describe('LinkReleaseContextMenu selection', () => {
beforeEach(() => {
groups.suggestions = []
groups.categories = []
groups.searchResultGroups = []
})
function renderMenuWith(handlers: Record<string, unknown>) {
return render(LinkReleaseContextMenu, {
props: { context: null, ...handlers },
global: { plugins: [i18n, createTestingPinia()], stubs }
})
}
it('emits selectNode when a suggestion is chosen', async () => {
groups.suggestions = [suggestion('KSampler')]
const onSelectNode = vi.fn()
renderMenuWith({ onSelectNode })
await userEvent.click(screen.getByText('KSampler'))
expect(onSelectNode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'KSampler' })
)
})
it('emits addReroute when the reroute item is chosen', async () => {
groups.suggestions = [suggestion('KSampler')]
const onAddReroute = vi.fn()
renderMenuWith({ onAddReroute })
await userEvent.click(screen.getByText('contextMenu.Add Reroute'))
expect(onAddReroute).toHaveBeenCalled()
})
it('selects the first search result on Enter in the search field', async () => {
groups.searchResultGroups = [
{
category: nodeCategory('comfy', 'contextMenu.Comfy Nodes'),
nodes: [suggestion('Found Node')]
}
]
const onSelectNode = vi.fn()
renderMenuWith({ onSelectNode })
const search = screen.getByRole('textbox')
await userEvent.type(search, 'fo')
await userEvent.keyboard('{Enter}')
expect(onSelectNode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Found Node' })
)
})
it('moves focus to the first item on ArrowDown from the search', async () => {
groups.suggestions = [suggestion('KSampler')]
renderMenu()
const search = screen.getByRole('textbox')
search.focus()
await userEvent.keyboard('{ArrowDown}')
expect(screen.getAllByRole('menuitem')[0]).toHaveFocus()
})
})

View File

@@ -1,384 +0,0 @@
<template>
<DropdownMenuRoot :open="open" :modal="false" @update:open="onOpenChange">
<DropdownMenuTrigger as-child>
<div
aria-hidden="true"
class="pointer-events-none fixed size-0"
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
/>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="bottom"
align="start"
:side-offset="SIDE_OFFSET"
:collision-padding="VIEWPORT_MARGIN"
:avoid-collisions="false"
:class="contentClass"
:style="menuMaxHeight ? { maxHeight: `${menuMaxHeight}px` } : undefined"
data-testid="link-release-context-menu"
@open-auto-focus.prevent="focusSearch"
@close-auto-focus.prevent
@entry-focus="onEntryFocus"
@keydown.capture="redirectTypingToSearch"
>
<DropdownMenuLabel
v-if="headerLabel"
class="flex shrink-0 items-center gap-2 p-2 text-xs font-medium text-muted-foreground uppercase"
>
<span class="flex size-4 shrink-0 items-center justify-center">
<span
class="size-4 rounded-full"
:style="{ backgroundColor: slotColor }"
/>
</span>
<span class="truncate">{{ headerLabel }}</span>
</DropdownMenuLabel>
<div data-search-field class="shrink-0 p-0.5">
<div
class="flex h-9 items-center gap-2 rounded-lg bg-secondary-background px-2"
>
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
/>
<input
ref="searchInput"
v-model="query"
type="text"
data-testid="link-release-search"
:placeholder="t('contextMenu.Search')"
class="size-full min-w-0 appearance-none border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
@keydown="onRootSearchKeydown"
/>
</div>
</div>
<DropdownMenuSeparator
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<div :class="scrollClass">
<template v-if="trimmedQuery">
<template
v-for="(group, groupIndex) in searchResultGroups"
:key="group.category.key"
>
<DropdownMenuSeparator
v-if="groupIndex > 0"
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<DropdownMenuItem
v-for="node in group.nodes"
:key="node.name"
:class="itemClass"
@select="selectNode(node)"
>
<i
:class="cn(group.category.icon, 'size-4 shrink-0 opacity-80')"
/>
<span class="flex min-w-0 flex-1 items-center gap-1">
<span class="shrink-0 text-muted-foreground">
{{ t(group.category.labelKey) }}:
</span>
<MiddleTruncate
:text="node.display_name"
class="min-w-0 flex-1"
/>
</span>
</DropdownMenuItem>
</template>
<div
v-if="searchResults.length === 0"
class="p-2 text-sm text-muted-foreground"
>
{{ t('g.noResults') }}
</div>
</template>
<template v-else>
<template v-if="suggestions.length">
<DropdownMenuLabel
class="block truncate p-2 text-xs font-medium text-muted-foreground uppercase"
>
{{ t('contextMenu.Most Relevant') }}
</DropdownMenuLabel>
<DropdownMenuItem
v-for="nodeDef in suggestions"
:key="nodeDef.name"
:class="itemClass"
@select="selectNode(nodeDef)"
>
<MiddleTruncate
:text="nodeDef.display_name"
class="min-w-0 flex-1"
/>
</DropdownMenuItem>
</template>
<DropdownMenuSeparator
v-if="suggestions.length && categories.length"
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<template v-if="categories.length">
<DropdownMenuLabel
class="block truncate p-2 text-xs font-medium text-muted-foreground uppercase"
>
{{ t('contextMenu.Compatible Nodes') }}
</DropdownMenuLabel>
<LinkReleaseNodeSubmenu
v-for="category in categories"
:key="category.key"
:category
:item-class="itemClass"
:content-class="submenuContentClass"
:scroll-class="submenuScrollClass"
@select="selectNode"
/>
</template>
</template>
</div>
<template v-if="!trimmedQuery">
<DropdownMenuSeparator
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<DropdownMenuItem
:class="cn(itemClass, 'shrink-0')"
@select="addReroute"
>
<i class="icon-[lucide--git-fork] size-4 shrink-0 opacity-80" />
<span class="flex-1 truncate">
{{ t('contextMenu.Add Reroute') }}
</span>
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getSlotColor } from '@/constants/slotColors'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
import MiddleTruncate from './MiddleTruncate.vue'
import {
buildLinkReleaseNodeCategories,
computeContextMenuTop,
estimateLinkReleaseMenuHeight,
getLinkReleaseHeaderLabel,
getLinkReleaseSuggestions,
groupLinkReleaseSearchResults,
searchLinkReleaseNodes
} from './linkReleaseMenuModel'
import type {
LinkReleaseContext,
LinkReleaseNodeMatch
} from './linkReleaseMenuModel'
const { context } = defineProps<{ context: LinkReleaseContext | null }>()
const emit = defineEmits<{
selectNode: [nodeDef: ComfyNodeDefImpl]
addReroute: []
dismiss: []
}>()
const { t } = useI18n()
const nodeDefStore = useNodeDefStore()
const open = ref(false)
const position = ref({ x: 0, y: 0 })
const searchInput = ref<HTMLInputElement>()
const query = ref('')
const menuMaxHeight = ref<number>()
let actionTaken = false
const VIEWPORT_MARGIN = 8
const SIDE_OFFSET = 4
const MENU_WIDTH = 384
const contentClass =
'z-1700 flex min-w-[260px] max-w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
const scrollClass =
'flex-1 min-h-0 overflow-y-auto overflow-x-hidden scrollbar-custom'
const submenuContentClass =
'z-1700 flex w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
const submenuScrollClass =
'flex-1 min-h-0 overflow-y-auto overflow-x-hidden scrollbar-custom'
const itemClass =
'flex cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-sm text-base-foreground outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-interface-menu-component-surface-hovered'
const headerLabel = computed(() =>
context ? getLinkReleaseHeaderLabel(context) : ''
)
const slotColor = computed(() => getSlotColor(context?.dataType?.split(',')[0]))
const trimmedQuery = computed(() => query.value.trim())
const typeFilter = computed(() => {
if (!context) return null
const svc = nodeDefStore.nodeSearchService
return {
filterDef: context.isFromOutput
? svc.inputTypeFilter
: svc.outputTypeFilter,
value: context.dataType
}
})
const compatibleNodes = computed<ComfyNodeDefImpl[]>(() => {
if (!typeFilter.value) return []
return nodeDefStore.nodeSearchService.searchNode('', [typeFilter.value], {
limit: 500
})
})
const defaultNodeDefs = computed<ComfyNodeDefImpl[]>(() => {
if (!context?.dataType) return []
const table = context.isFromOutput
? LiteGraph.slot_types_default_out
: LiteGraph.slot_types_default_in
const types = table?.[context.dataType] ?? []
return types
.map((type) => nodeDefStore.allNodeDefsByName[type])
.filter((nodeDef): nodeDef is ComfyNodeDefImpl => Boolean(nodeDef))
})
const suggestions = computed(() =>
getLinkReleaseSuggestions(defaultNodeDefs.value)
)
const categories = computed(() =>
buildLinkReleaseNodeCategories(compatibleNodes.value)
)
const searchResultGroups = computed(() =>
groupLinkReleaseSearchResults(categories.value, trimmedQuery.value)
)
const searchResults = computed<LinkReleaseNodeMatch[]>(() =>
searchLinkReleaseNodes(categories.value, trimmedQuery.value)
)
function selectNode(nodeDef: ComfyNodeDefImpl) {
actionTaken = true
emit('selectNode', nodeDef)
hide()
}
function addReroute() {
actionTaken = true
emit('addReroute')
hide()
}
function focusSearch() {
searchInput.value?.focus()
}
function isPrintableKey(event: KeyboardEvent) {
return (
event.key.length === 1 &&
event.key !== ' ' &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
)
}
// When the keyboard focus is on a menu item, funnel printable keystrokes into
// the search field instead of letting Reka run its item type-ahead.
function redirectTypingToSearch(event: KeyboardEvent) {
if (event.target === searchInput.value || !isPrintableKey(event)) return
event.preventDefault()
event.stopPropagation()
query.value += event.key
focusSearch()
}
// Reka refocuses the first item (scrolling the list to the top) whenever the
// menu regains focus, which fires as the pointer leaves an item while scrolling.
function onEntryFocus(event: Event) {
event.preventDefault()
}
function focusFirstItem(target: HTMLElement) {
const menu = target.closest<HTMLElement>('[role="menu"]')
menu
?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
?.focus()
}
function onRootSearchKeydown(event: KeyboardEvent) {
// Let Reka close the menu natively on Escape.
if (event.key === 'Escape') return
event.stopPropagation()
if (event.key === 'ArrowDown') {
event.preventDefault()
focusFirstItem(event.currentTarget as HTMLElement)
} else if (event.key === 'Enter' && trimmedQuery.value) {
const first = searchResults.value[0]
if (first) selectNode(first.node)
}
}
function show(event: MouseEvent) {
actionTaken = false
query.value = ''
const menuHeight = estimateLinkReleaseMenuHeight({
hasHeader: Boolean(headerLabel.value),
suggestionCount: suggestions.value.length,
categoryCount: categories.value.length,
searchResultCount: 0,
showReroute: true
})
const menuTop = computeContextMenuTop({
cursorY: event.clientY,
menuHeight,
viewportHeight: window.innerHeight,
margin: VIEWPORT_MARGIN,
sideOffset: SIDE_OFFSET
})
menuMaxHeight.value = window.innerHeight - menuTop - VIEWPORT_MARGIN
const maxX = Math.max(
VIEWPORT_MARGIN,
window.innerWidth - MENU_WIDTH - VIEWPORT_MARGIN
)
position.value = {
x: Math.min(maxX, Math.max(VIEWPORT_MARGIN, event.clientX)),
y: menuTop - SIDE_OFFSET
}
void nextTick(() => {
open.value = true
})
}
function hide() {
open.value = false
}
function onOpenChange(value: boolean) {
open.value = value
if (value) return
if (!actionTaken) emit('dismiss')
actionTaken = false
}
defineExpose({ show, hide })
</script>

View File

@@ -1,120 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
const contentClass =
'z-1700 flex max-h-[min(80vh,var(--reka-dropdown-menu-content-available-height))] min-w-[260px] max-w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
const submenuContentClass =
'z-1700 flex w-sm max-h-[min(80vh,var(--reka-dropdown-menu-content-available-height))] flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
const submenuScrollClass =
'overflow-y-auto scrollbar-custom max-h-[min(calc(var(--reka-dropdown-menu-content-available-height)-3.5rem),80vh)]'
const itemClass =
'flex cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-base-foreground outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-interface-menu-component-surface-hovered'
function node(name: string, display_name = name): ComfyNodeDefImpl {
return { name, display_name } as ComfyNodeDefImpl
}
const category: LinkReleaseNodeCategory = {
key: 'comfy',
labelKey: 'contextMenu.Comfy Nodes',
icon: 'icon-[lucide--box]',
nodes: [
node('KSampler'),
node('VAEDecode', 'VAE Decode'),
node('VAEEncode', 'VAE Encode'),
node('CLIPTextEncode', 'CLIP Text Encode'),
node('LoadImage', 'Load Image'),
node('SaveImage', 'Save Image'),
node('EmptyLatentImage', 'Empty Latent Image'),
node(
'StableCascade_StageB_Conditioning',
'StableCascade_StageB_Conditioning'
)
]
}
const meta: Meta<typeof LinkReleaseNodeSubmenu> = {
title: 'Components/Searchbox/LinkReleaseNodeSubmenu',
component: LinkReleaseNodeSubmenu
}
export default meta
type Story = StoryObj<typeof meta>
function renderAnchored(side: 'left' | 'right'): Story['render'] {
return () => ({
components: {
DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuContent,
DropdownMenuLabel,
LinkReleaseNodeSubmenu
},
setup() {
const anchorStyle =
side === 'right'
? 'position: fixed; top: 64px; right: 16px;'
: 'position: fixed; top: 64px; left: 16px;'
return {
anchorStyle,
contentClass,
submenuContentClass,
submenuScrollClass,
itemClass,
category,
side
}
},
template: `
<div style="height: 480px;">
<DropdownMenuRoot default-open>
<DropdownMenuTrigger as-child>
<button :style="anchorStyle" class="rounded-md border border-interface-menu-stroke bg-interface-menu-surface px-3 py-1.5 text-sm text-base-foreground">
Compatible Nodes
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:class="contentClass"
:side="side === 'right' ? 'bottom' : 'bottom'"
:align="side === 'right' ? 'end' : 'start'"
:side-offset="4"
>
<DropdownMenuLabel class="block truncate px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase">
Compatible Nodes
</DropdownMenuLabel>
<LinkReleaseNodeSubmenu
:category="category"
:item-class="itemClass"
:content-class="submenuContentClass"
:scroll-class="submenuScrollClass"
/>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
`
})
}
/** Anchored near the LEFT edge: the submenu opens to the RIGHT (normal). */
export const OpensRight: Story = { render: renderAnchored('left') }
/**
* Anchored near the RIGHT edge: with no room on the right, Floating UI flips the
* submenu to the LEFT, landing flush against the parent menu's left edge.
*/
export const FlipsLeft: Story = { render: renderAnchored('right') }

View File

@@ -1,138 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { Slots } from 'vue'
import { computed, h, inject, nextTick, provide } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
const category: LinkReleaseNodeCategory = {
key: 'comfy',
labelKey: 'Comfy Nodes',
icon: 'icon-[lucide--box]',
nodes: [{ name: 'KSampler', display_name: 'KSampler' } as ComfyNodeDefImpl]
}
const SUB_OPEN = Symbol('subOpen')
const stubs = {
DropdownMenuSub: {
props: ['open'],
setup(props: { open?: boolean }, { slots }: { slots: Slots }) {
provide(
SUB_OPEN,
computed(() => props.open ?? false)
)
return () => h('div', slots.default?.())
}
},
DropdownMenuSubTrigger: {
template: '<button data-testid="sub-trigger"><slot /></button>'
},
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuSubContent: {
setup(_: unknown, { slots }: { slots: Slots }) {
const open = inject<{ value: boolean }>(SUB_OPEN)
return () =>
open?.value ? h('div', { role: 'menu' }, slots.default?.()) : null
}
},
DropdownMenuSeparator: { template: '<hr />' },
DropdownMenuItem: {
template: '<div role="menuitem" tabindex="-1"><slot /></div>'
},
MiddleTruncate: { template: '<span>{{ text }}</span>', props: ['text'] }
}
function renderSubmenu() {
return render(LinkReleaseNodeSubmenu, {
props: { category, itemClass: '', contentClass: '', scrollClass: '' },
global: { plugins: [i18n], stubs }
})
}
describe('LinkReleaseNodeSubmenu keyboard handling', () => {
it('steps into the submenu search on ArrowRight', async () => {
renderSubmenu()
await userEvent.click(screen.getByTestId('sub-trigger'))
await userEvent.keyboard('{ArrowRight}')
await nextTick()
expect(screen.getByRole('textbox')).toHaveFocus()
})
it('steps into the submenu search on Enter', async () => {
renderSubmenu()
await userEvent.click(screen.getByTestId('sub-trigger'))
await userEvent.keyboard('{Enter}')
await nextTick()
expect(screen.getByRole('textbox')).toHaveFocus()
})
it('does not move focus to the search on other keys', async () => {
renderSubmenu()
await userEvent.click(screen.getByTestId('sub-trigger'))
await userEvent.keyboard('a')
await nextTick()
expect(screen.getByRole('textbox')).not.toHaveFocus()
})
async function stepIntoSearch() {
await userEvent.click(screen.getByTestId('sub-trigger'))
await userEvent.keyboard('{ArrowRight}')
await nextTick()
}
it('selects the first filtered node on Enter in the search', async () => {
const onSelect = vi.fn()
render(LinkReleaseNodeSubmenu, {
props: {
category,
itemClass: '',
contentClass: '',
scrollClass: '',
onSelect
},
global: { plugins: [i18n], stubs }
})
await stepIntoSearch()
expect(screen.getByRole('textbox')).toHaveFocus()
await userEvent.keyboard('{Enter}')
expect(onSelect).toHaveBeenCalledWith(category.nodes[0])
})
it('does not select on Escape in the search', async () => {
const onSelect = vi.fn()
render(LinkReleaseNodeSubmenu, {
props: {
category,
itemClass: '',
contentClass: '',
scrollClass: '',
onSelect
},
global: { plugins: [i18n], stubs }
})
await stepIntoSearch()
await userEvent.keyboard('{Escape}')
expect(onSelect).not.toHaveBeenCalled()
})
it('moves focus to the first node on ArrowDown from the search', async () => {
renderSubmenu()
await stepIntoSearch()
await userEvent.keyboard('{ArrowDown}')
expect(screen.getByRole('menuitem')).toHaveFocus()
})
})

View File

@@ -1,242 +0,0 @@
<template>
<DropdownMenuSub v-model:open="open">
<DropdownMenuSubTrigger
ref="triggerRef"
:class="triggerClass"
@focus="open = true"
@keydown="onTriggerKeydown"
@blur="onTriggerBlur"
>
<i :class="cn(category.icon, 'size-4 shrink-0 opacity-80')" />
<span class="flex-1 truncate">{{ t(category.labelKey) }}</span>
<span
class="rounded-full bg-interface-menu-keybind-surface-default px-1.5 text-xs text-muted-foreground"
>
{{ category.nodes.length }}
</span>
<i class="icon-[lucide--chevron-right] size-4 shrink-0 opacity-60" />
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<!--
Opens to the right of the trigger; when there's no room, Floating UI
flips it to the LEFT. align-offset is computed per-open
(alignToContextMenu) so the submenu's search field lines up with the
root search field instead of the hovered trigger row. The height is also
pinned per-open: maxHeight grows into the viewport space below the
submenu top but never drops under the context menu height, so the panel
scrolls internally instead of letting Floating UI shift it upward.
-->
<DropdownMenuSubContent
:class="contentClass"
:style="maxHeight ? { maxHeight: `${maxHeight}px` } : undefined"
side="right"
align="start"
:side-offset="-2"
:align-offset="alignOffset"
:collision-padding="8"
update-position-strategy="optimized"
@open-auto-focus.prevent
@entry-focus="onEntryFocus"
@keydown.capture="redirectTypingToSearch"
>
<div class="shrink-0 p-0.5">
<div
class="flex h-9 items-center gap-2 rounded-lg bg-secondary-background px-2"
>
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
/>
<input
ref="searchInput"
v-model="query"
type="text"
:aria-label="
t('g.searchPlaceholder', { subject: t(category.labelKey) })
"
:placeholder="
t('g.searchPlaceholder', { subject: t(category.labelKey) })
"
class="size-full min-w-0 appearance-none border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
@keydown="onSearchKeydown"
/>
</div>
</div>
<DropdownMenuSeparator
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<div :class="scrollClass">
<DropdownMenuItem
v-for="nodeDef in filteredNodes"
:key="nodeDef.name"
:class="itemClass"
@select="emit('select', nodeDef)"
>
<MiddleTruncate
:text="nodeDef.display_name"
class="min-w-0 flex-1 self-stretch"
/>
</DropdownMenuItem>
<div
v-if="filteredNodes.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
{{ t('g.noResults') }}
</div>
</div>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from 'reka-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import MiddleTruncate from './MiddleTruncate.vue'
import {
computeSubmenuAlignOffset,
computeSubmenuMaxHeight,
filterNodesByName
} from './linkReleaseMenuModel'
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
const { category, itemClass, contentClass, scrollClass } = defineProps<{
category: LinkReleaseNodeCategory
itemClass: string
contentClass: string
scrollClass: string
}>()
const emit = defineEmits<{
select: [nodeDef: ComfyNodeDefImpl]
}>()
const { t } = useI18n()
const open = ref(false)
const query = ref('')
const searchInput = ref<HTMLInputElement>()
const triggerRef = ref<InstanceType<typeof DropdownMenuSubTrigger>>()
// Pin the submenu's search field to the root search field rather than to the
// hovered trigger row; both recomputed each time the submenu opens.
const alignOffset = ref(-5)
const maxHeight = ref<number>()
const VIEWPORT_MARGIN = 8
const triggerClass = computed(() =>
cn(itemClass, 'data-[state=open]:bg-interface-menu-component-surface-hovered')
)
const filteredNodes = computed(() =>
filterNodesByName(category.nodes, query.value)
)
function alignToContextMenu() {
const triggerEl = triggerRef.value?.$el as HTMLElement | undefined
const rootMenu = triggerEl?.closest<HTMLElement>('[role="menu"]')
const rootSearch = rootMenu?.querySelector<HTMLElement>('[data-search-field]')
if (!triggerEl || !rootMenu || !rootSearch) return
const triggerTop = triggerEl.getBoundingClientRect().top
const rootRect = rootMenu.getBoundingClientRect()
const rootSearchTop = rootSearch.getBoundingClientRect().top
const contentPaddingTop = parseFloat(getComputedStyle(rootMenu).paddingTop)
alignOffset.value = computeSubmenuAlignOffset({
triggerTop,
rootSearchTop,
contentPaddingTop
})
maxHeight.value = computeSubmenuMaxHeight({
submenuTop: rootSearchTop - contentPaddingTop,
contextMenuHeight: rootRect.height,
viewportHeight: window.innerHeight,
margin: VIEWPORT_MARGIN
})
}
watch(open, (isOpen) => {
if (isOpen) alignToContextMenu()
else query.value = ''
})
function focusSearch() {
searchInput.value?.focus()
}
function submenuContent() {
return searchInput.value?.closest<HTMLElement>('[role="menu"]') ?? null
}
// Step into the open submenu, landing on its search field.
function onTriggerKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowRight' && event.key !== 'Enter') return
event.preventDefault()
open.value = true
void nextTick(focusSearch)
}
// Close the preview when focus leaves the trigger to a sibling item rather
// than into the submenu content.
function onTriggerBlur(event: FocusEvent) {
const next = event.relatedTarget
if (next instanceof Node && submenuContent()?.contains(next)) return
open.value = false
}
function isPrintableKey(event: KeyboardEvent) {
return (
event.key.length === 1 &&
event.key !== ' ' &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
)
}
// When the keyboard focus is on a submenu item, funnel printable keystrokes
// into this submenu's search field instead of Reka's item type-ahead.
function redirectTypingToSearch(event: KeyboardEvent) {
if (event.target === searchInput.value || !isPrintableKey(event)) return
event.preventDefault()
event.stopPropagation()
query.value += event.key
focusSearch()
}
// Reka refocuses the first item (scrolling the list to the top) whenever the
// menu regains focus, which fires as the pointer leaves an item while scrolling.
function onEntryFocus(event: Event) {
event.preventDefault()
}
function focusFirstNode(target: HTMLElement) {
const panel = target.closest<HTMLElement>('[role="menu"]')
panel
?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
?.focus()
}
function onSearchKeydown(event: KeyboardEvent) {
// Let Reka handle submenu/menu navigation keys natively.
if (event.key === 'Escape' || event.key === 'ArrowLeft') return
event.stopPropagation()
if (event.key === 'ArrowDown') {
event.preventDefault()
focusFirstNode(event.currentTarget as HTMLElement)
} else if (event.key === 'Enter') {
const first = filteredNodes.value[0]
if (first) emit('select', first)
}
}
</script>

View File

@@ -1,182 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MiddleTruncate from './MiddleTruncate.vue'
import * as overflow from './isTextOverflowing'
function stubRect(el: HTMLElement, rect: Partial<DOMRect>) {
el.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({}),
...rect
}) as DOMRect
}
describe('MiddleTruncate', () => {
beforeEach(() => {
Object.defineProperty(document.documentElement, 'clientWidth', {
configurable: true,
value: 1024
})
})
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(document.documentElement, 'clientWidth')
})
it('renders the full text inline', () => {
render(MiddleTruncate, { props: { text: 'KSampler' } })
expect(screen.getByText('KSampler')).toBeInTheDocument()
})
it('does not reveal a tooltip when the text fits', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(0)
render(MiddleTruncate, { props: { text: 'KSampler' } })
await userEvent.hover(screen.getByText('KSampler'))
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('reveals the full text on hover when truncated', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
const longName = 'ONNX Detector (SEGS/legacy) - use BBOXDetector'
render(MiddleTruncate, { props: { text: longName } })
const el = screen.getByText(longName)
stubRect(el, { left: 10, top: 20, width: 100, height: 20 })
await userEvent.hover(el)
expect(screen.getByRole('tooltip')).toHaveTextContent(longName)
})
it('reveals when hovering anywhere on the parent menu item', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
const longName = 'ONNX Detector (SEGS/legacy) - use BBOXDetector'
render({
components: { MiddleTruncate },
template: `<div role="menuitem"><MiddleTruncate text="${longName}" /></div>`
})
stubRect(screen.getByText(longName), {
left: 10,
top: 20,
width: 120,
height: 20
})
await userEvent.hover(screen.getByRole('menuitem'))
expect(screen.getByRole('tooltip')).toHaveTextContent(longName)
})
it('sizes the reveal to the parent menu item height', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
const nodeName = 'A long truncated node name'
render({
components: { MiddleTruncate },
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /></div>`
})
stubRect(screen.getByText(nodeName), {
left: 10,
top: 20,
width: 100,
height: 20
})
stubRect(screen.getByRole('menuitem'), {
left: 0,
top: 10,
right: 200,
width: 200,
height: 36
})
await userEvent.hover(screen.getByText(nodeName))
expect(screen.getByRole('tooltip')).toHaveStyle({ height: '36px' })
})
it('anchors the reveal to the left when it fits to the right', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(50)
const nodeName = 'Fits To The Right'
render({
components: { MiddleTruncate },
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /></div>`
})
stubRect(screen.getByText(nodeName), {
left: 10,
top: 20,
width: 100,
height: 20
})
stubRect(screen.getByRole('menuitem'), {
left: 0,
top: 10,
right: 200,
width: 200,
height: 36
})
await userEvent.hover(screen.getByText(nodeName))
expect(screen.getByRole('tooltip')).toHaveStyle({ left: '10px' })
})
it('flips to a right anchor when revealing rightward would overflow', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(600)
const nodeName = 'A very long node name near the right edge'
render({
components: { MiddleTruncate },
template: `<div role="menuitem" style="padding-right: 16px"><MiddleTruncate text="${nodeName}" /></div>`
})
stubRect(screen.getByText(nodeName), {
left: 850,
top: 20,
width: 150,
height: 20
})
stubRect(screen.getByRole('menuitem'), {
left: 840,
top: 10,
right: 1000,
width: 160,
height: 36
})
await userEvent.hover(screen.getByText(nodeName))
const tooltip = screen.getByRole('tooltip')
// Anchored to the item's right edge (1024 - 1000), independent of its padding.
expect(tooltip).toHaveStyle({ right: '24px' })
expect(tooltip).not.toHaveStyle({ left: '850px' })
})
it('hides the reveal when the pointer leaves the menu item', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
const nodeName = 'A long truncated node name'
render({
components: { MiddleTruncate },
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /></div>`
})
const el = screen.getByText(nodeName)
stubRect(el, { left: 10, top: 20, width: 100, height: 20 })
await userEvent.hover(el)
expect(screen.getByRole('tooltip')).toBeInTheDocument()
await userEvent.unhover(el)
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('keeps the reveal while the pointer moves within the menu item', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
const nodeName = 'A long truncated node name'
render({
components: { MiddleTruncate },
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /><span data-testid="sibling">x</span></div>`
})
const el = screen.getByText(nodeName)
stubRect(el, { left: 10, top: 20, width: 100, height: 20 })
await userEvent.hover(el)
expect(screen.getByRole('tooltip')).toBeInTheDocument()
await userEvent.pointer({ target: screen.getByTestId('sibling') })
expect(screen.getByRole('tooltip')).toBeInTheDocument()
})
})

View File

@@ -1,156 +0,0 @@
<template>
<span
ref="elRef"
v-bind="$attrs"
:class="cn('block min-w-0 truncate', revealed && 'text-transparent')"
@pointerenter="reveal"
@pointermove="reveal"
@pointerleave="onPointerLeave"
@focusin="reveal"
@focusout="hide"
>
{{ text }}
</span>
<Teleport to="body">
<span
v-if="revealed && revealStyle"
role="tooltip"
:class="
cn(
'pointer-events-none fixed z-99999 inline-flex items-center rounded-lg bg-interface-menu-component-surface-hovered pr-3 text-sm whitespace-nowrap text-base-foreground shadow-interface',
revealRect?.anchor === 'right' && 'pl-3'
)
"
:style="revealStyle"
>
{{ text }}
</span>
</Teleport>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref } from 'vue'
import { measureTextWidth } from './isTextOverflowing'
defineOptions({ inheritAttrs: false })
const { text } = defineProps<{ text: string }>()
// Gap kept between the reveal and the viewport edge (mirrors the menu's
// collision-padding) and the reveal's own far-side padding (`pl-3`/`pr-3`).
const VIEWPORT_MARGIN = 8
const REVEAL_PADDING = 12
type RevealRect = {
top: number
height: number
minWidth: number
maxWidth: number
anchor: 'left' | 'right'
offset: number
}
const elRef = ref<HTMLElement>()
const revealed = ref(false)
const revealRect = ref<RevealRect>()
const revealStyle = computed(() => {
const rect = revealRect.value
if (!rect) return undefined
return {
top: `${rect.top}px`,
height: `${rect.height}px`,
minWidth: `${rect.minWidth}px`,
maxWidth: `${rect.maxWidth}px`,
width: 'max-content',
[rect.anchor]: `${rect.offset}px`
}
})
const menuItem = computed(
() =>
elRef.value?.closest<HTMLElement>('[role="menuitem"]') ??
elRef.value?.parentElement ??
null
)
function getRevealRect(el: HTMLElement, textWidth: number): RevealRect {
const textRect = el.getBoundingClientRect()
const item = menuItem.value
const itemRect = item?.getBoundingClientRect()
const paddingRight = item
? Number.parseFloat(getComputedStyle(item).paddingRight) || 0
: 0
const rightInset = itemRect ? itemRect.right - paddingRight : textRect.right
const itemRight = itemRect ? itemRect.right : textRect.right
const viewportWidth = document.documentElement.clientWidth
const top = itemRect?.top ?? textRect.top
const height = itemRect?.height ?? textRect.height
const minWidth = Math.max(textRect.width, rightInset - textRect.left)
const neededWidth = Math.max(minWidth, textWidth + REVEAL_PADDING)
const fitsRight =
textRect.left + neededWidth <= viewportWidth - VIEWPORT_MARGIN
if (fitsRight) {
return {
top,
height,
minWidth,
maxWidth: viewportWidth - VIEWPORT_MARGIN - textRect.left,
anchor: 'left',
offset: textRect.left
}
}
return {
top,
height,
minWidth,
maxWidth: itemRight - VIEWPORT_MARGIN,
anchor: 'right',
offset: Math.max(VIEWPORT_MARGIN, viewportWidth - itemRight)
}
}
function reveal() {
const el = elRef.value
if (!el) {
revealed.value = false
return
}
const textWidth = measureTextWidth(el)
if (textWidth <= el.clientWidth + 0.5) {
revealed.value = false
return
}
revealRect.value = getRevealRect(el, textWidth)
revealed.value = true
}
function hide() {
revealed.value = false
}
function isStillOverMenuItem(related: EventTarget | null) {
const item = menuItem.value
return (
related instanceof Node &&
item != null &&
(item === related || item.contains(related))
)
}
function onPointerLeave(event: PointerEvent) {
if (isStillOverMenuItem(event.relatedTarget)) return
hide()
}
useEventListener(menuItem, 'pointerenter', reveal)
useEventListener(menuItem, 'pointermove', reveal)
useEventListener(menuItem, 'pointerleave', (event: PointerEvent) => {
if (isStillOverMenuItem(event.relatedTarget)) return
hide()
})
</script>

View File

@@ -6,14 +6,10 @@ import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
const coreSettingsById = Object.fromEntries(CORE_SETTINGS.map((s) => [s.id, s]))
@@ -55,7 +51,6 @@ describe('NodeSearchBoxPopover', () => {
let emitAddFilter: EmitAddFilter | null = null
let emitAddNodeV1: EmitAddNode | null = null
let emitAddNodeV2: EmitAddNode | null = null
let emitSelectNode: ((nodeDef: ComfyNodeDefImpl) => void) | null = null
const NodeSearchBoxStub = defineComponent({
name: 'NodeSearchBox',
@@ -87,17 +82,6 @@ describe('NodeSearchBoxPopover', () => {
template: '<div data-testid="search-content-v2"></div>'
})
const LinkReleaseContextMenuStub = defineComponent({
name: 'LinkReleaseContextMenu',
props: { context: { type: Object, default: null } },
emits: ['selectNode', 'addReroute', 'dismiss'],
setup(_, { emit }) {
emitSelectNode = (nodeDef) => emit('selectNode', nodeDef)
return {}
},
template: '<div data-testid="link-release-menu" />'
})
const pinia = createTestingPinia({
stubActions: false,
initialState: {
@@ -115,7 +99,6 @@ describe('NodeSearchBoxPopover', () => {
stubs: {
NodeSearchBox: NodeSearchBoxStub,
NodeSearchContent: NodeSearchContentStub,
LinkReleaseContextMenu: LinkReleaseContextMenuStub,
NodePreviewCard: true,
Dialog: {
template: '<div><slot name="container" /></div>',
@@ -139,11 +122,6 @@ describe('NodeSearchBoxPopover', () => {
if (!emitAddNodeV2)
throw new Error('NodeSearchContent stub did not mount')
return emitAddNodeV2
},
get emitSelectNode() {
if (!emitSelectNode)
throw new Error('LinkReleaseContextMenu stub did not mount')
return emitSelectNode
}
}
}
@@ -298,122 +276,4 @@ describe('NodeSearchBoxPopover', () => {
)
})
})
describe('selecting a node from the link-release menu', () => {
function setupCanvas() {
const selectNode = vi.fn()
const canvasStore = useCanvasStore()
canvasStore.canvas = {
graph: { nodes: [] },
allow_searchbox: false,
setDirty: vi.fn(),
selectNode,
linkConnector: {
events: new EventTarget(),
reset: vi.fn(),
disconnectLinks: vi.fn(),
connectToNode: vi.fn()
}
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
return { selectNode }
}
it('auto-selects the placed node on the canvas', async () => {
const node = { id: 7 }
const { emitSelectNode } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'default'
})
const { selectNode } = setupCanvas()
addNodeOnGraph.mockReturnValue(node)
emitSelectNode({ name: 'KSampler' } as ComfyNodeDefImpl)
await nextTick()
expect(selectNode).toHaveBeenCalledWith(node)
})
it('does not select when the node could not be created', async () => {
const { emitSelectNode } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'default'
})
const { selectNode } = setupCanvas()
addNodeOnGraph.mockReturnValue(null)
emitSelectNode({ name: 'KSampler' } as ComfyNodeDefImpl)
await nextTick()
expect(selectNode).not.toHaveBeenCalled()
})
})
describe('defaultRootFilter on dialog open', () => {
function setGraphNodes(nodes: unknown[]) {
const canvasStore = useCanvasStore()
canvasStore.canvas = {
graph: { nodes },
allow_searchbox: false,
setDirty: vi.fn(),
linkConnector: {
events: new EventTarget(),
reset: vi.fn(),
disconnectLinks: vi.fn()
}
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
}
async function openSearch() {
useSearchBoxStore().visible = true
await nextTick()
}
it('defaults to Essentials when the graph is empty', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to Essentials when the canvas is not yet available', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to null when the graph has nodes', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
it('re-evaluates each time the dialog opens', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
useSearchBoxStore().visible = false
await nextTick()
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
})
})

View File

@@ -27,8 +27,6 @@
<div v-if="useSearchBoxV2" role="search" class="relative">
<NodeSearchContent
:filters="nodeFilters"
:default-root-filter="defaultRootFilter"
:data-default-root-filter="defaultRootFilter"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@@ -53,13 +51,6 @@
/>
</template>
</Dialog>
<LinkReleaseContextMenu
ref="linkReleaseMenu"
:context="linkReleaseContext"
@select-node="connectNodeFromMenu"
@add-reroute="addRerouteFromMenu"
@dismiss="reset"
/>
</div>
</template>
@@ -71,11 +62,7 @@ import { computed, ref, toRaw, watch, watchEffect } from 'vue'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
import {
LGraphNode,
LiteGraph,
isNodeSlot
} from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
@@ -91,14 +78,11 @@ import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import LinkReleaseContextMenu from './LinkReleaseContextMenu.vue'
import type { LinkReleaseContext } from './linkReleaseMenuModel'
import NodeSearchContent from './v2/NodeSearchContent.vue'
import { RootCategory } from './v2/rootCategories'
import type { RootCategoryId } from './v2/rootCategories'
import NodeSearchBox from './NodeSearchBox.vue'
let triggerEvent: CanvasPointerEvent | null = null
let listenerController: AbortController | null = null
let disconnectOnReset = false
const settingStore = useSettingStore()
@@ -119,8 +103,6 @@ const enableNodePreview = computed(
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
)
const linkReleaseMenu = ref<InstanceType<typeof LinkReleaseContextMenu>>()
const linkReleaseContext = ref<LinkReleaseContext | null>(null)
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
@@ -147,26 +129,16 @@ function closeDialog() {
}
const canvasStore = useCanvasStore()
// Pre-select the Essentials category when opening search on an empty graph,
// where new users benefit most from a curated starting set.
const defaultRootFilter = computed<RootCategoryId | null>(() => {
const graph = canvasStore.canvas?.graph
return graph && graph.nodes.length > 0 ? null : RootCategory.Essentials
})
function connectNewNode(
nodeDef: ComfyNodeDefImpl,
options: { ghost?: boolean; dragEvent?: MouseEvent } = {}
): LGraphNode | null {
const { ghost = false, dragEvent } = options
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
const node = withNodeAddSource('search_modal', () =>
litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost, dragEvent }
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
)
)
if (!node) return null
if (!node) return
if (disconnectOnReset && triggerEvent) {
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
@@ -178,16 +150,6 @@ function connectNewNode(
// Notify changeTracker - new step should be added
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
return node
}
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
connectNewNode(nodeDef, {
ghost: useSearchBoxV2.value && followCursor,
dragEvent
})
window.requestAnimationFrame(closeDialog)
}
@@ -240,46 +202,62 @@ function showContextMenu(e: CanvasPointerEvent) {
const firstLink = getFirstLink()
if (!firstLink) return
const { fromSlot, toType } = firstLink
linkReleaseContext.value = {
dataType: fromSlot.type?.toString() ?? '',
slotName: fromSlot.name ?? '',
isFromOutput: toType === 'input'
const { node, fromSlot, toType } = firstLink
const commonOptions = {
e,
allow_searchbox: true,
showSearchBox: () => {
cancelResetOnContextClose()
showSearchBox(e)
}
}
triggerEvent = e
const afterRerouteId = firstLink.fromReroute?.id
const connectionOptions =
toType === 'input'
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
// Hide the dangling link while the menu holds the connection open; the real
// edge reappears once a node is committed (reset clears this flag).
const canvas = canvasStore.getCanvas()
canvas.linkConnector.renderLinksHidden = true
canvas.setDirty(true, true)
const menu = canvas.showConnectionMenu({
...connectionOptions,
...commonOptions
})
linkReleaseMenu.value?.show(e)
}
function connectNodeFromMenu(nodeDef: ComfyNodeDefImpl) {
const node = connectNewNode(nodeDef)
if (node) canvasStore.getCanvas().selectNode(node)
reset()
}
function addRerouteFromMenu() {
const firstLink = getFirstLink()
const node = firstLink?.node
if (
firstLink &&
triggerEvent &&
node instanceof LGraphNode &&
isNodeSlot(firstLink.fromSlot)
) {
node.connectFloatingReroute(
[triggerEvent.canvasX, triggerEvent.canvasY],
firstLink.fromSlot,
firstLink.fromReroute?.id
)
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
if (!menu) {
console.warn('No menu was returned from showConnectionMenu')
return
}
reset()
triggerEvent = e
listenerController = new AbortController()
const { signal } = listenerController
const options = { once: true, signal }
// Connect the node after it is created via context menu
useEventListener(
canvas.canvas,
'connect-new-default-node',
(createEvent) => {
if (!(createEvent instanceof CustomEvent))
throw new Error('Invalid event')
const node: unknown = createEvent.detail?.node
if (!(node instanceof LGraphNode)) throw new Error('Invalid node')
disconnectOnReset = false
createEvent.preventDefault()
canvas.linkConnector.connectToNode(node, e)
},
options
)
// Reset when the context menu is closed
const cancelResetOnContextClose = useEventListener(
menu.controller.signal,
'abort',
reset,
options
)
}
// Disable litegraph's default behavior of release link and search box.
@@ -355,32 +333,25 @@ function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
// Resets litegraph state
function reset() {
listenerController?.abort()
listenerController = null
triggerEvent = null
const canvas = canvasStore.getCanvas()
canvas.linkConnector.events.removeEventListener('reset', preventDefault)
if (disconnectOnReset) canvas.linkConnector.disconnectLinks()
disconnectOnReset = false
canvas.linkConnector.reset()
canvas.setDirty(true, true)
}
// Tears down a held link-release session synchronously so a new link drag can
// take over without hitting LinkConnector's "Already dragging links" guard.
function cancelLinkRelease() {
linkReleaseMenu.value?.hide()
visible.value = false
reset()
}
// Reset connecting links when the search box is closed
watch(visible, () => {
if (!visible.value) reset()
})
useEventListener(document, 'litegraph:canvas', canvasEventHandler)
defineExpose({ showSearchBox, cancelLinkRelease })
defineExpose({ showSearchBox })
</script>
<style>

View File

@@ -1,45 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isTextOverflowing } from './isTextOverflowing'
const CHAR_WIDTH = 10
function setup(text: string, contentWidth: number) {
const el = document.createElement('span')
el.textContent = text
Object.defineProperty(el, 'clientWidth', {
configurable: true,
value: contentWidth
})
vi.spyOn(window, 'getComputedStyle').mockReturnValue(
{} as CSSStyleDeclaration
)
vi.spyOn(
HTMLSpanElement.prototype,
'getBoundingClientRect'
).mockImplementation(function (this: HTMLSpanElement) {
return { width: (this.textContent?.length ?? 0) * CHAR_WIDTH } as DOMRect
})
return el
}
describe('isTextOverflowing', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('returns false when the text fits the content width', () => {
const el = setup('KSampler', 200)
expect(isTextOverflowing(el)).toBe(false)
})
it('returns true when the full text is wider than the content width', () => {
const el = setup('ONNX Detector (SEGS/legacy) - use BBOXDetector', 120)
expect(isTextOverflowing(el)).toBe(true)
})
it('returns false for a zero-width element', () => {
const el = setup('anything', 0)
expect(isTextOverflowing(el)).toBe(false)
})
})

View File

@@ -1,46 +0,0 @@
const FONT_PROPS = [
'fontStyle',
'fontVariant',
'fontWeight',
'fontStretch',
'fontSize',
'fontFamily',
'letterSpacing',
'textTransform',
'wordSpacing'
] as const
/**
* Measures the full, unclipped width of an element's text by rendering it in a
* hidden clone that copies the element's font metrics. `scrollWidth` is
* unreliable for `text-overflow: ellipsis` in Chrome (it often reports equal to
* `clientWidth`), so the clone is the source of truth.
*/
export function measureTextWidth(el: HTMLElement): number {
const style = getComputedStyle(el)
const clone = document.createElement('span')
clone.textContent = el.textContent ?? ''
clone.style.position = 'fixed'
clone.style.top = '-9999px'
clone.style.left = '-9999px'
clone.style.visibility = 'hidden'
clone.style.whiteSpace = 'nowrap'
for (const prop of FONT_PROPS) clone.style[prop] = style[prop]
document.body.appendChild(clone)
const textWidth = clone.getBoundingClientRect().width
clone.remove()
return textWidth
}
/**
* Detects whether a single-line, ellipsis-truncated element is actually
* clipping its text by comparing its full text width against the available
* content width.
*/
export function isTextOverflowing(el: HTMLElement): boolean {
const contentWidth = el.clientWidth
if (contentWidth <= 0) return false
return measureTextWidth(el) > contentWidth + 0.5
}

View File

@@ -1,317 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import {
buildLinkReleaseNodeCategories,
computeContextMenuTop,
computeSubmenuAlignOffset,
computeSubmenuMaxHeight,
estimateLinkReleaseMenuHeight,
filterNodesByName,
getLinkReleaseHeaderLabel,
getLinkReleaseSuggestions,
groupLinkReleaseSearchResults,
searchLinkReleaseNodes
} from './linkReleaseMenuModel'
import type { LinkReleaseContext } from './linkReleaseMenuModel'
function coreNode(name: string, display_name = name): ComfyNodeDefImpl {
return {
name,
display_name,
nodeSource: { type: NodeSourceType.Core },
api_node: false
} as ComfyNodeDefImpl
}
function customNode(name: string, display_name = name): ComfyNodeDefImpl {
return {
name,
display_name,
nodeSource: { type: NodeSourceType.CustomNodes },
api_node: false
} as ComfyNodeDefImpl
}
function partnerNode(name: string, display_name = name): ComfyNodeDefImpl {
return {
name,
display_name,
nodeSource: { type: NodeSourceType.Core },
api_node: true
} as ComfyNodeDefImpl
}
const ksampler = coreNode('KSampler')
const vaeDecode = coreNode('VAEDecode', 'VAE Decode')
const rerouteNode = coreNode('Reroute')
function createContext(
overrides: Partial<LinkReleaseContext> = {}
): LinkReleaseContext {
return {
dataType: 'MODEL',
slotName: 'model',
isFromOutput: true,
...overrides
}
}
describe('getLinkReleaseHeaderLabel', () => {
it('combines slot name and data type', () => {
const label = getLinkReleaseHeaderLabel(
createContext({ slotName: 'model', dataType: 'MODEL' })
)
expect(label).toBe('model | MODEL')
})
it('falls back to whichever value is present', () => {
const onlyType = getLinkReleaseHeaderLabel(
createContext({ slotName: '', dataType: 'IMAGE' })
)
const onlyName = getLinkReleaseHeaderLabel(
createContext({ slotName: 'clip', dataType: '' })
)
expect(onlyType).toBe('IMAGE')
expect(onlyName).toBe('clip')
})
})
describe('getLinkReleaseSuggestions', () => {
it('excludes the Reroute node', () => {
const suggestions = getLinkReleaseSuggestions([rerouteNode, vaeDecode])
expect(suggestions.map((n) => n.name)).toEqual(['VAEDecode'])
})
it('preserves the incoming order of remaining nodes', () => {
const suggestions = getLinkReleaseSuggestions([vaeDecode, ksampler])
expect(suggestions.map((n) => n.name)).toEqual(['VAEDecode', 'KSampler'])
})
})
describe('buildLinkReleaseNodeCategories', () => {
it('groups nodes by source into comfy, extensions and partner buckets', () => {
const ext = customNode('ExtNode', 'Ext Node')
const partner = partnerNode('PartnerNode', 'Partner Node')
const categories = buildLinkReleaseNodeCategories([ksampler, ext, partner])
const byKey = Object.fromEntries(categories.map((c) => [c.key, c]))
expect(byKey.comfy.nodes.map((n) => n.name)).toContain('KSampler')
expect(byKey.extensions.nodes.map((n) => n.name)).toContain('ExtNode')
expect(byKey.partner.nodes.map((n) => n.name)).toContain('PartnerNode')
})
it('omits empty buckets', () => {
const categories = buildLinkReleaseNodeCategories([ksampler])
expect(categories.map((c) => c.key)).toEqual(['comfy'])
})
it('orders buckets comfy, extensions, partner', () => {
const categories = buildLinkReleaseNodeCategories([
partnerNode('P'),
customNode('E'),
coreNode('C')
])
expect(categories.map((c) => c.key)).toEqual([
'comfy',
'extensions',
'partner'
])
})
it('sorts nodes alphabetically by display name within a bucket', () => {
const categories = buildLinkReleaseNodeCategories([
coreNode('B'),
coreNode('A')
])
expect(categories[0].nodes.map((n) => n.display_name)).toEqual(['A', 'B'])
})
it('classifies api-category nodes as partner', () => {
const apiNode = {
name: 'ApiThing',
display_name: 'Api Thing',
nodeSource: { type: NodeSourceType.Core },
api_node: false,
category: 'api node/openai'
} as ComfyNodeDefImpl
const categories = buildLinkReleaseNodeCategories([apiNode])
expect(categories.map((c) => c.key)).toEqual(['partner'])
})
})
describe('filterNodesByName', () => {
it('returns all nodes when query is blank', () => {
expect(filterNodesByName([ksampler, vaeDecode], ' ')).toHaveLength(2)
})
it('matches display name case-insensitively', () => {
const result = filterNodesByName([ksampler, vaeDecode], 'vae')
expect(result.map((n) => n.name)).toEqual(['VAEDecode'])
})
})
describe('groupLinkReleaseSearchResults', () => {
const categories = buildLinkReleaseNodeCategories([
coreNode('LoadImage', 'Load Image'),
customNode('ImageBlend', 'Image Blend'),
partnerNode('ImageGen', 'Image Gen'),
coreNode('KSampler')
])
it('returns no groups for a blank query', () => {
expect(groupLinkReleaseSearchResults(categories, ' ')).toEqual([])
})
it('groups matching nodes by category', () => {
const groups = groupLinkReleaseSearchResults(categories, 'image')
expect(groups.map((g) => g.category.key)).toEqual([
'comfy',
'extensions',
'partner'
])
expect(groups.map((g) => g.nodes.map((n) => n.name))).toEqual([
['LoadImage'],
['ImageBlend'],
['ImageGen']
])
})
it('omits categories with no matches', () => {
const groups = groupLinkReleaseSearchResults(categories, 'ksampler')
expect(groups.map((g) => g.category.key)).toEqual(['comfy'])
expect(groups[0].nodes.map((n) => n.name)).toEqual(['KSampler'])
})
})
describe('searchLinkReleaseNodes', () => {
const categories = buildLinkReleaseNodeCategories([
coreNode('LoadImage', 'Load Image'),
customNode('ImageBlend', 'Image Blend'),
partnerNode('ImageGen', 'Image Gen'),
coreNode('KSampler')
])
it('returns no matches for a blank query', () => {
expect(searchLinkReleaseNodes(categories, ' ')).toEqual([])
})
it('flattens matching nodes across categories, tagged with their category', () => {
const matches = searchLinkReleaseNodes(categories, 'image')
expect(matches.map((m) => m.node.name)).toEqual([
'LoadImage',
'ImageBlend',
'ImageGen'
])
expect(matches.map((m) => m.category.key)).toEqual([
'comfy',
'extensions',
'partner'
])
})
it('matches display name case-insensitively', () => {
const matches = searchLinkReleaseNodes(categories, 'ksampler')
expect(matches.map((m) => m.node.name)).toEqual(['KSampler'])
expect(matches[0].category.key).toBe('comfy')
})
it('returns an empty list when nothing matches', () => {
expect(searchLinkReleaseNodes(categories, 'zzz')).toEqual([])
})
})
describe('computeSubmenuAlignOffset', () => {
it('lifts the submenu up to the root search field for a trigger below it', () => {
const offset = computeSubmenuAlignOffset({
triggerTop: 200,
rootSearchTop: 48,
contentPaddingTop: 4
})
expect(offset).toBe(-156)
})
it('offsets only by the content padding when the trigger sits at the search field', () => {
const offset = computeSubmenuAlignOffset({
triggerTop: 48,
rootSearchTop: 48,
contentPaddingTop: 4
})
expect(offset).toBe(-4)
})
})
describe('computeSubmenuMaxHeight', () => {
it('grows to the space below when there is ample room', () => {
const height = computeSubmenuMaxHeight({
submenuTop: 100,
contextMenuHeight: 420,
viewportHeight: 1000,
margin: 8
})
expect(height).toBe(892)
})
it('floors at the context menu height when room below is smaller', () => {
const height = computeSubmenuMaxHeight({
submenuTop: 600,
contextMenuHeight: 420,
viewportHeight: 1000,
margin: 8
})
expect(height).toBe(420)
})
})
describe('estimateLinkReleaseMenuHeight', () => {
it('estimates a typical default layout with header, suggestions, categories and reroute', () => {
const height = estimateLinkReleaseMenuHeight({
hasHeader: true,
suggestionCount: 4,
categoryCount: 3,
searchResultCount: 0,
showReroute: true
})
expect(height).toBe(468)
})
it('estimates search results instead of the default sections', () => {
const height = estimateLinkReleaseMenuHeight({
hasHeader: true,
suggestionCount: 4,
categoryCount: 3,
searchResultCount: 5,
searchResultGroupCount: 2,
showReroute: false
})
expect(height).toBe(280)
})
})
describe('computeContextMenuTop', () => {
const base = {
menuHeight: 468,
viewportHeight: 1000,
margin: 8,
sideOffset: 4
}
it('bottom-anchors when the cursor is near the viewport bottom', () => {
const top = computeContextMenuTop({ ...base, cursorY: 900 })
expect(top).toBe(524)
})
it('opens at the cursor when there is room below', () => {
const top = computeContextMenuTop({ ...base, cursorY: 100 })
expect(top).toBe(104)
})
it('pins to the top margin when the cursor is above the viewport', () => {
const top = computeContextMenuTop({ ...base, cursorY: -10 })
expect(top).toBe(8)
})
})

View File

@@ -1,264 +0,0 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
export interface LinkReleaseContext {
/** The data type of the slot the link was dragged from (e.g. "MODEL"). */
dataType: string
/** The name of the slot the link was dragged from (e.g. "model"). */
slotName: string
/**
* Whether the released link originates from an output slot, meaning the new
* node will be connected to via one of its inputs.
*/
isFromOutput: boolean
}
type LinkReleaseCategoryKey = 'comfy' | 'extensions' | 'partner'
export interface LinkReleaseNodeCategory {
key: LinkReleaseCategoryKey
/** i18n key for the group heading. */
labelKey: string
/** Iconify class shown beside the group label. */
icon: string
/** Nodes in the group, sorted alphabetically by display name. */
nodes: ComfyNodeDefImpl[]
}
const CATEGORY_META: Record<
LinkReleaseCategoryKey,
{ labelKey: string; icon: string }
> = {
comfy: { labelKey: 'contextMenu.Comfy Nodes', icon: 'icon-[lucide--box]' },
extensions: {
labelKey: 'contextMenu.Extensions',
icon: 'icon-[lucide--puzzle]'
},
partner: {
labelKey: 'contextMenu.Partner Nodes',
icon: 'icon-[lucide--handshake]'
}
}
const CATEGORY_ORDER: LinkReleaseCategoryKey[] = [
'comfy',
'extensions',
'partner'
]
export function getLinkReleaseHeaderLabel(context: LinkReleaseContext): string {
const { slotName, dataType } = context
if (slotName && dataType) return `${slotName} | ${dataType}`
return slotName || dataType
}
function classifyNode(node: ComfyNodeDefImpl): LinkReleaseCategoryKey {
if (node.api_node || node.category?.startsWith('api node')) return 'partner'
if (
node.nodeSource.type === NodeSourceType.Core ||
node.nodeSource.type === NodeSourceType.Essentials
) {
return 'comfy'
}
return 'extensions'
}
function byDisplayName(a: ComfyNodeDefImpl, b: ComfyNodeDefImpl): number {
return a.display_name.localeCompare(b.display_name)
}
/**
* Group slot-compatible nodes into source buckets for the cascading menu.
* Empty buckets are omitted and each bucket's nodes are sorted by display name.
*/
export function buildLinkReleaseNodeCategories(
compatibleNodes: ComfyNodeDefImpl[]
): LinkReleaseNodeCategory[] {
const buckets: Record<LinkReleaseCategoryKey, ComfyNodeDefImpl[]> = {
comfy: [],
extensions: [],
partner: []
}
for (const node of compatibleNodes) {
buckets[classifyNode(node)].push(node)
}
return CATEGORY_ORDER.filter((key) => buckets[key].length > 0).map((key) => ({
key,
labelKey: CATEGORY_META[key].labelKey,
icon: CATEGORY_META[key].icon,
nodes: [...buckets[key]].sort(byDisplayName)
}))
}
/** Quick-add suggestions for the released slot, excluding the Reroute node. */
export function getLinkReleaseSuggestions(
defaultNodeDefs: ComfyNodeDefImpl[]
): ComfyNodeDefImpl[] {
return defaultNodeDefs.filter((nodeDef) => nodeDef.name !== 'Reroute')
}
/** Case-insensitive filter of a node list by display name. */
export function filterNodesByName(
nodes: ComfyNodeDefImpl[],
query: string
): ComfyNodeDefImpl[] {
const trimmed = query.trim().toLowerCase()
if (!trimmed) return nodes
return nodes.filter((nodeDef) =>
nodeDef.display_name.toLowerCase().includes(trimmed)
)
}
/** A node surfaced by the root flat-value search, tagged with its category. */
export interface LinkReleaseNodeMatch {
category: LinkReleaseNodeCategory
node: ComfyNodeDefImpl
}
export interface LinkReleaseSearchResultGroup {
category: LinkReleaseNodeCategory
nodes: ComfyNodeDefImpl[]
}
/**
* Group matching nodes by category for the root flat-value search. Empty
* categories are omitted; category order and per-category display-name order
* are preserved.
*/
export function groupLinkReleaseSearchResults(
categories: LinkReleaseNodeCategory[],
query: string
): LinkReleaseSearchResultGroup[] {
const trimmed = query.trim().toLowerCase()
if (!trimmed) return []
return categories
.map((category) => ({
category,
nodes: category.nodes.filter((node) =>
node.display_name.toLowerCase().includes(trimmed)
)
}))
.filter((group) => group.nodes.length > 0)
}
/**
* Flat-value search across every category submenu: when the root search has
* text we surface matching nodes inline (tagged with their category) so a node
* can be picked straight from the root without first drilling into a submenu.
* Results preserve category order, then per-category display-name order.
*/
export function searchLinkReleaseNodes(
categories: LinkReleaseNodeCategory[],
query: string
): LinkReleaseNodeMatch[] {
const matches: LinkReleaseNodeMatch[] = []
for (const group of groupLinkReleaseSearchResults(categories, query)) {
for (const node of group.nodes) {
matches.push({ category: group.category, node })
}
}
return matches
}
/**
* Vertical `alignOffset` (px) that makes a category submenu open level with the
* root menu rather than with the hovered trigger row. Positioning the submenu's
* top one content-padding above the root search field lines the submenu's own
* search field up with the root search field, since both menus share the same
* content padding and search-field markup.
*/
export function computeSubmenuAlignOffset(metrics: {
triggerTop: number
rootSearchTop: number
contentPaddingTop: number
}): number {
const { triggerTop, rootSearchTop, contentPaddingTop } = metrics
return rootSearchTop - contentPaddingTop - triggerTop
}
/**
* Max height (px) for a category submenu pinned level with the root menu. The
* panel grows into the viewport space below its top, but never shrinks below
* the root menu's height so it can always be at least as tall as the context
* menu even when there is little room beneath it.
*/
export function computeSubmenuMaxHeight(metrics: {
submenuTop: number
contextMenuHeight: number
viewportHeight: number
margin: number
}): number {
const { submenuTop, contextMenuHeight, viewportHeight, margin } = metrics
return Math.max(contextMenuHeight, viewportHeight - submenuTop - margin)
}
const CONTENT_PADDING_Y = 8
const HEADER_HEIGHT = 36
const SEARCH_HEIGHT = 40
const SEPARATOR_HEIGHT = 8
const SECTION_LABEL_HEIGHT = 36
const MENU_ITEM_HEIGHT = 36
/**
* Rough pixel height of the link-release context menu from its Tailwind layout.
* Used once on open to bottom-anchor the panel without relying on Reka's 80vh
* collision sizing.
*/
export function estimateLinkReleaseMenuHeight(layout: {
hasHeader: boolean
suggestionCount: number
categoryCount: number
searchResultCount: number
searchResultGroupCount?: number
showReroute: boolean
}): number {
const {
hasHeader,
suggestionCount,
categoryCount,
searchResultCount,
searchResultGroupCount = 0,
showReroute
} = layout
let height = CONTENT_PADDING_Y + SEARCH_HEIGHT + SEPARATOR_HEIGHT
if (hasHeader) height += HEADER_HEIGHT
if (searchResultCount > 0) {
height += searchResultCount * MENU_ITEM_HEIGHT
if (searchResultGroupCount > 1) {
height += (searchResultGroupCount - 1) * SEPARATOR_HEIGHT
}
return height
}
if (suggestionCount > 0) {
height += SECTION_LABEL_HEIGHT + suggestionCount * MENU_ITEM_HEIGHT
}
if (suggestionCount > 0 && categoryCount > 0) {
height += SEPARATOR_HEIGHT
}
if (categoryCount > 0) {
height += SECTION_LABEL_HEIGHT + categoryCount * MENU_ITEM_HEIGHT
}
if (showReroute) {
height += SEPARATOR_HEIGHT + MENU_ITEM_HEIGHT
}
return height
}
/** Bottom-anchor the context menu top edge within the viewport. */
export function computeContextMenuTop(metrics: {
cursorY: number
menuHeight: number
viewportHeight: number
margin: number
sideOffset: number
}): number {
const { cursorY, menuHeight, viewportHeight, margin, sideOffset } = metrics
const menuTopAtCursor = cursorY + sideOffset
const maxMenuTop = Math.max(margin, viewportHeight - margin - menuHeight)
return Math.min(Math.max(margin, menuTopAtCursor), maxMenuTop)
}

View File

@@ -142,9 +142,8 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
[RootCategory.Custom]: isCustomNode
}
const { filters, defaultRootFilter = null } = defineProps<{
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
defaultRootFilter?: RootCategoryId | null
}>()
const emit = defineEmits<{
@@ -196,7 +195,7 @@ function onSearchFocus() {
}
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<RootCategoryId | null>(defaultRootFilter)
const rootFilter = ref<RootCategoryId | null>(null)
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {

View File

@@ -4,12 +4,8 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
CanvasPointer,
CanvasPointerEvent,
LGraphCanvas
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -256,273 +252,6 @@ describe('Widget change error clearing via onWidgetChanged', () => {
expect(store.lastNodeErrors).toBeNull()
expect(mediaStore.missingMediaCandidates).toBeNull()
})
it('uses interior node execution ID for promoted widget error clearing', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'model.safetensors',
() => undefined,
{ values: ['model.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
seedRequiredInputMissingNodeError(store, interiorExecId, 'ckpt_name')
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'other_model.safetensors',
'model.safetensors',
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
it('clears range errors for promoted widgets by interior widget name', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'steps_input', type: 'INT' }]
})
const interiorNode = new LGraphNode('KSampler')
const interiorInput = interiorNode.addInput('steps_input', 'INT')
interiorNode.addWidget('number', 'steps', 150, () => undefined, {
min: 1,
max: 100
})
interiorInput.widget = { name: 'steps' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
store.lastNodeErrors = {
[interiorExecId]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'KSampler'
}
}
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'steps'
)
expect(promotedWidget).toBeDefined()
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'steps',
50,
150,
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
it('clears missing model state when a promoted widget changes through the legacy canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
interiorNode.type = 'CheckpointLoaderSimple'
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, {
id: 65,
pos: [0, 0],
size: [200, 100]
})
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const missingModelStore = useMissingModelStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
missingModelStore.setMissingModels([
{
nodeId: interiorExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
const promotedWidget = subgraphNode.widgets?.find(
(widget) =>
'sourceWidgetName' in widget && widget.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
const clickEvent = fromAny<CanvasPointerEvent, unknown>({
canvasX: 190,
canvasY: 20,
deltaX: 0
})
const pointer = fromAny<CanvasPointer, unknown>({
eDown: clickEvent
})
const canvas = fromAny<LGraphCanvas, unknown>({
graph_mouse: [190, 20],
last_mouseclick: 0
})
const handled = promotedWidget!.onPointerDown?.(
pointer,
subgraphNode,
canvas
)
expect(handled).toBe(true)
expect(pointer.onClick).toBeDefined()
pointer.onClick?.(clickEvent)
expect(missingModelStore.missingModelCandidates).toBeNull()
})
it('keeps unchanged same-named promoted model targets on the canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first_ckpt', type: '*' },
{ name: 'second_ckpt', type: '*' }
]
})
const firstNode = new LGraphNode('CheckpointLoaderSimple')
firstNode.type = 'CheckpointLoaderSimple'
const firstInput = firstNode.addInput('first_ckpt', '*')
const firstWidget = firstNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
firstInput.widget = { name: 'ckpt_name' }
subgraph.add(firstNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
const secondNode = new LGraphNode('CheckpointLoaderSimple')
secondNode.type = 'CheckpointLoaderSimple'
const secondInput = secondNode.addInput('second_ckpt', '*')
secondNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
secondInput.widget = { name: 'ckpt_name' }
subgraph.add(secondNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const promotedWidgets =
subgraphNode.widgets?.filter(
(widget) =>
'sourceWidgetName' in widget &&
widget.sourceWidgetName === 'ckpt_name'
) ?? []
expect(promotedWidgets).toHaveLength(2)
const missingModelStore = useMissingModelStore()
const firstExecId = `${subgraphNode.id}:${firstNode.id}`
const secondExecId = `${subgraphNode.id}:${secondNode.id}`
missingModelStore.setMissingModels([
{
nodeId: firstExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate,
{
nodeId: secondExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
firstWidget.value = 'present.safetensors'
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'present.safetensors',
'missing.safetensors',
firstWidget
)
expect(missingModelStore.missingModelCandidates).toEqual([
expect.objectContaining({
nodeId: secondExecId,
widgetName: 'ckpt_name',
name: 'missing.safetensors'
})
])
})
})
describe('installErrorClearingHooks lifecycle', () => {
@@ -1249,4 +978,54 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
clearSpy.mockRestore()
})
it('clears promoted widget errors by interior execution id', () => {
const subgraph = createTestSubgraph()
const graph = subgraph.rootGraph
const host = createTestSubgraphNode(subgraph, { id: 2 })
graph.add(host)
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
interiorNode.id = 1
subgraph.add(interiorNode)
const input = interiorNode.addInput('ckpt_name', 'COMBO')
const widget = interiorNode.addWidget(
'combo',
'ckpt_name',
'fake_model.safetensors',
() => undefined,
{ values: ['fake_model.safetensors', 'real_model.safetensors'] }
)
input.widget = { name: widget.name }
expect(
promoteValueWidgetViaSubgraphInput(host, interiorNode, widget).ok
).toBe(true)
installErrorClearingHooks(graph)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const missingModelStore = useMissingModelStore()
missingModelStore.setMissingModels([
{
nodeId: '2:1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'fake_model.safetensors',
directory: 'checkpoints',
isMissing: true
}
])
const promotedWidget = host.widgets[0]
host.onWidgetChanged!.call(
host,
promotedWidget.name,
'real_model.safetensors',
'fake_model.safetensors',
promotedWidget
)
expect(missingModelStore.hasMissingModels).toBe(false)
})
})

View File

@@ -6,12 +6,9 @@
* works in legacy canvas mode as well.
*/
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import {
LGraphEventMode,
NodeSlotType
@@ -46,130 +43,6 @@ import {
isAncestorPathActive
} from '@/utils/graphTraversalUtil'
interface WidgetErrorClearingTarget {
executionId: string
validationInputName: string
assetWidgetName: string
currentValue: unknown
options?: { min?: number; max?: number }
}
function getWidgetRangeOptions(widget: IBaseWidget): {
min?: number
max?: number
} {
return {
min: widget.options?.min,
max: widget.options?.max
}
}
function plainWidgetToErrorTarget(
widget: IBaseWidget,
hostExecId: string
): WidgetErrorClearingTarget {
return {
executionId: hostExecId,
validationInputName: widget.name,
assetWidgetName: widget.name,
currentValue: widget.value,
options: getWidgetRangeOptions(widget)
}
}
function promotedWidgetToErrorTarget(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: PromotedWidgetView,
hostExecId: string
): WidgetErrorClearingTarget {
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
const execId =
result.status === 'resolved' && result.resolved.node
? (getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId)
: hostExecId
const resolvedWidget =
result.status === 'resolved' ? result.resolved.widget : widget
return {
executionId: execId,
validationInputName: resolvedWidget.name,
assetWidgetName: widget.sourceWidgetName,
currentValue: resolvedWidget.value,
options: getWidgetRangeOptions(resolvedWidget)
}
}
function resolveCanvasPathPromotedWidgetTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (!hostNode.isSubgraphNode?.() || isPromotedWidgetView(widget)) return []
// Canvas-path events lose promoted identity, so the post-write value
// disambiguates same-named promoted widgets.
return (hostNode.widgets ?? [])
.filter(isPromotedWidgetView)
.filter((promotedWidget) => promotedWidget.sourceWidgetName === widget.name)
.map((promotedWidget) =>
promotedWidgetToErrorTarget(
rootGraph,
hostNode,
promotedWidget,
hostExecId
)
)
.filter((target) => Object.is(target.currentValue, newValue))
}
function resolveWidgetErrorTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (isPromotedWidgetView(widget)) {
return [
promotedWidgetToErrorTarget(rootGraph, hostNode, widget, hostExecId)
]
}
const canvasPathTargets = resolveCanvasPathPromotedWidgetTargets(
rootGraph,
hostNode,
widget,
hostExecId,
newValue
)
return canvasPathTargets.length
? canvasPathTargets
: [plainWidgetToErrorTarget(widget, hostExecId)]
}
function clearWidgetErrorTargets(
targets: WidgetErrorClearingTarget[],
newValue: unknown
): void {
const store = useExecutionErrorStore()
for (const target of targets) {
store.clearWidgetRelatedErrors(
target.executionId,
target.validationInputName,
target.assetWidgetName,
newValue,
target.options
)
}
}
const hookedNodes = new WeakSet<LGraphNode>()
type OriginalCallbacks = {
@@ -203,21 +76,24 @@ function installNodeHooks(node: LGraphNode): void {
node.onWidgetChanged = useChainCallback(
node.onWidgetChanged,
// _name is the LiteGraph callback arg; re-derive from the widget
// object to handle promoted widgets where sourceWidgetName differs.
function (_name, newValue, _oldValue, widget) {
if (!app.rootGraph) return
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
if (!hostExecId) return
const targets = resolveWidgetErrorTargets(
app.rootGraph,
node,
widget,
hostExecId,
newValue
const promotedSource = widgetPromotedSource(node, widget)
const executionId = promotedSource
? `${hostExecId}:${promotedSource.nodeId}`
: hostExecId
const widgetName = promotedSource?.widgetName ?? widget.name
useExecutionErrorStore().clearWidgetRelatedErrors(
executionId,
widgetName,
widgetName,
newValue,
{ min: widget.options?.min, max: widget.options?.max }
)
clearWidgetErrorTargets(targets, newValue)
}
)
}

View File

@@ -1,13 +1,11 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetEntityId } from '@/world/entityIds'
import { widgetId } from '@/types/widgetId'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -47,9 +45,10 @@ describe('Node Reactivity', () => {
expect((widget as BaseWidget).node.id).toBe(node.id)
// Initial value should be in store after setNodeId was called
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
const id = widgetId(graph.id, node.id, 'testnum')
expect(store.getWidget(id)?.value).toBe(2)
const state = store.getWidget(graph.id, node.id, 'testnum')
const state = store.getWidget(id)
if (!state) throw new Error('Expected widget state to exist')
const onValueChange = vi.fn()
@@ -74,7 +73,7 @@ describe('Node Reactivity', () => {
})
await nextTick()
const state = store.getWidget(graph.id, node.id, 'testnum')
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
if (!state) throw new Error('Expected widget state to exist')
const widgetValue = computed(() => state.value)
@@ -211,105 +210,32 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
// Set up a subgraph with an interior node that has a "prompt" widget.
// createPromotedWidgetView resolves against this interior node.
const subgraph = createTestSubgraph()
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
// Subgraph input named "value" promotes an interior "prompt" widget. The
// projected widget's name is the input slot name "value"; the interior
// source widget name "prompt" is carried separately for backend lookups.
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
const interiorNode = new LGraphNode('interior')
interiorNode.id = 10
const interiorInput = interiorNode.addInput('value', 'STRING')
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
interiorInput.widget = { name: 'prompt' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with identityName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
'value',
'value'
)
// Host the promoted view on a regular node so we can control widgets
// directly (SubgraphNode.widgets is a synthetic getter).
const graph = new LGraph()
const hostNode = new LGraphNode('host')
hostNode.widgets = [promotedView]
const input = hostNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
graph.add(hostNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(hostNode.id))
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
// input slot widget name is "value" — slotName bridges this gap.
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
expect(widgetData?.slotName).toBe('value')
expect(widgetData?.slotMetadata).toBeDefined()
})
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'seed', type: '*' },
{ name: 'seed', type: '*' }
]
})
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('seed', '*')
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('seed', '*')
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
const graph = subgraphNode.graph
if (!graph) throw new Error('Expected subgraph node graph')
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const promotedViews = subgraphNode.widgets
const secondPromotedView = promotedViews[1]
if (!secondPromotedView) throw new Error('Expected second promoted view')
fromAny<
{
sourceNodeId: string
sourceWidgetName: string
},
unknown
>(secondPromotedView).sourceNodeId = '9999'
fromAny<
{
sourceNodeId: string
sourceWidgetName: string
},
unknown
>(secondPromotedView).sourceWidgetName = 'stale_widget'
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const secondMappedWidget = nodeData?.widgets?.find(
(widget) => widget.slotMetadata?.index === 1
)
if (!secondMappedWidget)
throw new Error('Expected mapped widget for slot 1')
expect(secondMappedWidget.name).not.toBe('stale_widget')
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
})
it('clears stale slotMetadata when input no longer matches widget', async () => {
@@ -448,8 +374,8 @@ describe('Nested promoted widget mapping', () => {
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.entityId).toBe(
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
expect(mappedWidget?.widgetId).toBe(
widgetId(graph.id, subgraphNodeB.id, 'b_input')
)
})
@@ -484,13 +410,13 @@ describe('Nested promoted widget mapping', () => {
const widgets = nodeData?.widgets
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
expect(widgets?.[0]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'first_seed')
)
expect(widgets?.[1]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
expect(widgets?.[1]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'second_seed')
)
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
})
})
@@ -528,10 +454,11 @@ describe('Promoted widget sourceExecutionId', () => {
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_name'
(w) => w.name === 'ckpt_input'
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
// The interior node is inside subgraphNode (id=65),
// so its execution ID should be "65:<interiorNodeId>"
expect(promotedWidget?.sourceExecutionId).toBe(

View File

@@ -3,17 +3,16 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetSource
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
inputForWidget,
promotedInputSource,
promotedInputWidgets
} from '@/core/graph/subgraph/promotedInputWidget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -27,10 +26,11 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
import type { WidgetEntityId } from '@/world/entityIds'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { WidgetId } from '@/types/widgetId'
import type {
LGraph,
@@ -38,7 +38,8 @@ import type {
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
LGraphTriggerParam,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
@@ -60,7 +61,7 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
entityId?: WidgetEntityId
widgetId?: WidgetId
nodeId?: NodeId
name: string
type: string
@@ -81,17 +82,12 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
values?: unknown
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Original LiteGraph widget name used for slot metadata matching.
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
* which differs from the subgraph node's input slot widget name.
*/
slotName?: string
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the
@@ -99,10 +95,14 @@ export interface SafeWidgetData {
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
/**
* Interior source widget name. Only set for promoted widgets, where `name`
* is the host input slot name; missing-model lookups key by the interior
* widget name, which can differ from the slot name (e.g. after a rename).
*/
sourceWidgetName?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
promotedLabel?: string
}
export interface VueNodeData {
@@ -143,18 +143,6 @@ export interface GraphNodeManager {
cleanup(): void
}
function isPromotedDOMWidget(widget: IBaseWidget): boolean {
if (!isPromotedWidgetView(widget)) return false
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
if (!sourceWidget) return false
const innerWidget = sourceWidget.widget
return (
('element' in innerWidget && !!innerWidget.element) ||
('component' in innerWidget && !!innerWidget.component)
)
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
@@ -214,73 +202,83 @@ function normalizeWidgetValue(value: unknown): WidgetValue {
return undefined
}
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function isDOMBackedWidget(widget: IBaseWidget): boolean {
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: string
sourceWidgetName?: string
}
/**
* Resolves the interior source of a promoted subgraph input to derive the
* metadata that backend lookups key by (execution ID, interior widget name)
* plus the source widget's control + DOM nature. Also seeds host widget state
* if it is somehow missing. Returns undefined when the widget is not promoted.
*/
function resolvePromotedMetadata(
node: SubgraphNode,
widget: IBaseWidget
): PromotedWidgetMetadata | undefined {
const input = inputForWidget(node, widget)
if (!input?.widgetId) return undefined
const source = promotedInputSource(node, input)
if (!source) return undefined
const resolution = resolveConcretePromotedWidget(
node,
source.nodeId,
source.widgetName
)
const resolved =
resolution.status === 'resolved' ? resolution.resolved : undefined
const sourceWidget = resolved?.widget
const sourceNode = resolved?.node
ensurePromotedHostWidgetState(input.widgetId, input, sourceWidget)
return {
controlWidget: sourceWidget ? getControlWidget(sourceWidget) : undefined,
isDOMWidget: sourceWidget ? isDOMBackedWidget(sourceWidget) : false,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
sourceWidgetName: sourceWidget?.name
}
}
function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function resolvePromotedSourceByInputName(
inputName: string
): PromotedWidgetSource | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName
}
}
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
displayName: string
promotedSource: PromotedWidgetSource | null
} {
if (!isPromotedWidgetView(widget)) {
return {
displayName: widget.name,
promotedSource: null
}
}
const matchedInput = matchPromotedInput(node.inputs, widget)
const promotedInputName = matchedInput?.name
const displayName = promotedInputName ?? widget.name
const directSource: PromotedWidgetSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
const promotedSource =
matchedInput?._widget === widget
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
: directSource
return {
displayName,
promotedSource
}
}
const duplicateIndexByKey = new Map<string, number>()
return function (widget) {
try {
const { displayName, promotedSource } =
resolvePromotedWidgetIdentity(widget)
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const slotInfo =
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const slotInfo = slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
@@ -294,67 +292,26 @@ function safeWidgetMapper(
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const isPromotedPseudoWidget =
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
// Extract only render-critical options (canvasOnly, advanced, read_only)
const options = extractWidgetDisplayOptions(widget)
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const resolvedSourceResult =
isPromotedWidgetView(widget) && promotedSource
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName
)
: null
const resolvedSource =
resolvedSourceResult?.status === 'resolved'
? resolvedSourceResult.resolved
: undefined
const sourceWidget = resolvedSource?.widget
const sourceNode = resolvedSource?.node
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
const promoted = node.isSubgraphNode()
? resolvePromotedMetadata(node, widget)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const sourceWidgetName = isPromotedWidgetView(widget)
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
: undefined
const name = sourceWidgetName ?? displayName
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
return {
entityId: getWidgetEntityIdForNode(node, widget),
nodeId,
name,
type: effectiveWidget.type,
...sharedEnhancements,
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
name: widget.name,
type: widget.type,
...getSharedWidgetEnhancements(node, widget),
...(promoted?.controlWidget && {
controlWidget: promoted.controlWidget
}),
callback,
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
options: isPromotedPseudoWidget
? {
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
canvasOnly: true
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
options: extractWidgetDisplayOptions(widget),
slotMetadata: slotInfo,
// For promoted widgets, name is sourceWidgetName while widget.name
// is the subgraph input slot name — store the slot name for lookups.
slotName: name !== widget.name ? widget.name : undefined,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
sourceExecutionId: promoted?.sourceExecutionId,
sourceWidgetName: promoted?.sourceWidgetName,
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
@@ -370,6 +327,24 @@ function safeWidgetMapper(
}
}
function ensurePromotedHostWidgetState(
id: WidgetId,
input: INodeInputSlot,
sourceWidget: IBaseWidget | undefined
): void {
if (!sourceWidget) return
const store = useWidgetValueStore()
if (store.getWidget(id)) return
store.registerWidget(id, {
type: sourceWidget.type,
value: sourceWidget.value,
options: cloneDeep(sourceWidget.options ?? {}),
label: input.label ?? input.name,
serialize: sourceWidget.serialize,
disabled: sourceWidget.disabled
})
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
@@ -471,14 +446,16 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
const widgets = node.isSubgraphNode()
? promotedInputWidgets(node)
: (node.widgets ?? [])
return widgets.map(safeWidgetMapper(node, slotMetadata))
})
const nodeType =
@@ -534,7 +511,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
widget.slotMetadata = slotMetadata.get(widget.name)
}
}
@@ -812,7 +789,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so promotedLabel reflects the rename
// Re-extract widget data so the label reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}

View File

@@ -1,7 +1,6 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type {
LGraphGroup,
LGraphNode,
@@ -265,16 +264,8 @@ export function useMoreOptionsMenu() {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
options.push({ type: 'divider' })
}
const [widgetName, nodeId] = hoveredWidget.value ?? []
const widget =
nodeId !== undefined
? node?.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
)
: node?.widgets?.find((w) => w.name === widgetName)
const [widgetName] = hoveredWidget.value ?? []
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (widget) {
const widgetOptions = convertContextMenuToOptions(
getExtraOptionsForWidget(node, widget)

View File

@@ -1,11 +1,21 @@
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
const getNodeDisplayPrice = vi.fn(
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
String(overrides?.get('prompt') ?? 'missing override')
)
vi.mock('@/composables/node/useNodePricing', () => ({
useNodePricing: () => ({ getNodeDisplayPrice })
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
@@ -54,4 +64,43 @@ describe('subgraph pricing', () => {
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
}
)
subgraphTest(
'uses promoted widget override from any matching internal link',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
class ApiNode extends LGraphNode {
static override nodeData = { name: 'ApiNode', api_node: true }
}
const apiNode = new ApiNode('api node')
apiNode.badges = [getCreditsBadge('$0.05/Run')]
const apiInput = apiNode.addInput('prompt', 'STRING')
apiInput.widget = { name: 'prompt' }
apiNode.addWidget('string', 'prompt', 'inner value', () => undefined, {})
const decoyNode = new LGraphNode('decoy node')
const decoyInput = decoyNode.addInput('prompt', 'STRING')
decoyInput.widget = { name: 'prompt' }
decoyNode.addWidget(
'string',
'prompt',
'decoy value',
() => undefined,
{}
)
subgraph.add(decoyNode)
subgraph.add(apiNode)
subgraph.inputNode.slots[0].connect(decoyInput, decoyNode)
subgraph.inputNode.slots[0].connect(apiInput, apiNode)
subgraphNode._internalConfigureAfterSlots()
const inputWidgetId = subgraphNode.inputs[0].widgetId
if (!inputWidgetId) throw new Error('Missing promoted input widgetId')
useWidgetValueStore().setValue(inputWidgetId, 'outer value')
updateSubgraphCredits(subgraphNode)
expect(getBadgeText(subgraphNode)).toBe('outer value')
}
)
})

View File

@@ -2,9 +2,14 @@ import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
type LinkedWidgetInput = INodeInputSlot & {
_subgraphSlot?: { linkIds?: number[] }
}
const componentIconSvg = new Image()
componentIconSvg.src =
@@ -95,11 +100,20 @@ export const usePriceBadge = () => {
): ReadonlyMap<string, unknown> {
const overrides = new Map<string, unknown>()
if (!wrapper.isSubgraphNode()) return overrides
const innerId = String(innerNode.id)
for (const w of wrapper.widgets ?? []) {
if (!isPromotedWidgetView(w)) continue
if (w.sourceNodeId !== innerId) continue
overrides.set(w.sourceWidgetName, w.value)
for (const input of wrapper.inputs as LinkedWidgetInput[]) {
if (!input.widgetId) continue
for (const linkId of input._subgraphSlot?.linkIds ?? []) {
const link = wrapper.subgraph.getLink(linkId)
if (link?.target_id !== innerNode.id) continue
const targetInput = innerNode.inputs[link.target_slot]
const widgetName = targetInput?.widget?.name
if (!widgetName) continue
overrides.set(
widgetName,
useWidgetValueStore().getWidget(input.widgetId)?.value
)
}
}
return overrides
}

View File

@@ -2,6 +2,7 @@ import { render } from '@testing-library/vue'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { nextTick, reactive, ref } from 'vue'
import type { Ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobState } from '@/types/queue'
@@ -19,32 +20,24 @@ type TestTask = {
workflowId?: string
}
const translations: Record<string, string> = {
'queue.jobList.undated': 'Undated',
'g.emDash': '--',
'g.untitled': 'Untitled'
}
let localeRef: Ref<string>
let tMock: ReturnType<typeof vi.fn>
const ensureLocaleMocks = () => {
if (!localeRef) {
localeRef = ref('en-US') as Ref<string>
}
if (!tMock) {
tMock = vi.fn((key: string) => translations[key] ?? key)
}
return { localeRef, tMock }
}
vi.mock('vue-i18n', () => ({
useI18n: () => {
ensureLocaleMocks()
return {
t: tMock,
locale: localeRef
const createTestI18n = () =>
createI18n({
legacy: false,
locale: 'en-US',
messages: {
'en-US': {
queue: {
jobList: {
undated: 'Undated'
}
},
g: {
emDash: '--',
untitled: 'Untitled'
}
}
}
}
}))
})
vi.mock('@/i18n', () => ({
st: vi.fn((key: string, fallback?: string) => `i18n(${key})-${fallback}`)
@@ -184,13 +177,20 @@ const createTask = (
const mountUseJobList = () => {
let composable: ReturnType<typeof useJobList>
const result = render({
template: '<div />',
setup() {
composable = useJobList()
return {}
const result = render(
{
template: '<div />',
setup() {
composable = useJobList()
return {}
}
},
{
global: {
plugins: [createTestI18n()]
}
}
})
)
return { ...result, composable: composable! }
}
@@ -215,10 +215,6 @@ const resetStores = () => {
totalPercent.value = 0
currentNodePercent.value = 0
ensureLocaleMocks()
localeRef.value = 'en-US'
tMock.mockClear()
if (isJobInitializingMock) {
vi.mocked(isJobInitializingMock).mockReset()
vi.mocked(isJobInitializingMock).mockReturnValue(false)
@@ -561,6 +557,35 @@ describe('useJobList', () => {
)
})
it('groups terminal jobs without an execution end timestamp by create time', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
queueStoreMock.historyTasks = [
createTask({
jobId: 'failed-before-execution',
job: { priority: 1 },
mockState: 'failed',
createTime: Date.now()
}),
createTask({
jobId: 'completed-without-end-time',
job: { priority: 1 },
mockState: 'completed',
createTime: Date.now() - 1_000
})
]
const instance = initComposable()
await flush()
const groups = instance.groupedJobItems.value
expect(groups.map((g) => g.label)).toEqual(['Today'])
expect(groups[0].items.map((item) => item.id)).toEqual([
'failed-before-execution',
'completed-without-end-time'
])
})
it('groups job items by date label and sorts by total generation time when requested', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))

View File

@@ -341,7 +341,7 @@ export function useJobList() {
for (const { task, state } of searchableTaskEntries.value) {
let ts: number | undefined
if (state === 'completed' || state === 'failed') {
ts = task.executionEndTimestamp
ts = task.executionEndTimestamp ?? task.createTime
} else {
ts = task.createTime
}

View File

@@ -1,12 +1,19 @@
import { describe, expect, it } from 'vitest'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { WidgetState } from '@/types/widgetState'
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
function widget(name: string, value: unknown): WidgetState {
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
return {
name,
type: 'INPUT',
value,
nodeId: '1' as NodeId,
options: {},
y: 0
}
}
const isNumber = (v: unknown): v is number => typeof v === 'number'

View File

@@ -2,9 +2,9 @@ import { computed } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { Bounds } from '@/renderer/core/layout/types'
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
import type { WidgetState } from '@/types/widgetState'
type ValueExtractor<T = unknown> = (
widgets: WidgetState[],

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -21,8 +20,8 @@ import {
normalizeLegacyProxyWidgetEntry,
readHostQuarantine
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
@@ -55,39 +54,15 @@ function addInnerNode(
return node
}
function addPromotedHostInput(
function getPromotedInputValue(
host: SubgraphNode,
args: {
inputName: string
promotedName: string
sourceNodeId: string
sourceWidgetName: string
initialValue?: TWidgetValue
}
): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } {
let widgetValue: TWidgetValue = args.initialValue ?? 0
const slot = host.addInput(args.inputName, '*')
slot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: args.promotedName,
sourceNodeId: args.sourceNodeId,
sourceWidgetName: args.sourceWidgetName,
get value() {
return widgetValue
},
set value(v: TWidgetValue) {
widgetValue = v
},
hydrateHostValue(v: TWidgetValue) {
widgetValue = v
}
})
return {
setValue: (v) => {
widgetValue = v
},
getValue: () => widgetValue
}
name: string
): TWidgetValue | undefined {
const input = host.inputs.find((input) => input.name === name)
if (!input?.widgetId) return undefined
return useWidgetValueStore().getWidget(input.widgetId)?.value as
| TWidgetValue
| undefined
}
function addPrimitiveWithTargets(
@@ -141,29 +116,6 @@ describe('flushProxyWidgetMigration', () => {
})
describe('value-widget repair', () => {
it('alreadyLinked: applies host value to the matching promoted widget', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 0
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(handle.getValue()).toBe(99)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
@@ -183,23 +135,61 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: [99]
})
expect(host.widgets[0].value).toBe(99)
expect(getPromotedInputValue(host, 'seed')).toBe(99)
const innerWidget = inner.widgets!.find((w) => w.name === 'seed')!
expect(innerWidget.value).toBe(0)
})
it('createSubgraphInput: uses disambiguator for duplicate nested widget names', () => {
const rootGraph = new LGraph()
const innerSubgraph = createTestSubgraph({ rootGraph })
const firstText = new LGraphNode('CLIPTextEncode')
const firstSlot = firstText.addInput('text', 'STRING')
firstSlot.widget = { name: 'text' }
firstText.addWidget('text', 'text', '11111111111', () => {})
innerSubgraph.add(firstText)
const secondText = new LGraphNode('CLIPTextEncode')
const secondSlot = secondText.addInput('text', 'STRING')
secondSlot.widget = { name: 'text' }
secondText.addWidget('text', 'text', '22222222222', () => {})
innerSubgraph.add(secondText)
const nestedHost = createTestSubgraphNode(innerSubgraph, {
parentGraph: rootGraph
})
nestedHost.properties.proxyWidgets = [
[String(firstText.id), 'text'],
[String(secondText.id), 'text']
]
flushProxyWidgetMigration({ hostNode: nestedHost })
const outerSubgraph = createTestSubgraph({ rootGraph })
outerSubgraph.add(nestedHost)
const outerHost = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph
})
outerHost.properties.proxyWidgets = [
[String(nestedHost.id), 'text', String(secondText.id)]
]
flushProxyWidgetMigration({ hostNode: outerHost })
expect(getPromotedInputValue(outerHost, 'text')).toBe('22222222222')
})
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
const host = buildHost()
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const host = createTestSubgraphNode(subgraph)
host.graph!.add(host)
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 7
const slot = n.addInput('seed', 'INT')
const innerWidget = n.addWidget('number', 'seed', 7, () => {})
slot.widget = { name: innerWidget.name }
})
subgraph.inputNode.slots[0].connect(inner.inputs[0], inner)
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
const sparse: unknown[] = []
@@ -208,43 +198,7 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: sparse
})
expect(handle.getValue()).toBe(7)
})
it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const a = addPromotedHostInput(host, {
inputName: 'first_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 1
})
const b = addPromotedHostInput(host, {
inputName: 'second_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 2
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(a.getValue()).toBe(1)
expect(b.getValue()).toBe(2)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'seed'],
reason: 'ambiguousSubgraphInput'
})
])
expect(getPromotedInputValue(host, 'seed')).toBe(7)
})
it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => {
@@ -264,29 +218,25 @@ describe('flushProxyWidgetMigration', () => {
expect(created?._widget).toBeDefined()
})
it('createSubgraphInput: honors disambiguatingSourceNodeId when source widget name has been deduplicated', () => {
it('createSubgraphInput: preserves the source slot label', () => {
const host = buildHost()
const inner = addInnerNode(host, 'InnerWithDedupedPromotion', (n) => {
const slot1 = n.addInput('text', 'STRING')
slot1.widget = { name: 'text' }
const w1 = n.addWidget('text', 'text', '11111111111', () => {})
Object.assign(w1, { sourceNodeId: '1', sourceWidgetName: 'text' })
const slot2 = n.addInput('text_1', 'STRING')
slot2.widget = { name: 'text_1' }
const w2 = n.addWidget('text', 'text_1', '22222222222', () => {})
Object.assign(w2, { sourceNodeId: '2', sourceWidgetName: 'text' })
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('text', 'STRING')
slot.label = 'renamed_from_sidepanel'
slot.widget = { name: 'text' }
n.addWidget('text', 'text', '', () => {})
})
host.properties.proxyWidgets = [[String(inner.id), 'text', '2']]
host.properties.proxyWidgets = [[String(inner.id), 'text']]
flushProxyWidgetMigration({ hostNode: host })
const created = host.subgraph.inputs.at(-1)
expect(created?._widget).toBeDefined()
const linkedSlot = inner.inputs.find(
(slot) => slot.link === created?.linkIds[0]
)
expect(linkedSlot?.name).toBe('text_1')
const promotedInput = host.inputs.find((input) => input.name === 'text')
expect(promotedInput?.label).toBe('renamed_from_sidepanel')
expect(
promotedInput?.widgetId
? useWidgetValueStore().getWidget(promotedInput.widgetId)?.label
: undefined
).toBe('renamed_from_sidepanel')
})
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
@@ -361,8 +311,7 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: [123]
})
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(123)
expect(getPromotedInputValue(host, 'value')).toBe(123)
})
it('seeds value from the primitive widget when no host value is supplied', () => {
@@ -375,8 +324,7 @@ describe('flushProxyWidgetMigration', () => {
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(11)
expect(getPromotedInputValue(host, 'value')).toBe(11)
})
it('quarantines an unlinked primitive node with no fan-out', () => {
@@ -474,10 +422,8 @@ describe('flushProxyWidgetMigration', () => {
expect(hostA.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(hostB.properties.proxyWidgetErrorQuarantine).toBeUndefined()
const widgetA = hostA.inputs.at(-1)?._widget
const widgetB = hostB.inputs.at(-1)?._widget
expect(widgetA?.value).toBe(11)
expect(widgetB?.value).toBe(22)
expect(getPromotedInputValue(hostA, 'value')).toBe(11)
expect(getPromotedInputValue(hostB, 'value')).toBe(22)
})
})

View File

@@ -1,6 +1,6 @@
import { isEqual } from 'es-toolkit/compat'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
findHostInputForPromotion,
@@ -8,6 +8,7 @@ import {
isPreviewPseudoWidget
} from '@/core/graph/subgraph/promotionUtils'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type {
@@ -27,6 +28,7 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
interface LegacyProxyEntrySource extends PromotedWidgetSource {
disambiguatingSourceNodeId?: string
@@ -93,23 +95,24 @@ function resolveSourceWidget(
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): IBaseWidget | undefined {
const widgets = sourceNode.widgets
if (widgets && disambiguatingSourceNodeId !== undefined) {
const byDisambiguator = widgets.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceNodeId === disambiguatingSourceNodeId &&
w.sourceWidgetName === sourceWidgetName
)
if (byDisambiguator) return byDisambiguator
// Disambiguator missed: fall back only to non-promoted same-name widgets.
// A sibling PromotedWidgetView would re-introduce the cross-binding bug.
const byName = widgets.find(
(w) => !isPromotedWidgetView(w) && w.name === sourceWidgetName
)
if (byName) return byName
if (sourceNode.isSubgraphNode()) {
const input = sourceNode.inputs.find((input) => {
const target = resolveSubgraphInputTarget(sourceNode, input.name)
if (disambiguatingSourceNodeId) {
return (
target?.widgetName === sourceWidgetName &&
target.nodeId === disambiguatingSourceNodeId
)
}
if (input.name === sourceWidgetName) return true
return target?.widgetName === sourceWidgetName
})
// Store-backed projection for a promoted input on a nested subgraph node:
// getSlotFromWidget locates the backing slot by widgetId.
if (input?.widgetId) return promotedInputWidget(input) ?? undefined
}
const widgets = sourceNode.widgets
return (
widgets?.find((w) => w.name === sourceWidgetName) ??
getPromotableWidgets(sourceNode).find((w) => w.name === sourceWidgetName)
@@ -300,19 +303,6 @@ function classify(
normalized.sourceWidgetName
)
if (linkedInput) {
const ambiguous =
hostNode.inputs.filter((input) => {
const w = input._widget
return (
!!w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === normalized.sourceNodeId &&
w.sourceWidgetName === normalized.sourceWidgetName
)
}).length > 1
if (ambiguous) {
return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
}
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
}
@@ -373,19 +363,23 @@ function classify(
}
}
function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void {
if (entry.isHole) return
if (
isPromotedWidgetView(widget) &&
typeof widget.hydrateHostValue === 'function'
) {
widget.hydrateHostValue(entry.hostValue)
return
}
console.error(
'[proxyWidgetMigration] applyHostValue called with non-promoted widget; refusing to write to shared interior',
{ widgetName: widget.name, type: widget.type }
)
function applyHostValueToInput(
input: INodeInputSlot,
entry: PendingEntry
): boolean {
if (!input.widgetId || entry.isHole) return Boolean(input.widgetId)
return useWidgetValueStore().setValue(input.widgetId, entry.hostValue)
}
function applyHostLabelToInput(
input: INodeInputSlot,
label: string | undefined
): void {
if (label === undefined) return
input.label = label
if (!input.widgetId) return
const state = useWidgetValueStore().getWidget(input.widgetId)
if (state) state.label = label
}
function addUniqueSubgraphInput(
@@ -422,10 +416,9 @@ function repairAlreadyLinked(
return { ok: false, reason: 'ambiguousSubgraphInput' }
}
const hostInput = matches[0]
if (!hostInput._widget) {
if (!applyHostValueToInput(hostInput, entry)) {
return { ok: false, reason: 'missingSubgraphInput' }
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: hostInput.name }
}
@@ -480,11 +473,10 @@ function repairCreateSubgraphInput(
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
if (!hostInput?._widget) {
return { ok: true, subgraphInputName: newSubgraphInput.name }
if (hostInput) {
applyHostLabelToInput(hostInput, slot.label)
applyHostValueToInput(hostInput, entry)
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
@@ -649,22 +641,19 @@ function repairPrimitive(
return failPrimitive('mutation failed; rolled back', { error: e })
}
// Apply through the host's input mirror (PromotedWidgetView), NOT
// `newSubgraphInput._widget`: the interior is shared across hosts.
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
const hostInputWidget = hostInput?._widget
if (hostInputWidget) {
if (hostInput) {
const valueEntry = validated.uniqueEntries.find((e) => !e.isHole)
if (valueEntry) {
applyHostValue(hostInputWidget, valueEntry)
applyHostValueToInput(hostInput, valueEntry)
} else {
const primitiveValue = primitiveNode.widgets?.find(
(w) => w.name === validated.sourceWidgetName
)?.value as TWidgetValue | undefined
if (primitiveValue !== undefined) {
applyHostValue(hostInputWidget, {
applyHostValueToInput(hostInput, {
...validated.uniqueEntries[0],
hostValue: primitiveValue,
isHole: false

View File

@@ -0,0 +1,118 @@
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
/**
* Where a promoted subgraph input is sourced from inside the subgraph. The
* interior node id + widget name that the host input slot forwards to. Resolved
* by walking the live link, so it is authoritative derived data — never stored
* on the projected widget.
*/
export interface PromotedSource {
nodeId: string
widgetName: string
}
/**
* The interior source of a host input slot, or undefined when the slot is not a
* promoted widget input.
*/
export function promotedInputSource(
node: LGraphNode,
input: INodeInputSlot
): PromotedSource | undefined {
if (!input.widgetId) return undefined
return resolveSubgraphInputTarget(node, input.name)
}
/** The host input slot backing a projected widget, matched by widgetId. */
export function inputForWidget(
node: LGraphNode,
widget: IBaseWidget
): INodeInputSlot | undefined {
return node.getSlotFromWidget(widget)
}
/**
* The interior source of a widget when it is a promoted subgraph input.
* Replaces ad-hoc "is this promoted?" duck-typing: a widget is promoted iff its
* host node is a subgraph node and its backing input slot has an interior
* source.
*/
export function widgetPromotedSource(
node: LGraphNode,
widget: IBaseWidget
): PromotedSource | undefined {
if (!node.isSubgraphNode()) return undefined
const input = inputForWidget(node, widget)
if (!input) return undefined
return promotedInputSource(node, input)
}
/**
* Projects a promoted subgraph input into an ordinary widget descriptor. The
* descriptor is store-backed: type/value/options read live from
* {@link useWidgetValueStore} by widgetId (mirroring BaseWidget), so the row
* list does not reactively rebuild — and re-key — on every value edit.
*
* `name` is the input slot name (unique + fixed; widgetId derives from it), and
* `label` is the mutable display label. Returns null when the input is not a
* promoted widget input.
*/
export function promotedInputWidget(input: INodeInputSlot): IBaseWidget | null {
const id = input.widgetId
if (!id) return null
const store = useWidgetValueStore()
return {
get name() {
return store.getWidget(id)?.name ?? input.name
},
get label() {
return store.getWidget(id)?.label ?? input.label ?? input.name
},
set label(next) {
const state = store.getWidget(id)
if (state) state.label = next
},
get y() {
return store.getWidget(id)?.y ?? 0
},
set y(next) {
const state = store.getWidget(id)
if (state) state.y = next
},
widgetId: id,
get type() {
return store.getWidget(id)?.type ?? 'text'
},
get options() {
return store.getWidget(id)?.options ?? {}
},
get value() {
const value = store.getWidget(id)?.value
return isWidgetValue(value) ? value : undefined
},
set value(next) {
store.setValue(id, next)
},
// Canvas edits operate on a transient concrete widget (toConcreteWidget),
// so the value setter above is never invoked; BaseWidget.setValue writes its
// own local state and then calls this callback, which is the only bridge
// back to the store.
callback(next) {
store.setValue(id, next)
}
}
}
/** Every promoted subgraph input on a node, projected to ordinary widgets. */
export function promotedInputWidgets(node: LGraphNode): IBaseWidget[] {
return node.inputs.flatMap((input) => {
const widget = promotedInputWidget(input)
return widget ? [widget] : []
})
}

View File

@@ -1,31 +1,17 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetEntityId } from '@/world/entityIds'
export interface ResolvedPromotedWidget {
node: LGraphNode
widget: IBaseWidget
}
/**
* A persisted promotion's source identity: the interior node + widget a host
* subgraph input was promoted from. Used by the migration/schema layer, where
* the source is a stored tuple rather than something link-derivable.
*/
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly entityId: WidgetEntityId
readonly sourceNodeId: string
readonly sourceWidgetName: string
hydrateHostValue(value: IBaseWidget['value']): void
ensureHostWidgetState(): void
}
export function isPromotedWidgetView(
widget: IBaseWidget
): widget is PromotedWidgetView {
return 'sourceNodeId' in widget && 'sourceWidgetName' in widget
}

View File

@@ -1,100 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { isPromotedWidgetView } from './promotedWidgetTypes'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function createNumericInteriorNode(initialValue: number) {
const node = new LGraphNode('Interior')
const input = node.addInput('value', 'number')
node.addOutput('out', 'number')
const widget = node.addWidget('number', 'widget', initialValue, () => {}, {
min: 0,
max: 100,
step: 1
})
input.widget = { name: widget.name }
return { node, widget }
}
describe('PromotedWidgetView — host-wins semantics', () => {
it('does not leak host-side writes into the interior widget or into a sibling host', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interior, widget: interiorWidget } =
createNumericInteriorNode(42)
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
const hostA = createTestSubgraphNode(subgraph, { id: 100 })
const hostB = createTestSubgraphNode(subgraph, { id: 101 })
const viewA = hostA.widgets.find(isPromotedWidgetView)
const viewB = hostB.widgets.find(isPromotedWidgetView)
if (!viewA || !viewB)
throw new Error('Expected promoted views on both hosts')
viewA.value = 7
expect(viewA.value).toBe(7)
expect(interiorWidget.value).toBe(42)
expect(viewB.value).toBe(42)
})
it('keeps the interior widgetValueStore row untouched when a host writes', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interior } = createNumericInteriorNode(42)
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(subgraph.rootGraph.id, {
nodeId: String(interior.id),
name: 'widget',
type: 'number',
value: 42,
options: {},
label: undefined,
serialize: true,
disabled: false
})
const host = createTestSubgraphNode(subgraph, { id: 200 })
const view = host.widgets.find(isPromotedWidgetView)
if (!view) throw new Error('Expected promoted view on host')
view.value = 99
const interiorState = widgetStore.getWidget(
subgraph.rootGraph.id,
String(interior.id),
'widget'
)
expect(interiorState?.value).toBe(42)
})
})

Some files were not shown because too many files have changed in this diff Show More