Compare commits

...

38 Commits

Author SHA1 Message Date
Comfy Org PR Bot
f2254852b2 1.41.20 (#9930)
Patch version increment to 1.41.20

**Base branch:** `core/1.41`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9930-1-41-20-3236d73d365081f6be57fe1fcac43665)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-03-14 15:43:54 -07:00
Alexander Brown
49bec80f12 [backport core/1.41] fix: stabilize subgraph promoted widget identity and rendering (#9928)
## Summary
- Backport of #9896 to `core/1.41` via cherry-pick of merge commit
`74a48ab2aa6de160ca38496c843a19a8fcf0f77b`
- Resolved conflicts in:
  - `src/composables/graph/useGraphNodeManager.test.ts`
  - `src/lib/litegraph/src/subgraph/SubgraphNode.ts`
  - `src/renderer/extensions/vueNodes/components/NodeWidgets.vue`
- Applied `core/1.41` compatibility resolution for non-obvious conflict:
retained identity/render-key stabilization while keeping
execution-error-only widget error detection (no missing-model-store
dependency introduced)

## Testing
- `pnpm exec vitest run
src/composables/graph/useGraphNodeManager.test.ts
src/lib/litegraph/src/subgraph/SubgraphNode.test.ts
src/renderer/extensions/vueNodes/components/NodeWidgets.test.ts`
- `pnpm typecheck`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9928-backport-core-1-41-fix-stabilize-subgraph-promoted-widget-identity-and-rendering-3236d73d365081f5a0a8e3d549b2e19b)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-14 15:42:33 -07:00
Comfy Org PR Bot
2d3de22150 [backport core/1.41] fix: clear stale widget slotMetadata on link disconnect (#9919)
Backport of #9885 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9919-backport-core-1-41-fix-clear-stale-widget-slotMetadata-on-link-disconnect-3236d73d365081028353cbabd935e7a0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-14 12:17:48 -07:00
Comfy Org PR Bot
65079e51ca [backport core/1.41] fix: restore native copy/paste events for image paste support (#9916)
Backport of #9914 to `core/1.41`

Automatically created by backport workflow.

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2026-03-14 08:12:56 +00:00
Comfy Org PR Bot
c068f3b9e6 [backport core/1.41] Add support for values factory function in widget select combo (#9882)
Backport of #8775 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9882-backport-core-1-41-Add-support-for-values-factory-function-in-widget-select-combo-3226d73d3650812ca13ac9755d33901b)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-13 10:36:39 -07:00
Comfy Org PR Bot
804bfd3c38 [backport core/1.41] fix: switching tabs in app mode clearing outputs (#9872)
Backport of #9745 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9872-backport-core-1-41-fix-switching-tabs-in-app-mode-clearing-outputs-3226d73d365081378512f06bfd955296)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-13 10:36:28 -07:00
Comfy Org PR Bot
d245752882 [backport core/1.41] feat: show ComfyUI context menu on textarea widget right-click (#9870)
Backport of #9840 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9870-backport-core-1-41-feat-show-ComfyUI-context-menu-on-textarea-widget-right-click-3226d73d365081b19ea2d46add24284d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-13 09:02:34 -07:00
Comfy Org PR Bot
1daa6909a3 [backport core/1.41] fix: advanced widgets always visible regardless of setting (#9866)
Backport of #9857 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9866-backport-core-1-41-fix-advanced-widgets-always-visible-regardless-of-setting-3226d73d3650815ba2a6faa3a0ee6de3)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:42:50 -07:00
Comfy Org PR Bot
8801002a23 1.41.19 (#9869)
Patch version increment to 1.41.19

**Base branch:** `core/1.41`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9869-1-41-19-3226d73d36508188bb73dc9ed77353de)
by [Unito](https://www.unito.io)

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2026-03-13 22:57:34 +09:00
Comfy Org PR Bot
73fd0e9700 [backport core/1.41] fix: return undefined for muted node output resolution (#9847)
Backport of #9302 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9847-backport-core-1-41-fix-return-undefined-for-muted-node-output-resolution-3226d73d365081079cb2f85de62eea1a)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-12 17:55:57 -07:00
Comfy Org PR Bot
3a0d7fbaa8 1.41.18 (#9833)
Patch version increment to 1.41.18

**Base branch:** `core/1.41`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9833-1-41-18-3216d73d365081edab56d5511f5c3e64)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-03-12 14:45:31 -07:00
Comfy Org PR Bot
84bca7581c [backport core/1.41] fix: dropdown widget fetching output files (#9830)
Backport of #6734 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9830-backport-core-1-41-fix-dropdown-widget-fetching-output-files-3216d73d36508132a9a2f41aaba94356)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-12 13:54:10 -07:00
Comfy Org PR Bot
ff375e0f50 [backport core/1.41] fix: preserve input asset previews across execution updates (#9815)
Backport of #9123 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9815-backport-core-1-41-fix-preserve-input-asset-previews-across-execution-updates-3216d73d365081b79ddbf08f6c216c9e)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-12 09:20:55 -07:00
Comfy Org PR Bot
8f9625bb91 [backport core/1.41] fix: skip node metadata paste when media node is selected (#9813)
Backport of #9773 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9813-backport-core-1-41-fix-skip-node-metadata-paste-when-media-node-is-selected-3216d73d3650814db149deb1325eebd5)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-12 08:53:40 -07:00
Comfy Org PR Bot
0d60b07260 [backport core/1.41] fix: restore correct workflow on page reload (#9799)
Backport of #9318 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9799-backport-core-1-41-fix-restore-correct-workflow-on-page-reload-3216d73d3650819cb81ed740a1b67ba5)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-12 08:53:05 -07:00
Comfy Org PR Bot
e4be6af9ca [backport core/1.41] fix: call checkState after image input changes for proper undo tracking (#9809)
Backport of #9623 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9809-backport-core-1-41-fix-call-checkState-after-image-input-changes-for-proper-undo-track-3216d73d36508162a608cea3e20dd4fd)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-12 08:39:50 -07:00
Comfy Org PR Bot
7f5737bd7d [backport core/1.41] fix: app mode widgets disappear after hard refresh (#9771)
Backport of #9621 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9771-backport-core-1-41-fix-app-mode-widgets-disappear-after-hard-refresh-3216d73d36508104aafee90b94d1b9ff)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-12 08:05:00 -07:00
Comfy Org PR Bot
30ff10f8b6 1.41.17 (#9763)
Patch version increment to 1.41.17

**Base branch:** `core/1.41`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9763-1-41-17-3216d73d365081d8bfc2eaa0f8d40df5)
by [Unito](https://www.unito.io)

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2026-03-11 23:39:36 -07:00
Comfy Org PR Bot
e9445610aa 1.41.16 (#9762)
Patch version increment to 1.41.16

**Base branch:** `core/1.41`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9762-1-41-16-3216d73d36508154bf2ed49ac522eb4e)
by [Unito](https://www.unito.io)

Co-authored-by: DrJKL <448862+DrJKL@users.noreply.github.com>
2026-03-12 01:03:27 +00:00
Comfy Org PR Bot
5397017e49 [backport core/1.41] fix: restore widget.inputEl backward compatibility for custom nodes (#9760)
Backport of #9759 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9760-backport-core-1-41-fix-restore-widget-inputEl-backward-compatibility-for-custom-nodes-3216d73d3650812681aad2b14c2640be)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:55:13 +00:00
Comfy Org PR Bot
d2e1993c8f 1.41.15 (#9754)
Patch version increment to 1.41.15

**Base branch:** `core/1.41`

Co-authored-by: DrJKL <448862+DrJKL@users.noreply.github.com>
2026-03-11 20:06:45 +00:00
Luke Mino-Altherr
7a7f6380f0 [backport core/1.41] fix: make zPreviewOutput accept text-only job outputs (#9751)
Backport of #9724 to core/1.41.

Resolves Zod validation crash when a jobs batch contains text-only
preview outputs (e.g. from LLM nodes), which caused the Assets sidebar
to show nothing.

- Makes `filename`, `subfolder`, and `type` optional in `zPreviewOutput`
- Adds `.passthrough()` for extra fields like `content`
- Adds tests for mixed batch parsing and text-only job filtering

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9751-backport-core-1-41-fix-make-zPreviewOutput-accept-text-only-job-outputs-3206d73d365081b384fbd93c78942f9d)
by [Unito](https://www.unito.io)
2026-03-11 12:36:14 -07:00
Comfy Org PR Bot
782599995e 1.41.14 (#9747)
Patch version increment to 1.41.14

**Base branch:** `core/1.41`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9747-1-41-14-3206d73d36508147bc4bdf7aed8d16f8)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
2026-03-11 11:10:03 -07:00
AustinMroz
078c414cdf [backport core/1.41] feat: show App/Node Graph type indicator on template cards (#9746)
Manual backport of #9695 to core/1.41.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9746-backport-core-1-41-feat-show-App-Node-Graph-type-indicator-on-template-cards-3206d73d36508166a8f9f257357d927f)
by [Unito](https://www.unito.io)

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-11 11:00:30 -07:00
Comfy Org PR Bot
7c69c5f4c6 [backport core/1.41] Mobile input tweaks (#9710)
Backport of #9686 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9710-backport-core-1-41-Mobile-input-tweaks-31f6d73d365081048373f3616a84cd51)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 23:35:03 -07:00
Comfy Org PR Bot
a1af7e454c [backport core/1.41] Use preview downscaling in fewer places (#9682)
Backport of #9678 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9682-backport-core-1-41-Use-preview-downscaling-in-fewer-places-31e6d73d365081fb845ff568d2088070)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 16:16:34 -07:00
Comfy Org PR Bot
0596e36202 [backport core/1.41] Restore hiding of linked inputs in app mode (#9676)
Backport of #9671 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9676-backport-core-1-41-Restore-hiding-of-linked-inputs-in-app-mode-31e6d73d365081f7bab6ee781b782810)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 13:42:45 -07:00
Comfy Org PR Bot
bca61c75f2 [backport core/1.41] Always use interior nodeId for app mode (#9674)
Backport of #9669 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9674-backport-core-1-41-Always-use-interior-nodeId-for-app-mode-31e6d73d365081e1a227edf172c0b822)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 13:25:16 -07:00
Comfy Org PR Bot
441ffec3bc [backport core/1.41] Even further app fixes (#9663)
Backport of #9617 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9663-backport-core-1-41-Even-further-app-fixes-31e6d73d365081e0a64bc958cf8d1bbb)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 09:45:34 -07:00
Comfy Org PR Bot
be14ce3348 [backport core/1.41] fix: prevent showing outputs in app mode when no output nodes configured (#9632)
Backport of #9625 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9632-backport-core-1-41-fix-prevent-showing-outputs-in-app-mode-when-no-output-nodes-config-31e6d73d3650813b99d6d40fd2db8084)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-08 17:44:29 -07:00
Comfy Org PR Bot
ffd334cdaa [backport core/1.41] fix: use previewable output count for asset sidebar badge (#9559)
Backport of #9535 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9559-backport-core-1-41-fix-use-previewable-output-count-for-asset-sidebar-badge-31d6d73d36508161ac9be3c361dab360)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-07 19:02:39 -08:00
Christian Byrne
98ad2a9672 [backport core/1.41] Yet further app fixes (#9523) (#9566)
Backport of #9523 to core/1.41.

Cherry-pick of merge commit 83ffaf30 with minor conflict in
`src/locales/en/main.json` (added new i18n keys). Resolved by accepting
incoming keys.

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9523
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9566-backport-core-1-41-Yet-further-app-fixes-9523-31d6d73d3650815c9c06eee813f0e72e)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-07 18:19:54 -08:00
Christian Byrne
95e5981f2f [backport core/1.41] feat/fix: App mode further updates (#9545) (#9567)
Backport of #9545 to core/1.41.

Cherry-pick of merge commit c4156d70 applied cleanly.

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9545
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9567-backport-core-1-41-feat-fix-App-mode-further-updates-9545-31d6d73d3650812394d5cb53af3292e1)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-07 18:18:24 -08:00
Christian Byrne
e3287d4c95 [backport core/1.41] feat/fix: App mode QA fixes (#9530) (#9565)
Backport of #9530 to core/1.41.

Cherry-pick of merge commit 5bb742ac applied cleanly.

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9530
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9565-backport-core-1-41-feat-fix-App-mode-QA-fixes-9530-31d6d73d36508116b377fafed6f5aec5)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-07 18:18:20 -08:00
Christian Byrne
d65d8ec06e [backport core/1.41] fix: remove workspace switching confirmation dialog (#9250) (#9564)
Backport of #9250 to core/1.41.

Cherry-pick of merge commit 2875f897 applied cleanly (auto-merge on
locale files).

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9250
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9564-backport-core-1-41-fix-remove-workspace-switching-confirmation-dialog-9250-31d6d73d3650818b86eeeedc79b3d6c5)
by [Unito](https://www.unito.io)
2026-03-07 18:18:18 -08:00
Christian Byrne
648a964531 [backport core/1.41] fix: prevent non-widget inputs on nested subgraphs from appearing as button widgets (#9542) (#9563)
Backport of #9542 to core/1.41.

Cherry-pick of merge commit 8a5bcde1 applied cleanly.

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9542
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9563-backport-core-1-41-fix-prevent-non-widget-inputs-on-nested-subgraphs-from-appearing-as-31d6d73d36508154927fd4b7b55ea87f)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 18:18:15 -08:00
Christian Byrne
0f889a95d9 [backport core/1.41] fix: remove timeouts from error toasts so they persist until dismissed (#9543) (#9562)
Backport of #9543 to core/1.41.

Cherry-pick of merge commit 725a0a2b applied cleanly.

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9543
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9562-backport-core-1-41-fix-remove-timeouts-from-error-toasts-so-they-persist-until-dismiss-31d6d73d365081caa825ff32ca622b2c)
by [Unito](https://www.unito.io)
2026-03-07 18:18:12 -08:00
Christian Byrne
1fc437bb41 [backport core/1.41] fix: Prevent corruption of workflow data due to checkState during graph loading (#9531) (#9561)
Backport of #9531 to core/1.41.

Cherry-pick of merge commit ec129de6 applied cleanly.

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9531
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9561-backport-core-1-41-fix-Prevent-corruption-of-workflow-data-due-to-checkState-during-gr-31d6d73d365081aabc05c0409ee49615)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-07 18:18:10 -08:00
161 changed files with 5759 additions and 1036 deletions

1
.gitignore vendored
View File

@@ -26,6 +26,7 @@ dist-ssr
.claude/*.local.json
.claude/*.local.md
.claude/*.local.txt
.claude/worktrees
CLAUDE.local.md
# Editor directories and files

View File

@@ -4,7 +4,7 @@
<template v-if="filter.tasks.length === 0">
<!-- Empty filter -->
<Divider />
<p class="text-neutral-400 w-full text-center">
<p class="w-full text-center text-neutral-400">
{{ $t('maintenance.allOk') }}
</p>
</template>
@@ -25,7 +25,7 @@
<!-- Display: Cards -->
<template v-else>
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
<div class="pad-y my-4 flex flex-wrap justify-evenly gap-8">
<TaskCard
v-for="task in filter.tasks"
:key="task.id"
@@ -45,7 +45,8 @@ import { useConfirm, useToast } from 'primevue'
import ConfirmPopup from 'primevue/confirmpopup'
import Divider from 'primevue/divider'
import { t } from '@/i18n'
import { useI18n } from 'vue-i18n'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type {
MaintenanceFilter,
@@ -55,6 +56,7 @@ import type {
import TaskCard from './TaskCard.vue'
import TaskListItem from './TaskListItem.vue'
const { t } = useI18n()
const toast = useToast()
const confirm = useConfirm()
const taskStore = useMaintenanceTaskStore()
@@ -80,8 +82,7 @@ const executeTask = async (task: MaintenanceTask) => {
toast.add({
severity: 'error',
summary: t('maintenance.error.toastTitle'),
detail: message ?? t('maintenance.error.defaultDescription'),
life: 10_000
detail: message ?? t('maintenance.error.defaultDescription')
})
}

View File

@@ -189,8 +189,7 @@ const completeValidation = async () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('maintenance.error.cannotContinue'),
life: 5_000
detail: t('maintenance.error.cannotContinue')
})
}
}

View File

@@ -1,8 +1,8 @@
<template>
<BaseViewTemplate dark hide-language-selector>
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
<div class="flex h-full flex-col items-center justify-center p-8 2xl:p-16">
<div
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
class="flex w-full max-w-[600px] flex-col gap-6 rounded-lg bg-neutral-800 p-6 shadow-lg"
>
<h2 class="text-3xl font-semibold text-neutral-100">
{{ $t('install.helpImprove') }}
@@ -15,7 +15,7 @@
<a
href="https://comfy.org/privacy"
target="_blank"
class="text-blue-400 hover:text-blue-300 underline"
class="text-blue-400 underline hover:text-blue-300"
>
{{ $t('install.privacyPolicy') }} </a
>.
@@ -33,7 +33,7 @@
}}
</span>
</div>
<div class="flex pt-6 justify-end">
<div class="flex justify-end pt-6">
<Button
:label="$t('g.ok')"
icon="pi pi-check"
@@ -72,8 +72,7 @@ const updateConsent = async () => {
toast.add({
severity: 'error',
summary: t('install.settings.errorUpdatingConsent'),
detail: t('install.settings.errorUpdatingConsentDetail'),
life: 3000
detail: t('install.settings.errorUpdatingConsentDetail')
})
} finally {
isUpdating.value = false

View File

@@ -0,0 +1,47 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": null },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": { "Node name for S&R": "LoadImage" },
"widgets_values": ["example.png", "image"]
},
{
"id": 2,
"type": "KSampler",
"pos": [500, 50],
"size": [315, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": null }],
"properties": { "Node name for S&R": "KSampler" },
"widgets_values": [0, "randomize", 20, 8, "euler", "normal", 1]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": { "offset": [0, 0], "scale": 1 }
},
"version": 0.4
}

View File

@@ -0,0 +1,58 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Image paste priority over stale node metadata',
{ tag: ['@node'] },
() => {
test('Should not paste copied node when a LoadImage node is selected and clipboard has stale node metadata', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBe(2)
// Copy the KSampler node (puts data-metadata in clipboard)
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await ksamplerNodes[0].copy()
// Select the LoadImage node
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
await loadImageNodes[0].click('title')
// Simulate pasting when clipboard has stale node metadata (text/html
// with data-metadata) but no image file items. This replicates the bug
// scenario: user copied a node, then copied a web image (which replaces
// clipboard files but may leave stale text/html with node metadata).
await comfyPage.page.evaluate(() => {
const nodeData = { nodes: [{ type: 'KSampler', id: 99 }] }
const base64 = btoa(JSON.stringify(nodeData))
const html =
'<meta charset="utf-8"><div><span data-metadata="' +
base64 +
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/html', html)
const event = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(event)
})
await comfyPage.nextFrame()
// Node count should remain the same — stale node metadata should NOT
// be deserialized when a media node is selected.
const finalCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(finalCount).toBe(initialCount)
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 119 KiB

View File

@@ -0,0 +1,102 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Advanced Widget Visibility', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
// Add a ModelSamplingFlux node which has both advanced (max_shift,
// base_shift) and non-advanced (width, height) widgets.
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('ModelSamplingFlux')!
node.pos = [500, 200]
window.app!.graph.add(node)
})
await comfyPage.vueNodes.waitForNodes()
})
function getNode(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
}
function getWidgets(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return getNode(comfyPage).locator('.lg-node-widget')
}
test('should hide advanced widgets by default', async ({ comfyPage }) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
// Non-advanced widgets (width, height) should be visible
await expect(widgets).toHaveCount(2)
await expect(node.getByLabel('width', { exact: true })).toBeVisible()
await expect(node.getByLabel('height', { exact: true })).toBeVisible()
// Advanced widgets should not be rendered
await expect(
node.getByLabel('max_shift', { exact: true })
).not.toBeVisible()
await expect(
node.getByLabel('base_shift', { exact: true })
).not.toBeVisible()
// "Show advanced inputs" button should be present
await expect(node.getByText('Show advanced inputs')).toBeVisible()
})
test('should show advanced widgets when per-node toggle is clicked', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Click the toggle button to show advanced widgets
await node.getByText('Show advanced inputs').click()
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// Button text should change to "Hide advanced inputs"
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
// Click again to hide
await node.getByText('Hide advanced inputs').click()
await expect(widgets).toHaveCount(2)
})
test('should show advanced widgets when global setting is enabled', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Enable the global setting
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
true
)
// All 4 widgets should now be visible
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// The toggle button should not be shown when global setting is active
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.13",
"version": "1.41.20",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -46,71 +46,74 @@ function showApps() {
</script>
<template>
<div class="pointer-events-auto flex flex-col gap-2">
<WorkflowActionsDropdown source="app_mode_toolbar" />
<Button
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:disabled="!hasNodes"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilder"
>
<i class="icon-[lucide--hammer] size-4" />
</Button>
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.right="{
value: t('actionbar.shareTooltip'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:aria-label="t('actionbar.shareTooltip')"
class="size-10 rounded-lg"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--send] size-4" />
</Button>
<div
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
>
<div class="pointer-events-auto flex flex-row items-start gap-2">
<div class="pointer-events-auto flex flex-col gap-2">
<Button
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('sideToolbar.mediaAssets.title'),
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
}"
variant="textonly"
variant="secondary"
size="unset"
:aria-label="t('sideToolbar.mediaAssets.title')"
:class="
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
"
@click="openAssets"
:disabled="!hasNodes"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilder"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
<i class="icon-[lucide--hammer] size-4" />
</Button>
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.apps'),
value: t('actionbar.shareTooltip'),
...tooltipOptions
}"
variant="textonly"
variant="secondary"
size="unset"
:aria-label="t('linearMode.appModeToolbar.apps')"
:class="cn('size-10', isAppsActive && 'bg-secondary-background-hover')"
@click="showApps"
:aria-label="t('actionbar.shareTooltip')"
class="size-10 rounded-lg"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--panels-top-left] size-4" />
<i class="icon-[lucide--send] size-4" />
</Button>
<div
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
>
<Button
v-tooltip.right="{
value: t('sideToolbar.mediaAssets.title'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('sideToolbar.mediaAssets.title')"
:class="
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
"
@click="openAssets"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
<Button
v-tooltip.right="{
value: t('linearMode.appModeToolbar.apps'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('linearMode.appModeToolbar.apps')"
:class="
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
"
@click="showApps"
>
<i class="icon-[lucide--panels-top-left] size-4" />
</Button>
</div>
</div>
<WorkflowActionsDropdown source="app_mode_toolbar" />
</div>
</template>

View File

@@ -8,9 +8,9 @@ import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import {
LGraphEventMode,
@@ -25,10 +25,10 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { promptRenameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -53,18 +53,15 @@ workflowStore.activeWorkflow?.changeTracker?.reset()
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const node = resolveNode(nodeId)
if (!node) return null
const widget = node.widgets?.find((w) => w.name === widgetName)
return { nodeId, widgetName, node, widget }
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return node ? { nodeId, widgetName, node, widget } : null
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = resolveNode(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) {
return {
nodeId,
@@ -73,15 +70,12 @@ const inputsWithState = computed(() =>
}
}
const input = node.inputs.find((i) => i.widget?.name === widget.name)
const rename = input && (() => renameWidget(widget, input))
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
rename
rename: () => promptRenameWidget(widget, node, t)
}
})
)
@@ -92,20 +86,6 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
])
)
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
const newLabel = await useDialogService().prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
widget.label = newLabel || undefined
input.label = newLabel || undefined
widget.callback?.(widget.value)
useCanvasStore().canvas?.setDirty(true)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
@@ -126,7 +106,7 @@ function getHovered(
function getBounding(nodeId: NodeId, widgetName?: string) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const node = app.rootGraph.getNodeById(nodeId)
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node) return
const titleOffset =
@@ -139,7 +119,6 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) return
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
@@ -162,7 +141,11 @@ function handleDown(e: MouseEvent) {
}
function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? []
if (node?.mode !== LGraphEventMode.ALWAYS)
if (
node?.mode !== LGraphEventMode.ALWAYS ||
!nodeTypeValidForApp(node.type) ||
node.has_errors
)
return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
@@ -174,12 +157,16 @@ function handleClick(e: MouseEvent) {
else appModeStore.selectedOutputs.splice(index, 1)
return
}
if (!isSelectInputsMode.value) return
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
else appModeStore.selectedInputs.splice(index, 1)
}
@@ -198,7 +185,9 @@ const renderedOutputs = computed(() => {
return canvas
.graph!.nodes.filter(
(n) =>
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
n.constructor.nodeData?.output_node &&
n.mode === LGraphEventMode.ALWAYS &&
!n.has_errors
)
.map(nodeToDisplayTuple)
})
@@ -260,7 +249,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
<i class="icon-[lucide--info] bg-muted-foreground" />
</div>
</template>
<template #empty>
@@ -315,7 +304,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
<i class="icon-[lucide--info] bg-muted-foreground" />
</div>
</template>
<template #empty>

View File

@@ -72,7 +72,7 @@ const menuItems = computed(() => [
},
{
label: t('builderMenu.exitAppBuilder'),
icon: 'icon-[lucide--square-pen]',
icon: 'icon-[lucide--x]',
action: onExitBuilder
}
])

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
@@ -7,6 +7,13 @@ import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
const titleTooltip = ref<string | null>(null)
const subTitleTooltip = ref<string | null>(null)
function isTruncated(e: MouseEvent): boolean {
const el = e.currentTarget as HTMLElement
return el.scrollWidth > el.clientWidth
}
const { rename, remove } = defineProps<{
title: string
subTitle?: string
@@ -32,15 +39,28 @@ const entries = computed(() => {
})
</script>
<template>
<div class="my-2 flex items-center-safe gap-2 rounded-lg p-2">
<div
class="drag-handle mr-auto inline max-w-max min-w-0 flex-[4_1_0%] truncate"
v-text="title"
/>
<div
class="drag-handle inline max-w-max min-w-0 flex-[2_1_0%] truncate text-end text-muted-foreground"
v-text="subTitle"
/>
<div
class="my-2 flex items-center-safe gap-2 rounded-lg p-2"
data-testid="builder-io-item"
>
<div class="drag-handle mr-auto flex min-w-0 flex-col gap-1">
<div
v-tooltip.left="{ value: titleTooltip, showDelay: 300 }"
class="drag-handle truncate text-sm"
data-testid="builder-io-item-title"
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
v-text="title"
/>
<div
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"
class="drag-handle truncate text-xs text-muted-foreground"
data-testid="builder-io-item-subtitle"
@mouseenter="
subTitleTooltip = isTruncated($event) ? (subTitle ?? null) : null
"
v-text="subTitle"
/>
</div>
<Popover :entries>
<template #button>
<Button variant="muted-textonly">

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
DialogTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
defineProps<{ title?: string; to?: string | HTMLElement }>()
const { t } = useI18n()
</script>
<template>
<DialogRoot v-slot="{ close }">
<DialogTrigger as-child>
<slot name="button" />
</DialogTrigger>
<DialogPortal :to>
<DialogOverlay
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-black/70"
/>
<DialogContent
v-bind="$attrs"
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
>
<div
v-if="title"
class="flex w-full items-center justify-between border-b border-border-subtle px-4"
>
<DialogTitle class="text-sm">{{ title }}</DialogTitle>
<DialogClose as-child>
<Button
:aria-label="t('g.close')"
size="icon"
variant="muted-textonly"
>
<i class="icon-[lucide--x]" />
</Button>
</DialogClose>
</div>
<slot :close />
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -54,11 +54,12 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5" :class="item.icon" />
{{ item.label }}
<i class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<div
v-if="item.new"
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
v-else-if="item.new"
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
v-text="t('contextMenu.new')"
/>
</DropdownMenuItem>

View File

@@ -27,7 +27,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const itemClass = computed(() =>
cn(
'm-1 flex cursor-pointer gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
'm-1 flex cursor-pointer items-center-safe gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
itemProp
)
)

View File

@@ -33,19 +33,20 @@
spellcheck="false"
@blur="handleBlur"
@keyup.enter="handleBlur"
@dragstart.prevent
@keydown.up.prevent="updateValueBy(step)"
@keydown.down.prevent="updateValueBy(-step)"
@keydown.page-up.prevent="updateValueBy(10 * step)"
@keydown.page-down.prevent="updateValueBy(-10 * step)"
/>
<div
ref="swipeElement"
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize',
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
textEdit && 'pointer-events-none hidden'
)
"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="resetDrag"
/>
</div>
<slot />
@@ -65,7 +66,7 @@
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -73,8 +74,8 @@ import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
min,
max,
min = -Number.MAX_VALUE,
max = Number.MAX_VALUE,
step = 1,
disabled = false,
hideButtons = false,
@@ -96,6 +97,7 @@ const modelValue = defineModel<number>({ default: 0 })
const container = useTemplateRef<HTMLDivElement>('container')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const swipeElement = useTemplateRef('swipeElement')
const textEdit = ref(false)
onClickOutside(container, () => {
@@ -103,21 +105,11 @@ onClickOutside(container, () => {
})
function clamp(value: number): number {
const lo = min ?? -Infinity
const hi = max ?? Infinity
return Math.min(hi, Math.max(lo, value))
return Math.min(max, Math.max(min, value))
}
const canDecrement = computed(
() => modelValue.value > (min ?? -Infinity) && !disabled
)
const canIncrement = computed(
() => modelValue.value < (max ?? Infinity) && !disabled
)
const dragging = ref(false)
const dragDelta = ref(0)
const hasDragged = ref(false)
const canDecrement = computed(() => modelValue.value > min && !disabled)
const canIncrement = computed(() => modelValue.value < max && !disabled)
function handleBlur(e: Event) {
const target = e.target as HTMLInputElement
@@ -135,41 +127,27 @@ function handleBlur(e: Event) {
textEdit.value = false
}
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
if (disabled) return
const target = e.target as HTMLElement
target.setPointerCapture(e.pointerId)
dragging.value = true
dragDelta.value = 0
hasDragged.value = false
}
function handlePointerMove(e: PointerEvent) {
if (!dragging.value) return
dragDelta.value += e.movementX
const steps = (dragDelta.value / 10) | 0
if (steps === 0) return
hasDragged.value = true
const unclipped = modelValue.value + steps * step
dragDelta.value %= 10
modelValue.value = clamp(unclipped)
}
let dragDelta = 0
function handlePointerUp() {
if (!dragging.value) return
if (isSwiping.value) return
if (!hasDragged.value) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
resetDrag()
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
function resetDrag() {
dragging.value = false
dragDelta.value = 0
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
onSwipeEnd: () => (dragDelta = 0)
})
whenever(distanceX, () => {
if (disabled) return
const delta = ((distanceX.value - dragDelta) / 10) | 0
dragDelta += delta * 10
modelValue.value = clamp(modelValue.value - delta * step)
})
function updateValueBy(delta: number) {
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
}
</script>

View File

@@ -178,7 +178,7 @@
v-show="isTemplateVisibleOnDistribution(template)"
:key="template.name"
ref="cardRefs"
size="compact"
size="tall"
variant="ghost"
rounded="lg"
:data-testid="`template-workflow-${template.name}`"
@@ -318,6 +318,20 @@
</Button>
</div>
</div>
<div class="flex">
<span
class="text-neutral flex items-center gap-1.5 text-xs font-bold"
>
<template v-if="isAppTemplate(template)">
<i class="icon-[lucide--panels-top-left]" />
{{ $t('builderToolbar.app', 'App') }}
</template>
<template v-else>
<i class="icon-[lucide--workflow]" />
{{ $t('builderToolbar.nodeGraph', 'Node Graph') }}
</template>
</span>
</div>
</div>
</CardBottom>
</template>
@@ -483,6 +497,8 @@ const {
const getEffectiveSourceModule = (template: TemplateInfo) =>
template.sourceModule || 'default'
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
const getBaseThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')

View File

@@ -138,8 +138,7 @@ onMounted(async () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToFetchLogs'),
life: 5000
detail: t('toastMessages.failedToFetchLogs')
})
}
})

View File

@@ -275,8 +275,7 @@ async function handleBuy() {
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage })
})
} finally {
loading.value = false

View File

@@ -98,8 +98,7 @@ async function onConfirmCancel() {
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
detail: error instanceof Error ? error.message : t('g.unknownError')
})
} finally {
isLoading.value = false

View File

@@ -50,7 +50,9 @@
{{ t('g.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="seeErrors">
{{ t('errorOverlay.seeErrors') }}
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}
</Button>
</div>
</div>
@@ -69,6 +71,8 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
@@ -94,6 +98,7 @@ function dismiss() {
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()

View File

@@ -535,7 +535,7 @@ onMounted(async () => {
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
workflowPersistence.restoreWorkflowTabsState()
await workflowPersistence.restoreWorkflowTabsState()
const sharedWorkflowLoadStatus =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()

View File

@@ -579,8 +579,7 @@ const onUpdateComfyUI = async (): Promise<void> => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error.value || t('helpCenter.updateComfyUIFailed'),
life: 5000
detail: error.value || t('helpCenter.updateComfyUIFailed')
})
return
}
@@ -597,8 +596,7 @@ const onUpdateComfyUI = async (): Promise<void> => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: err instanceof Error ? err.message : t('g.unknownError'),
life: 5000
detail: err instanceof Error ? err.message : t('g.unknownError')
})
}
}

View File

@@ -1,5 +1,5 @@
import { computed, reactive, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { computed, reactive, ref, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
@@ -227,7 +227,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
}
export function useErrorGroups(
searchQuery: Ref<string>,
searchQuery: MaybeRefOrGetter<string>,
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
@@ -584,7 +584,7 @@ export function useErrorGroups(
})
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = searchQuery.value.trim()
const query = toValue(searchQuery).trim()
return searchErrorGroups(tabErrorGroups.value, query)
})

View File

@@ -23,6 +23,7 @@ import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { GetNodeParentGroupKey } from '../shared'
import WidgetItem from './WidgetItem.vue'
import { getStableWidgetRenderKey } from '@/core/graph/subgraph/widgetRenderKey'
const {
label,
@@ -272,7 +273,7 @@ defineExpose({
<TransitionGroup name="list-scale">
<WidgetItem
v-for="{ widget, node } in widgets"
:key="`${node.id}-${widget.name}-${widget.type}`"
:key="getStableWidgetRenderKey(widget)"
:widget="widget"
:node="node"
:is-draggable="isDraggable"

View File

@@ -15,10 +15,9 @@ 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 { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDialogService } from '@/services/dialogService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
const {
@@ -42,7 +41,6 @@ const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const dialogService = useDialogService()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
@@ -67,15 +65,8 @@ const isCurrentValueDefault = computed(() => {
})
async function handleRename() {
const newLabel = await dialogService.prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
label.value = newLabel
const newLabel = await promptWidgetLabel(widget, t)
if (newLabel !== null) label.value = newLabel
}
function handleHideInput() {

View File

@@ -615,8 +615,7 @@ const enterFolderView = async (asset: AssetItem) => {
toast.add({
severity: 'error',
summary: t('sideToolbar.folderView.errorSummary'),
detail: t('sideToolbar.folderView.errorDetail'),
life: 5000
detail: t('sideToolbar.folderView.errorDetail')
})
exitFolderView()
}
@@ -662,8 +661,7 @@ const copyJobId = async () => {
toast.add({
severity: 'error',
summary: t('mediaAsset.jobIdToast.error'),
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
life: 3000
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed')
})
}
}

View File

@@ -10,8 +10,9 @@
@mouseup="handleMouseUp"
@click="handleClick"
>
<i v-if="isBuilderState" class="bg-text-subtle icon-[lucide--hammer]" />
<i
v-if="workflowOption.workflow.initialMode === 'app'"
v-else-if="workflowOption.workflow.initialMode === 'app'"
class="icon-[lucide--panels-top-left] bg-primary-background"
/>
<span
@@ -149,6 +150,11 @@ const shouldShowStatusIndicator = computed(() => {
return false
})
const isBuilderState = computed(() => {
const currentMode = props.workflowOption.workflow.activeMode
return typeof currentMode === 'string' && currentMode.startsWith('builder:')
})
const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})

View File

@@ -24,7 +24,7 @@ function handleWheel(e: WheelEvent) {
let dragging = false
function handleDown(e: PointerEvent) {
if (e.button !== 0) return
if (e.button !== 0 && e.button !== 1) return
const zoomPaneEl = zoomPane.value
if (!zoomPaneEl) return

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const buttonVariants = cva({
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer touch-manipulation whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
variants: {
variant: {
secondary:

View File

@@ -238,6 +238,159 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
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')
graph.add(subgraphNode)
const promotedViews = subgraphNode.widgets
const secondPromotedView = promotedViews[1]
if (!secondPromotedView) throw new Error('Expected second promoted view')
;(
secondPromotedView as unknown as {
sourceNodeId: string
sourceWidgetName: string
}
).sourceNodeId = '9999'
;(
secondPromotedView as unknown as {
sourceNodeId: string
sourceWidgetName: string
}
).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')
})
it('clears stale slotMetadata when input no longer matches widget', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(widgetData.slotMetadata?.linked).toBe(true)
node.inputs[0].name = 'other'
node.inputs[0].widget = { name: 'other' }
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
expect(widgetData.slotMetadata).toBeUndefined()
})
})
describe('Subgraph output slot label reactivity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('updates output slot labels when node:slot-label:changed is triggered', async () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addOutput('original_name', 'STRING')
node.addOutput('other_name', 'STRING')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeId = String(node.id)
const nodeData = vueNodeData.get(nodeId)
if (!nodeData?.outputs) throw new Error('Expected output data to exist')
expect(nodeData.outputs[0].label).toBeUndefined()
expect(nodeData.outputs[1].label).toBeUndefined()
// Simulate what SubgraphNode does: set the label, then fire the trigger
node.outputs[0].label = 'custom_label'
graph.trigger('node:slot-label:changed', {
nodeId: node.id,
slotType: NodeSlotType.OUTPUT
})
await nextTick()
const updatedData = vueNodeData.get(nodeId)
expect(updatedData?.outputs?.[0]?.label).toBe('custom_label')
expect(updatedData?.outputs?.[1]?.label).toBeUndefined()
})
it('updates input slot labels when node:slot-label:changed is triggered', async () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('original_name', 'STRING')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeId = String(node.id)
const nodeData = vueNodeData.get(nodeId)
if (!nodeData?.inputs) throw new Error('Expected input data to exist')
expect(nodeData.inputs[0].label).toBeUndefined()
node.inputs[0].label = 'custom_label'
graph.trigger('node:slot-label:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT
})
await nextTick()
const updatedData = vueNodeData.get(nodeId)
expect(updatedData?.inputs?.[0]?.label).toBe('custom_label')
})
it('ignores node:slot-label:changed for unknown node ids', () => {
const graph = new LGraph()
useGraphNodeManager(graph)
expect(() =>
graph.trigger('node:slot-label:changed', {
nodeId: 'missing-node',
slotType: NodeSlotType.OUTPUT
})
).not.toThrow()
})
})
describe('Subgraph Promoted Pseudo Widgets', () => {
@@ -315,4 +468,54 @@ describe('Nested promoted widget mapping', () => {
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
)
})
it('keeps linked and independent same-name promotions as distinct sources', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('string_a', '*')
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
linkedInput.widget = { name: 'string_a' }
subgraph.add(linkedNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget(
'text',
'string_a',
'independent',
() => undefined,
{}
)
subgraph.add(independentNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(independentNode.id),
'string_a'
)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidgets = nodeData?.widgets?.filter(
(widget) => widget.name === 'string_a'
)
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
).toEqual(
new Set([
`${subgraph.id}:${linkedNode.id}`,
`${subgraph.id}:${independentNode.id}`
])
)
})
})

View File

@@ -7,6 +7,7 @@ import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
@@ -204,7 +205,7 @@ function safeWidgetMapper(
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.advanced,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
@@ -234,16 +235,17 @@ function safeWidgetMapper(
}
}
const promotedInputName = node.inputs?.find((input) => {
if (input.name === widget.name) return true
if (input._widget === widget) return true
return false
})?.name
const matchedInput = matchPromotedInput(node.inputs, widget)
const promotedInputName = matchedInput?.name
const displayName = promotedInputName ?? widget.name
const promotedSource = resolvePromotedSourceByInputName(displayName) ?? {
const directSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
const promotedSource =
matchedInput?._widget === widget
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
: directSource
return {
displayName,
@@ -467,8 +469,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 ?? []) {
const slotInfo = slotMetadata.get(widget.slotName ?? widget.name)
if (slotInfo) widget.slotMetadata = slotInfo
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
}
}

View File

@@ -80,8 +80,12 @@ export function showNodeOptions(
}
/**
* Hide the node options popover
* Check if the node options menu is currently open
*/
export function isNodeOptionsOpen(): boolean {
return nodeOptionsInstance?.isOpen.value ?? false
}
interface NodeOptionsInstance {
toggle: (event: Event) => void
show: (event: MouseEvent) => void

View File

@@ -397,8 +397,7 @@ export function useCoreCommands(): ComfyCommand[] {
if (app.canvas.empty) {
toastStore.add({
severity: 'error',
summary: t('toastMessages.emptyCanvas'),
life: 3000
summary: t('toastMessages.emptyCanvas')
})
return
}
@@ -557,8 +556,7 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.nothingToQueue'),
detail: t('toastMessages.pleaseSelectOutputNodes'),
life: 3000
detail: t('toastMessages.pleaseSelectOutputNodes')
})
return
}
@@ -571,8 +569,7 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.failedToQueue'),
detail: t('toastMessages.failedExecutionPathResolution'),
life: 3000
detail: t('toastMessages.failedExecutionPathResolution')
})
return
}
@@ -602,8 +599,7 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.nothingToGroup'),
detail: t('toastMessages.pleaseSelectNodesToGroup'),
life: 3000
detail: t('toastMessages.pleaseSelectNodesToGroup')
})
return
}
@@ -962,8 +958,7 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.notAvailable'),
life: 3000
detail: t('manager.notAvailable')
})
return
}
@@ -1048,8 +1043,7 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.cannotCreateSubgraph'),
detail: t('toastMessages.failedToConvertToSubgraph'),
life: 3000
detail: t('toastMessages.failedToConvertToSubgraph')
})
return
}
@@ -1258,8 +1252,7 @@ export function useCoreCommands(): ComfyCommand[] {
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModels'
}),
life: 3000
})
})
return
}
@@ -1278,8 +1271,7 @@ export function useCoreCommands(): ComfyCommand[] {
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModelsAndExecutionCache'
}),
life: 3000
})
})
return
}

View File

@@ -595,6 +595,34 @@ describe('usePaste', () => {
)
})
})
it('should skip node metadata paste when a media node is selected', async () => {
const mockNode = createMockLGraphNode({
is_selected: true,
pasteFile: vi.fn(),
pasteFiles: vi.fn()
})
mockCanvas.current_node = mockNode
vi.mocked(isImageNode).mockReturnValue(true)
usePaste()
const nodeData = { nodes: [{ type: 'KSampler' }] }
const encoded = btoa(JSON.stringify(nodeData))
const html = `<div data-metadata="${encoded}"></div>`
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/html', html)
dataTransfer.setData('text/plain', 'some text')
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(mockCanvas._deserializeItems).not.toHaveBeenCalled()
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
})
})
})
describe('cloneDataTransfer', () => {

View File

@@ -229,7 +229,10 @@ export const usePaste = () => {
return
}
}
if (pasteClipboardItems(data)) return
const isMediaNodeSelected =
isImageNodeSelected || isVideoNodeSelected || isAudioNodeSelected
if (!isMediaNodeSelected && pasteClipboardItems(data)) return
// No image found. Look for node data
data = data.getData('text/plain')

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { matchPromotedInput } from './matchPromotedInput'
type MockInput = {
name: string
_widget?: IBaseWidget
}
function createWidget(name: string): IBaseWidget {
return {
name,
type: 'text'
} as IBaseWidget
}
describe(matchPromotedInput, () => {
it('prefers exact _widget matches before same-name inputs', () => {
const targetWidget = createWidget('seed')
const aliasWidget = createWidget('seed')
const aliasInput: MockInput = {
name: 'seed',
_widget: aliasWidget
}
const exactInput: MockInput = {
name: 'seed',
_widget: targetWidget
}
const matched = matchPromotedInput(
[aliasInput, exactInput] as unknown as Array<{
name: string
_widget?: IBaseWidget
}>,
targetWidget
)
expect(matched).toBe(exactInput)
})
it('falls back to same-name matching when no exact widget match exists', () => {
const targetWidget = createWidget('seed')
const aliasInput: MockInput = {
name: 'seed'
}
const matched = matchPromotedInput(
[aliasInput] as unknown as Array<{ name: string; _widget?: IBaseWidget }>,
targetWidget
)
expect(matched).toBe(aliasInput)
})
it('does not guess when multiple same-name inputs exist without an exact match', () => {
const targetWidget = createWidget('seed')
const firstAliasInput: MockInput = {
name: 'seed'
}
const secondAliasInput: MockInput = {
name: 'seed'
}
const matched = matchPromotedInput(
[firstAliasInput, secondAliasInput] as unknown as Array<{
name: string
_widget?: IBaseWidget
}>,
targetWidget
)
expect(matched).toBeUndefined()
})
})

View File

@@ -0,0 +1,19 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
type PromotedInputLike = {
name: string
_widget?: IBaseWidget
}
export function matchPromotedInput(
inputs: PromotedInputLike[] | undefined,
widget: IBaseWidget
): PromotedInputLike | undefined {
if (!inputs) return undefined
const exactMatch = inputs.find((input) => input._widget === widget)
if (exactMatch) return exactMatch
const sameNameMatches = inputs.filter((input) => input.name === widget.name)
return sameNameMatches.length === 1 ? sameNameMatches[0] : undefined
}

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
// Barrel import must come first to avoid circular dependency
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
@@ -11,19 +11,26 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type {
CanvasPointerEvent,
LGraphCanvas,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import {
cleanupComplexPromotionFixtureNodeType,
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
setupComplexPromotionFixture
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -78,6 +85,22 @@ function firstInnerNode(innerNodes: LGraphNode[]): LGraphNode {
return innerNode
}
function promotedWidgets(node: SubgraphNode): PromotedWidgetView[] {
return node.widgets as PromotedWidgetView[]
}
function callSyncPromotions(node: SubgraphNode) {
;(
node as unknown as {
_syncPromotions: () => void
}
)._syncPromotions()
}
afterEach(() => {
cleanupComplexPromotionFixtureNodeType()
})
describe(createPromotedWidgetView, () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -263,6 +286,31 @@ describe(createPromotedWidgetView, () => {
expect(fallbackWidget.value).toBe('updated')
})
test('value setter falls back to host widget when linked states are unavailable', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
subgraphNode.graph?.add(subgraphNode)
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('string_a', '*')
linkedNode.addWidget('text', 'string_a', 'initial', () => {})
linkedInput.widget = { name: 'string_a' }
subgraph.add(linkedNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
const linkedView = promotedWidgets(subgraphNode)[0]
if (!linkedView) throw new Error('Expected a linked promoted widget')
const widgetValueStore = useWidgetValueStore()
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
linkedView.value = 'updated'
expect(linkedNode.widgets?.[0].value).toBe('updated')
})
test('label falls back to displayName then widgetName', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
@@ -495,6 +543,185 @@ describe('SubgraphNode.widgets getter', () => {
])
})
test('renders all promoted widgets when duplicate input names are connected to different nodes', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'seed', type: '*' },
{ name: 'seed', type: '*' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 94 })
subgraphNode.graph?.add(subgraphNode)
const firstNode = new LGraphNode('FirstSeedNode')
const firstInput = firstNode.addInput('seed', '*')
firstNode.addWidget('number', 'seed', 1, () => {})
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('SecondSeedNode')
const secondInput = secondNode.addInput('seed', '*')
secondNode.addWidget('number', 'seed', 2, () => {})
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const widgets = promotedWidgets(subgraphNode)
expect(widgets).toHaveLength(2)
expect(widgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
String(firstNode.id),
String(secondNode.id)
])
})
test('input-linked same-name widgets share value state while store-promoted peer stays independent', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 95 })
subgraphNode.graph?.add(subgraphNode)
const linkedNodeA = new LGraphNode('LinkedNodeA')
const linkedInputA = linkedNodeA.addInput('string_a', '*')
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
linkedInputA.widget = { name: 'string_a' }
subgraph.add(linkedNodeA)
const linkedNodeB = new LGraphNode('LinkedNodeB')
const linkedInputB = linkedNodeB.addInput('string_a', '*')
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
linkedInputB.widget = { name: 'string_a' }
subgraph.add(linkedNodeB)
const promotedNode = new LGraphNode('PromotedNode')
promotedNode.addWidget('text', 'string_a', 'independent', () => {})
subgraph.add(promotedNode)
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(promotedNode.id),
'string_a'
)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(linkedNodeA.id),
'string_a'
)
const widgets = promotedWidgets(subgraphNode)
expect(widgets).toHaveLength(2)
const linkedView = widgets.find(
(widget) => widget.sourceNodeId === String(linkedNodeA.id)
)
const promotedView = widgets.find(
(widget) => widget.sourceNodeId === String(promotedNode.id)
)
if (!linkedView || !promotedView)
throw new Error(
'Expected linked and store-promoted widgets to be present'
)
linkedView.value = 'shared-value'
const widgetStore = useWidgetValueStore()
const graphId = subgraphNode.rootGraph.id
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeA.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeB.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(promotedNode.id)),
'string_a'
)?.value
).toBe('independent')
promotedView.value = 'independent-updated'
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeA.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeB.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(promotedNode.id)),
'string_a'
)?.value
).toBe('independent-updated')
})
test('duplicate-name promoted views map slot linkage by view identity', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
subgraphNode.graph?.add(subgraphNode)
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('string_a', '*')
linkedNode.addWidget('text', 'string_a', 'linked', () => {})
linkedInput.widget = { name: 'string_a' }
subgraph.add(linkedNode)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget('text', 'string_a', 'independent', () => {})
subgraph.add(independentNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(independentNode.id),
'string_a'
)
const widgets = promotedWidgets(subgraphNode)
const linkedView = widgets.find(
(widget) => widget.sourceNodeId === String(linkedNode.id)
)
const independentView = widgets.find(
(widget) => widget.sourceNodeId === String(independentNode.id)
)
if (!linkedView || !independentView)
throw new Error('Expected linked and independent promoted views')
const linkedSlot = subgraphNode.getSlotFromWidget(linkedView)
const independentSlot = subgraphNode.getSlotFromWidget(independentView)
expect(linkedSlot).toBeDefined()
expect(independentSlot).toBeUndefined()
})
test('returns empty array when no proxyWidgets', () => {
const [subgraphNode] = setupSubgraph()
expect(subgraphNode.widgets).toEqual([])
@@ -558,6 +785,273 @@ describe('SubgraphNode.widgets getter', () => {
])
})
test('full linked coverage does not prune unresolved independent fallback promotions', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'widgetA', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 125 })
subgraphNode.graph?.add(subgraphNode)
const liveNode = new LGraphNode('LiveNode')
const liveInput = liveNode.addInput('widgetA', '*')
liveNode.addWidget('text', 'widgetA', 'a', () => {})
liveInput.widget = { name: 'widgetA' }
subgraph.add(liveNode)
subgraph.inputNode.slots[0].connect(liveInput, liveNode)
setPromotions(subgraphNode, [
[String(liveNode.id), 'widgetA'],
['9999', 'widgetA']
])
callSyncPromotions(subgraphNode)
const promotions = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
{ interiorNodeId: '9999', widgetName: 'widgetA' }
])
})
test('input-added existing-input path tolerates missing link metadata', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'widgetA', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 126 })
subgraphNode.graph?.add(subgraphNode)
const existingSlot = subgraph.inputNode.slots[0]
if (!existingSlot) throw new Error('Expected subgraph input slot')
expect(() => {
subgraph.events.dispatch('input-added', { input: existingSlot })
}).not.toThrow()
})
test('syncPromotions prunes stale connected entries but keeps independent promotions', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 96 })
subgraphNode.graph?.add(subgraphNode)
const linkedNodeA = new LGraphNode('LinkedNodeA')
const linkedInputA = linkedNodeA.addInput('string_a', '*')
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
linkedInputA.widget = { name: 'string_a' }
subgraph.add(linkedNodeA)
const linkedNodeB = new LGraphNode('LinkedNodeB')
const linkedInputB = linkedNodeB.addInput('string_a', '*')
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
linkedInputB.widget = { name: 'string_a' }
subgraph.add(linkedNodeB)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget('text', 'string_a', 'independent', () => {})
subgraph.add(independentNode)
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
setPromotions(subgraphNode, [
[String(independentNode.id), 'string_a'],
[String(linkedNodeA.id), 'string_a'],
[String(linkedNodeB.id), 'string_a']
])
callSyncPromotions(subgraphNode)
const promotions = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(linkedNodeA.id), widgetName: 'string_a' },
{ interiorNodeId: String(independentNode.id), widgetName: 'string_a' }
])
})
test('syncPromotions prunes stale deep-alias entries for nested linked promotions', () => {
const { subgraphNodeB } = createTwoLevelNestedSubgraph()
const linkedView = promotedWidgets(subgraphNodeB)[0]
if (!linkedView)
throw new Error(
'Expected nested subgraph to expose a linked promoted view'
)
const concrete = resolveConcretePromotedWidget(
subgraphNodeB,
linkedView.sourceNodeId,
linkedView.sourceWidgetName
)
if (concrete.status !== 'resolved')
throw new Error(
'Expected nested promoted view to resolve to concrete widget'
)
const linkedEntry = [
linkedView.sourceNodeId,
linkedView.sourceWidgetName
] as [string, string]
const deepAliasEntry = [
String(concrete.resolved.node.id),
concrete.resolved.widget.name
] as [string, string]
// Guardrail: this test specifically validates host/deep alias cleanup.
expect(deepAliasEntry).not.toStrictEqual(linkedEntry)
setPromotions(subgraphNodeB, [linkedEntry, deepAliasEntry])
callSyncPromotions(subgraphNodeB)
const promotions = usePromotionStore().getPromotions(
subgraphNodeB.rootGraph.id,
subgraphNodeB.id
)
expect(promotions).toStrictEqual([
{
interiorNodeId: linkedEntry[0],
widgetName: linkedEntry[1]
}
])
})
test('configure prunes stale disconnected host aliases that resolve to the active linked concrete widget', () => {
const nestedSubgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
const concreteInput = concreteNode.addInput('string_a', '*')
concreteNode.addWidget('text', 'string_a', 'value', () => {})
concreteInput.widget = { name: 'string_a' }
nestedSubgraph.add(concreteNode)
nestedSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
const hostSubgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const activeAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 118 })
const staleAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 119 })
hostSubgraph.add(activeAliasNode)
hostSubgraph.add(staleAliasNode)
activeAliasNode._internalConfigureAfterSlots()
staleAliasNode._internalConfigureAfterSlots()
hostSubgraph.inputNode.slots[0].connect(
activeAliasNode.inputs[0],
activeAliasNode
)
const hostSubgraphNode = createTestSubgraphNode(hostSubgraph, { id: 120 })
hostSubgraphNode.graph?.add(hostSubgraphNode)
setPromotions(hostSubgraphNode, [
[String(activeAliasNode.id), 'string_a'],
[String(staleAliasNode.id), 'string_a']
])
const serialized = hostSubgraphNode.serialize()
const restoredNode = createTestSubgraphNode(hostSubgraph, { id: 121 })
restoredNode.configure({
...serialized,
id: restoredNode.id,
type: hostSubgraph.id,
inputs: []
})
const restoredPromotions = usePromotionStore().getPromotions(
restoredNode.rootGraph.id,
restoredNode.id
)
expect(restoredPromotions).toStrictEqual([
{
interiorNodeId: String(activeAliasNode.id),
widgetName: 'string_a'
}
])
const restoredWidgets = promotedWidgets(restoredNode)
expect(restoredWidgets).toHaveLength(1)
expect(restoredWidgets[0].sourceNodeId).toBe(String(activeAliasNode.id))
})
test('serialize syncs duplicate-name linked inputs by subgraph slot identity', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'seed', type: '*' },
{ name: 'seed', type: '*' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 127 })
subgraphNode.graph?.add(subgraphNode)
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('seed', '*')
firstNode.addWidget('text', 'seed', 'first-initial', () => {})
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('seed', '*')
secondNode.addWidget('text', 'seed', 'second-initial', () => {})
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const widgets = promotedWidgets(subgraphNode)
const firstView = widgets[0]
const secondView = widgets[1]
if (!firstView || !secondView)
throw new Error('Expected two linked promoted views')
firstView.value = 'first-updated'
secondView.value = 'second-updated'
expect(firstNode.widgets?.[0].value).toBe('first-updated')
expect(secondNode.widgets?.[0].value).toBe('second-updated')
subgraphNode.serialize()
expect(firstNode.widgets?.[0].value).toBe('first-updated')
expect(secondNode.widgets?.[0].value).toBe('second-updated')
})
test('renaming an input updates linked promoted view display names', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 128 })
subgraphNode.graph?.add(subgraphNode)
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('seed', '*')
linkedNode.addWidget('text', 'seed', 'value', () => {})
linkedInput.widget = { name: 'seed' }
subgraph.add(linkedNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
const beforeRename = promotedWidgets(subgraphNode)[0]
if (!beforeRename) throw new Error('Expected linked promoted view')
expect(beforeRename.name).toBe('seed')
const inputToRename = subgraph.inputs[0]
if (!inputToRename) throw new Error('Expected input to rename')
subgraph.renameInput(inputToRename, 'seed_renamed')
const afterRename = promotedWidgets(subgraphNode)[0]
if (!afterRename) throw new Error('Expected linked promoted view')
expect(afterRename.name).toBe('seed_renamed')
})
test('caches view objects across getter calls (stable references)', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
@@ -701,6 +1195,236 @@ describe('SubgraphNode.widgets getter', () => {
])
})
test('configure with empty serialized inputs keeps linked filtering active', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
subgraphNode.graph?.add(subgraphNode)
const linkedNodeA = new LGraphNode('LinkedNodeA')
const linkedInputA = linkedNodeA.addInput('string_a', '*')
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
linkedInputA.widget = { name: 'string_a' }
subgraph.add(linkedNodeA)
const linkedNodeB = new LGraphNode('LinkedNodeB')
const linkedInputB = linkedNodeB.addInput('string_a', '*')
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
linkedInputB.widget = { name: 'string_a' }
subgraph.add(linkedNodeB)
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
subgraph.add(storeOnlyNode)
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
setPromotions(subgraphNode, [
[String(linkedNodeA.id), 'string_a'],
[String(linkedNodeB.id), 'string_a'],
[String(storeOnlyNode.id), 'string_a']
])
const serialized = subgraphNode.serialize()
const restoredNode = createTestSubgraphNode(subgraph, { id: 98 })
restoredNode.configure({
...serialized,
id: restoredNode.id,
type: subgraph.id,
inputs: []
})
const restoredWidgets = promotedWidgets(restoredNode)
expect(restoredWidgets).toHaveLength(2)
const linkedViewCount = restoredWidgets.filter((widget) =>
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
widget.sourceNodeId
)
).length
expect(linkedViewCount).toBe(1)
expect(
restoredWidgets.some(
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
)
).toBe(true)
})
test('configure with serialized inputs rebinds subgraph slots for linked filtering', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 107 })
subgraphNode.graph?.add(subgraphNode)
const linkedNodeA = new LGraphNode('LinkedNodeA')
const linkedInputA = linkedNodeA.addInput('string_a', '*')
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
linkedInputA.widget = { name: 'string_a' }
subgraph.add(linkedNodeA)
const linkedNodeB = new LGraphNode('LinkedNodeB')
const linkedInputB = linkedNodeB.addInput('string_a', '*')
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
linkedInputB.widget = { name: 'string_a' }
subgraph.add(linkedNodeB)
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
subgraph.add(storeOnlyNode)
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
setPromotions(subgraphNode, [
[String(linkedNodeA.id), 'string_a'],
[String(linkedNodeB.id), 'string_a'],
[String(storeOnlyNode.id), 'string_a']
])
const serialized = subgraphNode.serialize()
const restoredNode = createTestSubgraphNode(subgraph, { id: 108 })
restoredNode.configure({
...serialized,
id: restoredNode.id,
type: subgraph.id,
inputs: [
{
name: 'string_a',
type: '*',
link: null
}
]
})
const restoredWidgets = promotedWidgets(restoredNode)
expect(restoredWidgets).toHaveLength(2)
const linkedViewCount = restoredWidgets.filter((widget) =>
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
widget.sourceNodeId
)
).length
expect(linkedViewCount).toBe(1)
expect(
restoredWidgets.some(
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
)
).toBe(true)
})
test('fixture keeps earliest linked representative and independent promotion only', () => {
const { graph, hostNode } = setupComplexPromotionFixture()
const hostWidgets = promotedWidgets(hostNode)
expect(hostWidgets).toHaveLength(2)
expect(hostWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
'20',
'19'
])
const promotions = usePromotionStore().getPromotions(graph.id, hostNode.id)
expect(promotions).toStrictEqual([
{ interiorNodeId: '20', widgetName: 'string_a' },
{ interiorNodeId: '19', widgetName: 'string_a' }
])
const linkedView = hostWidgets[0]
const independentView = hostWidgets[1]
if (!linkedView || !independentView)
throw new Error('Expected linked and independent promoted widgets')
independentView.value = 'independent-value'
linkedView.value = 'shared-linked'
const widgetStore = useWidgetValueStore()
const getValue = (nodeId: string) =>
widgetStore.getWidget(graph.id, stripGraphPrefix(nodeId), 'string_a')
?.value
expect(getValue('20')).toBe('shared-linked')
expect(getValue('18')).toBe('shared-linked')
expect(getValue('19')).toBe('independent-value')
})
test('fixture refreshes duplicate fallback after linked representative recovers', () => {
const { subgraph, hostNode } = setupComplexPromotionFixture()
const earliestLinkedNode = subgraph.getNodeById(20)
if (!earliestLinkedNode?.widgets)
throw new Error('Expected fixture to contain node 20 with widgets')
const originalWidgets = earliestLinkedNode.widgets
earliestLinkedNode.widgets = originalWidgets.filter(
(widget) => widget.name !== 'string_a'
)
const unresolvedWidgets = promotedWidgets(hostNode)
expect(
unresolvedWidgets.map((widget) => widget.sourceNodeId)
).toStrictEqual(['18', '20', '19'])
earliestLinkedNode.widgets = originalWidgets
const restoredWidgets = promotedWidgets(hostNode)
expect(restoredWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
'20',
'19'
])
})
test('fixture converges external widgets and keeps rendered value isolation after transient linked fallback churn', () => {
const { subgraph, hostNode } = setupComplexPromotionFixture()
const initialWidgets = promotedWidgets(hostNode)
expect(initialWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
'20',
'19'
])
const earliestLinkedNode = subgraph.getNodeById(20)
if (!earliestLinkedNode?.widgets)
throw new Error('Expected fixture to contain node 20 with widgets')
const originalWidgets = earliestLinkedNode.widgets
earliestLinkedNode.widgets = originalWidgets.filter(
(widget) => widget.name !== 'string_a'
)
const transientWidgets = promotedWidgets(hostNode)
expect(transientWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual(
['18', '20', '19']
)
earliestLinkedNode.widgets = originalWidgets
const finalWidgets = promotedWidgets(hostNode)
expect(finalWidgets).toHaveLength(2)
expect(finalWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
'20',
'19'
])
const finalLinkedView = finalWidgets.find(
(widget) => widget.sourceNodeId === '20'
)
const finalIndependentView = finalWidgets.find(
(widget) => widget.sourceNodeId === '19'
)
if (!finalLinkedView || !finalIndependentView)
throw new Error('Expected final rendered linked and independent views')
finalIndependentView.value = 'independent-final'
expect(finalIndependentView.value).toBe('independent-final')
expect(finalLinkedView.value).not.toBe('independent-final')
finalLinkedView.value = 'linked-final'
expect(finalLinkedView.value).toBe('linked-final')
expect(finalIndependentView.value).toBe('independent-final')
})
test('clone output preserves proxyWidgets for promotion hydration', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
@@ -751,6 +1475,103 @@ describe('widgets getter caching', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('reconciles at most once per canvas frame across repeated widgets reads', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
const fakeCanvas = { frame: 12 } as Pick<LGraphCanvas, 'frame'>
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
const reconcileSpy = vi.spyOn(
subgraphNode as unknown as {
_buildPromotionReconcileState: (
entries: Array<{ interiorNodeId: string; widgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
}>
) => unknown
},
'_buildPromotionReconcileState'
)
void subgraphNode.widgets
void subgraphNode.widgets
void subgraphNode.widgets
expect(reconcileSpy).toHaveBeenCalledTimes(1)
})
test('does not re-run reconciliation when only canvas frame advances', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
const fakeCanvas = { frame: 24 } as Pick<LGraphCanvas, 'frame'>
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
const reconcileSpy = vi.spyOn(
subgraphNode as unknown as {
_buildPromotionReconcileState: (
entries: Array<{ interiorNodeId: string; widgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
}>
) => unknown
},
'_buildPromotionReconcileState'
)
void subgraphNode.widgets
fakeCanvas.frame += 1
void subgraphNode.widgets
expect(reconcileSpy).toHaveBeenCalledTimes(1)
})
test('does not re-resolve linked entries when linked input state is unchanged', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
subgraphNode.graph?.add(subgraphNode)
const linkedNodeA = new LGraphNode('LinkedNodeA')
const linkedInputA = linkedNodeA.addInput('string_a', '*')
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
linkedInputA.widget = { name: 'string_a' }
subgraph.add(linkedNodeA)
const linkedNodeB = new LGraphNode('LinkedNodeB')
const linkedInputB = linkedNodeB.addInput('string_a', '*')
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
linkedInputB.widget = { name: 'string_a' }
subgraph.add(linkedNodeB)
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
const resolveSpy = vi.spyOn(
subgraphNode as unknown as {
_resolveLinkedPromotionBySubgraphInput: (...args: unknown[]) => unknown
},
'_resolveLinkedPromotionBySubgraphInput'
)
void subgraphNode.widgets
const initialResolveCount = resolveSpy.mock.calls.length
expect(initialResolveCount).toBeLessThanOrEqual(1)
void subgraphNode.widgets
expect(resolveSpy).toHaveBeenCalledTimes(initialResolveCount)
})
test('preserves view identities when promotion order changes', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})

View File

@@ -1,4 +1,4 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
@@ -13,11 +13,15 @@ import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
export type { PromotedWidgetView } from './promotedWidgetTypes'
@@ -131,6 +135,38 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
set value(value: IBaseWidget['value']) {
const linkedWidgets = this.getLinkedInputWidgets()
if (linkedWidgets.length > 0) {
const widgetStore = useWidgetValueStore()
let didUpdateState = false
for (const linkedWidget of linkedWidgets) {
const state = widgetStore.getWidget(
this.graphId,
linkedWidget.nodeId,
linkedWidget.widgetName
)
if (state) {
state.value = value
didUpdateState = true
}
}
const resolved = this.resolveDeepest()
if (resolved) {
const resolvedState = widgetStore.getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
if (resolvedState) {
resolvedState.value = value
didUpdateState = true
}
}
if (didUpdateState) return
}
const state = this.getWidgetState()
if (state) {
state.value = value
@@ -278,6 +314,9 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
private getWidgetState() {
const linkedState = this.getLinkedInputWidgetStates()[0]
if (linkedState) return linkedState
const resolved = this.resolveDeepest()
if (!resolved) return undefined
return useWidgetValueStore().getWidget(
@@ -287,6 +326,57 @@ class PromotedWidgetView implements IPromotedWidgetView {
)
}
private getLinkedInputWidgets(): Array<{
nodeId: NodeId
widgetName: string
widget: IBaseWidget
}> {
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
if (!input._subgraphSlot) return false
if (matchPromotedInput([input], this) !== input) return false
const boundWidget = input._widget
if (boundWidget === this) return true
if (boundWidget && isPromotedWidgetView(boundWidget)) {
return (
boundWidget.sourceNodeId === this.sourceNodeId &&
boundWidget.sourceWidgetName === this.sourceWidgetName
)
}
return input._subgraphSlot
.getConnectedWidgets()
.filter(hasWidgetNode)
.some(
(widget) =>
String(widget.node.id) === this.sourceNodeId &&
widget.name === this.sourceWidgetName
)
})
const linkedInput = linkedInputSlot?._subgraphSlot
if (!linkedInput) return []
return linkedInput
.getConnectedWidgets()
.filter(hasWidgetNode)
.map((widget) => ({
nodeId: stripGraphPrefix(String(widget.node.id)),
widgetName: widget.name,
widget
}))
}
private getLinkedInputWidgetStates(): WidgetState[] {
const widgetStore = useWidgetValueStore()
return this.getLinkedInputWidgets()
.map(({ nodeId, widgetName }) =>
widgetStore.getWidget(this.graphId, nodeId, widgetName)
)
.filter((state): state is WidgetState => state !== undefined)
}
private getProjectedWidget(resolved: {
node: LGraphNode
widget: IBaseWidget

View File

@@ -81,8 +81,7 @@ function getParentNodes(): SubgraphNode[] {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('subgraphStore.promoteOutsideSubgraph'),
life: 2000
detail: t('subgraphStore.promoteOutsideSubgraph')
})
return []
}

View File

@@ -0,0 +1,161 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() })
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function createOuterSubgraphSetup(inputNames: string[]): {
outerSubgraph: Subgraph
outerSubgraphNode: SubgraphNode
} {
const outerSubgraph = createTestSubgraph({
inputs: inputNames.map((name) => ({ name, type: '*' }))
})
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 1 })
return { outerSubgraph, outerSubgraphNode }
}
function addLinkedNestedSubgraphNode(
outerSubgraph: Subgraph,
inputName: string,
linkedInputName: string,
options: { widget?: string } = {}
): { innerSubgraphNode: SubgraphNode } {
const innerSubgraph = createTestSubgraph({
inputs: [{ name: linkedInputName, type: '*' }]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, { id: 819 })
outerSubgraph.add(innerSubgraphNode)
const inputSlot = outerSubgraph.inputNode.slots.find(
(slot) => slot.name === inputName
)
if (!inputSlot) throw new Error(`Missing subgraph input slot: ${inputName}`)
const input = innerSubgraphNode.addInput(linkedInputName, '*')
if (options.widget) {
innerSubgraphNode.addWidget('number', options.widget, 0, () => undefined)
input.widget = { name: options.widget }
}
inputSlot.connect(input, innerSubgraphNode)
if (input.link == null) {
throw new Error(`Expected link to be created for input ${linkedInputName}`)
}
return { innerSubgraphNode }
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
describe('resolveSubgraphInputTarget', () => {
test('returns target for widget-backed input on nested SubgraphNode', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'width'
])
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
widget: 'width'
})
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'width')
expect(result).toMatchObject({
nodeId: '819',
widgetName: 'width'
})
})
test('returns undefined for non-widget input on nested SubgraphNode', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'audio'
])
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
expect(result).toBeUndefined()
})
test('resolves widget inputs but not non-widget inputs on the same nested SubgraphNode', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'width',
'audio'
])
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
widget: 'width'
})
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
).toMatchObject({
nodeId: '819',
widgetName: 'width'
})
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
).toBeUndefined()
})
test('returns target for widget-backed input on plain interior node', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'seed'
])
const inputSlot = outerSubgraph.inputNode.slots.find(
(slot) => slot.name === 'seed'
)!
const node = new LGraphNode('Interior-seed')
node.id = 42
const input = node.addInput('seed_input', '*')
node.addWidget('number', 'seed', 0, () => undefined)
input.widget = { name: 'seed' }
outerSubgraph.add(node)
inputSlot.connect(input, node)
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'seed')
expect(result).toMatchObject({
nodeId: '42',
widgetName: 'seed'
})
})
test('returns undefined for non-widget input on plain interior node', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'image'
])
const inputSlot = outerSubgraph.inputNode.slots.find(
(slot) => slot.name === 'image'
)!
const node = new LGraphNode('Interior-image')
const input = node.addInput('image_input', '*')
outerSubgraph.add(node)
inputSlot.connect(input, node)
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'image')
expect(result).toBeUndefined()
})
})

View File

@@ -16,6 +16,9 @@ export function resolveSubgraphInputTarget(
inputName,
({ inputNode, targetInput, getTargetWidget }) => {
if (inputNode.isSubgraphNode()) {
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
return {
nodeId: String(inputNode.id),
widgetName: targetInput.name

View File

@@ -0,0 +1,8 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export function hasWidgetNode(
widget: IBaseWidget
): widget is IBaseWidget & { node: LGraphNode } {
return 'node' in widget && !!widget.node
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { getStableWidgetRenderKey } from './widgetRenderKey'
function createWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
return {
name: 'seed',
type: 'number',
...overrides
} as IBaseWidget
}
describe(getStableWidgetRenderKey, () => {
it('returns a stable key for the same widget instance', () => {
const widget = createWidget()
const first = getStableWidgetRenderKey(widget)
const second = getStableWidgetRenderKey(widget)
expect(second).toBe(first)
})
it('returns distinct keys for distinct widget instances', () => {
const firstWidget = createWidget()
const secondWidget = createWidget()
const firstKey = getStableWidgetRenderKey(firstWidget)
const secondKey = getStableWidgetRenderKey(secondWidget)
expect(secondKey).not.toBe(firstKey)
})
})

View File

@@ -0,0 +1,17 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
let nextWidgetRenderKeyId = 0
export function getStableWidgetRenderKey(widget: IBaseWidget): string {
const cachedKey = widgetRenderKeys.get(widget)
if (cachedKey) return cachedKey
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
const key = `${prefix}:${nextWidgetRenderKeyId++}`
widgetRenderKeys.set(widget, key)
return key
}

View File

@@ -204,8 +204,7 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('desktopUpdate.errorInstallingUpdate'),
life: 10_000
detail: t('desktopUpdate.errorInstallingUpdate')
})
}
}
@@ -214,8 +213,7 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('desktopUpdate.errorCheckingUpdate'),
life: 10_000
detail: t('desktopUpdate.errorCheckingUpdate')
})
}
}

View File

@@ -1,6 +1,5 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
@@ -29,7 +28,6 @@ useExtensionService().registerExtension({
const toUrl = (record: Record<string, string>) => {
const params = new URLSearchParams(record)
appendCloudResParam(params, record.filename)
return api.apiURL(`/view?${params}${rand}`)
}

View File

@@ -12,6 +12,9 @@ export function parseSlotTypes(type: ISlotType): string[] {
* @param name The name to make unique
* @param existingNames The names that already exist. Default: an empty array
* @returns The name, or a unique name if it already exists.
* @remark Used by SubgraphInputNode to deduplicate input names when promoting
* the same widget name from multiple node instances (e.g. `seed` → `seed_1`).
* Extensions matching by slot name should account for the `_N` suffix.
*/
export function nextUniqueName(
name: string,

View File

@@ -1,9 +1,12 @@
// TODO: Fix these tests after migration
import { describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphNode,
LGraphEventMode,
ExecutableNodeDTO
} from '@/lib/litegraph/src/litegraph'
@@ -249,6 +252,136 @@ describe.skip('ExecutableNodeDTO Output Resolution', () => {
})
})
describe('Muted node output resolution', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('should return undefined for NEVER mode nodes', () => {
const graph = new LGraph()
const node = new LGraphNode('Muted Node')
node.addOutput('out', 'string')
node.mode = LGraphEventMode.NEVER
graph.add(node)
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
const resolved = dto.resolveOutput(0, 'string', new Set())
expect(resolved).toBeUndefined()
})
it('should return undefined for muted subgraph nodes without throwing', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'out', type: 'IMAGE' }],
nodeCount: 1
})
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.mode = LGraphEventMode.NEVER
// Empty map simulates executionUtil skipping getInnerNodes() for muted nodes
const nodesByExecutionId = new Map()
const dto = new ExecutableNodeDTO(
subgraphNode,
[],
nodesByExecutionId,
undefined
)
nodesByExecutionId.set(dto.id, dto)
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeUndefined()
})
it('should resolve undefined when input is connected to a muted node', () => {
const graph = new LGraph()
const mutedNode = new LGraphNode('Muted Node')
mutedNode.addOutput('result', 'IMAGE')
mutedNode.mode = LGraphEventMode.NEVER
graph.add(mutedNode)
const downstreamNode = new LGraphNode('Downstream')
downstreamNode.addInput('input', 'IMAGE')
graph.add(downstreamNode)
mutedNode.connect(0, downstreamNode, 0)
const nodeDtoMap = new Map()
const mutedDto = new ExecutableNodeDTO(mutedNode, [], nodeDtoMap, undefined)
nodeDtoMap.set(mutedDto.id, mutedDto)
const downstreamDto = new ExecutableNodeDTO(
downstreamNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(downstreamDto.id, downstreamDto)
const resolved = downstreamDto.resolveInput(0)
expect(resolved).toBeUndefined()
})
})
describe('Bypass node output resolution', () => {
it('should still resolve bypass for BYPASS mode nodes', () => {
const graph = new LGraph()
const upstreamNode = new LGraphNode('Upstream')
upstreamNode.addOutput('out', 'IMAGE')
graph.add(upstreamNode)
const bypassedNode = new LGraphNode('Bypassed')
bypassedNode.addInput('in', 'IMAGE')
bypassedNode.addOutput('out', 'IMAGE')
bypassedNode.mode = LGraphEventMode.BYPASS
graph.add(bypassedNode)
upstreamNode.connect(0, bypassedNode, 0)
const nodeDtoMap = new Map()
const upstreamDto = new ExecutableNodeDTO(
upstreamNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(upstreamDto.id, upstreamDto)
const bypassedDto = new ExecutableNodeDTO(
bypassedNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(bypassedDto.id, bypassedDto)
const resolved = bypassedDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeDefined()
expect(resolved?.node).toBe(upstreamDto)
})
})
describe('ALWAYS mode node output resolution', () => {
it('should attempt normal resolution for ALWAYS mode nodes', () => {
const graph = new LGraph()
const node = new LGraphNode('Normal Node')
node.addOutput('out', 'IMAGE')
node.mode = LGraphEventMode.ALWAYS
graph.add(node)
const nodeDtoMap = new Map()
const dto = new ExecutableNodeDTO(node, [], nodeDtoMap, undefined)
nodeDtoMap.set(dto.id, dto)
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeDefined()
expect(resolved?.node).toBe(dto)
expect(resolved?.origin_slot).toBe(0)
})
})
describe.skip('ExecutableNodeDTO Properties', () => {
it('should provide access to basic properties', () => {
const graph = new LGraph()

View File

@@ -266,6 +266,9 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
}
visited.add(uniqueId)
// Muted nodes produce no output
if (this.mode === LGraphEventMode.NEVER) return
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
if (this.mode === LGraphEventMode.BYPASS) {
// Bypass nodes by finding first input with matching type

View File

@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/canvas/ToInputFromIoNodeLink'
import { LinkDirection } from '@/lib/litegraph/src//types/globalEnums'
import { usePromotionStore } from '@/stores/promotionStore'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -456,4 +457,50 @@ describe('SubgraphIO - Empty Slot Connection', () => {
expect(link.origin_slot).toBe(1) // Should be the second slot
}
)
subgraphTest(
'creates distinct named inputs when promoting same widget name from multiple node instances',
({ subgraphWithNode }) => {
const { subgraph, subgraphNode } = subgraphWithNode
const firstNode = new LGraphNode('First Seed Node')
const firstInput = firstNode.addInput('seed', 'number')
firstNode.addWidget('number', 'seed', 1, () => undefined)
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('Second Seed Node')
const secondInput = secondNode.addInput('seed', 'number')
secondNode.addWidget('number', 'seed', 2, () => undefined)
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.connectByType(-1, firstNode, 'number')
subgraph.inputNode.connectByType(-1, secondNode, 'number')
expect(subgraph.inputs.map((input) => input.name)).toStrictEqual([
'input',
'seed',
'seed_1'
])
expect(subgraphNode.inputs.map((input) => input.name)).toStrictEqual([
'input',
'seed',
'seed_1'
])
expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([
'seed',
'seed_1'
])
expect(
usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toStrictEqual([
{ interiorNodeId: String(firstNode.id), widgetName: 'seed' },
{ interiorNodeId: String(secondNode.id), widgetName: 'seed' }
])
}
)
})

View File

@@ -15,6 +15,7 @@ import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { findFreeSlotOfType } from '@/lib/litegraph/src/utils/collections'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import { EmptySubgraphInput } from './EmptySubgraphInput'
import { SubgraphIONodeBase } from './SubgraphIONodeBase'
@@ -130,8 +131,10 @@ export class SubgraphInputNode
if (slot === -1) {
// This indicates a connection is being made from the "Empty" slot.
// We need to create a new, concrete input on the subgraph that matches the target.
const existingNames = this.subgraph.inputs.map((input) => input.name)
const uniqueName = nextUniqueName(inputSlot.slot.name, existingNames)
const newSubgraphInput = this.subgraph.addInput(
inputSlot.slot.name,
uniqueName,
String(inputSlot.slot.type ?? '')
)
const newSlotIndex = this.slots.indexOf(newSubgraphInput)

View File

@@ -6,6 +6,8 @@
* IO synchronization, and edge cases.
*/
import { describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
@@ -615,3 +617,35 @@ describe.skip('SubgraphNode Cleanup', () => {
expect(abortSpy2).toHaveBeenCalledTimes(1)
})
})
describe('SubgraphNode promotion view keys', () => {
it('distinguishes tuples that differ only by colon placement', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const nodeWithKeyBuilder = subgraphNode as unknown as {
_makePromotionViewKey: (
inputKey: string,
interiorNodeId: string,
widgetName: string,
inputName?: string
) => string
}
const firstKey = nodeWithKeyBuilder._makePromotionViewKey(
'65',
'18',
'a:b',
'c'
)
const secondKey = nodeWithKeyBuilder._makePromotionViewKey(
'65',
'18',
'a',
'b:c'
)
expect(firstKey).not.toBe(secondKey)
})
})

View File

@@ -27,6 +27,7 @@ import type {
ExportedSubgraphInstance,
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import {
@@ -34,7 +35,9 @@ import {
isPromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -51,6 +54,12 @@ workflowSvg.src =
type LinkedPromotionEntry = {
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
}
type PromotionEntry = {
interiorNodeId: string
widgetName: string
}
@@ -90,46 +99,113 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
* `onAdded()`, so construction-time promotions require normal add-to-graph
* lifecycle to persist.
*/
private _pendingPromotions: Array<{
interiorNodeId: string
widgetName: string
}> = []
private _pendingPromotions: PromotionEntry[] = []
private _cacheVersion = 0
private _linkedEntriesCache?: {
version: number
hasMissingBoundSourceWidget: boolean
entries: LinkedPromotionEntry[]
}
private _promotedViewsCache?: {
version: number
entriesRef: PromotionEntry[]
hasMissingBoundSourceWidget: boolean
views: PromotedWidgetView[]
}
// Declared as accessor via Object.defineProperty in constructor.
// TypeScript doesn't allow overriding a property with get/set syntax,
// so we use declare + defineProperty instead.
declare widgets: IBaseWidget[]
private _resolveLinkedPromotionByInputName(
inputName: string
private _resolveLinkedPromotionBySubgraphInput(
subgraphInput: SubgraphInput
): { interiorNodeId: string; widgetName: string } | undefined {
const resolvedTarget = resolveSubgraphInputTarget(this, inputName)
if (!resolvedTarget) return undefined
// Preserve deterministic representative selection for multi-linked inputs:
// the first connected source remains the promoted linked view.
for (const linkId of subgraphInput.linkIds) {
const link = this.subgraph.getLink(linkId)
if (!link) continue
return {
interiorNodeId: resolvedTarget.nodeId,
widgetName: resolvedTarget.widgetName
const { inputNode } = link.resolve(this.subgraph)
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find(
(entry) => entry.link === linkId
)
if (!targetInput) continue
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (!targetWidget) continue
if (inputNode.isSubgraphNode())
return {
interiorNodeId: String(inputNode.id),
widgetName: targetInput.name
}
return {
interiorNodeId: String(inputNode.id),
widgetName: targetWidget.name
}
}
}
private _getLinkedPromotionEntries(): LinkedPromotionEntry[] {
private _getLinkedPromotionEntries(cache = true): LinkedPromotionEntry[] {
const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget()
const cached = this._linkedEntriesCache
if (
cache &&
cached?.version === this._cacheVersion &&
cached.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget
)
return cached.entries
const linkedEntries: LinkedPromotionEntry[] = []
// TODO(pr9282): Optimization target. This path runs on widgets getter reads
// and resolves each input link chain eagerly.
for (const input of this.inputs) {
const resolved = this._resolveLinkedPromotionByInputName(input.name)
const subgraphInput = input._subgraphSlot
if (!subgraphInput) continue
const boundWidget =
input._widget && isPromotedWidgetView(input._widget)
? input._widget
: undefined
if (boundWidget) {
const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId)
const hasBoundSourceWidget =
boundNode?.widgets?.some(
(widget) => widget.name === boundWidget.sourceWidgetName
) === true
if (hasBoundSourceWidget) {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
interiorNodeId: boundWidget.sourceNodeId,
widgetName: boundWidget.sourceWidgetName
})
continue
}
}
const resolved =
this._resolveLinkedPromotionBySubgraphInput(subgraphInput)
if (!resolved) continue
linkedEntries.push({ inputName: input.name, ...resolved })
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
...resolved
})
}
const seenEntryKeys = new Set<string>()
const deduplicatedEntries = linkedEntries.filter((entry) => {
const entryKey = this._makePromotionViewKey(
entry.inputName,
entry.inputKey,
entry.interiorNodeId,
entry.widgetName
entry.widgetName,
entry.inputName
)
if (seenEntryKeys.has(entryKey)) return false
@@ -137,24 +213,73 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return true
})
if (cache)
this._linkedEntriesCache = {
version: this._cacheVersion,
hasMissingBoundSourceWidget,
entries: deduplicatedEntries
}
return deduplicatedEntries
}
private _hasMissingBoundSourceWidget(): boolean {
return this.inputs.some((input) => {
const boundWidget =
input._widget && isPromotedWidgetView(input._widget)
? input._widget
: undefined
if (!boundWidget) return false
const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId)
return (
boundNode?.widgets?.some(
(widget) => widget.name === boundWidget.sourceWidgetName
) !== true
)
})
}
private _getPromotedViews(): PromotedWidgetView[] {
const store = usePromotionStore()
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget()
const cachedViews = this._promotedViewsCache
if (
cachedViews?.version === this._cacheVersion &&
cachedViews.entriesRef === entries &&
cachedViews.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget
)
return cachedViews.views
const linkedEntries = this._getLinkedPromotionEntries()
const { displayNameByViewKey, reconcileEntries } =
this._buildPromotionReconcileState(entries, linkedEntries)
return this._promotedViewManager.reconcile(reconcileEntries, (entry) =>
createPromotedWidgetView(
this,
entry.interiorNodeId,
entry.widgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined
)
const views = this._promotedViewManager.reconcile(
reconcileEntries,
(entry) =>
createPromotedWidgetView(
this,
entry.interiorNodeId,
entry.widgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined
)
)
this._promotedViewsCache = {
version: this._cacheVersion,
entriesRef: entries,
hasMissingBoundSourceWidget,
views
}
return views
}
private _invalidatePromotedViewsCache(): void {
this._cacheVersion++
}
private _syncPromotions(): void {
@@ -162,10 +287,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const store = usePromotionStore()
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
const linkedEntries = this._getLinkedPromotionEntries()
const { mergedEntries, shouldPersistLinkedOnly } =
this._buildPromotionPersistenceState(entries, linkedEntries)
if (!shouldPersistLinkedOnly) return
const linkedEntries = this._getLinkedPromotionEntries(false)
// Intentionally preserve independent store promotions when linked coverage is partial;
// tests assert that mixed linked/independent states must not collapse to linked-only.
const { mergedEntries } = this._buildPromotionPersistenceState(
entries,
linkedEntries
)
const hasChanged =
mergedEntries.length !== entries.length ||
@@ -180,7 +308,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _buildPromotionReconcileState(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
entries: PromotionEntry[],
linkedEntries: LinkedPromotionEntry[]
): {
displayNameByViewKey: Map<string, string>
@@ -196,48 +324,64 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
const linkedReconcileEntries =
this._buildLinkedReconcileEntries(linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
linkedEntries,
fallbackStoredEntries
)
const reconcileEntries = shouldPersistLinkedOnly
? linkedReconcileEntries
: [...linkedReconcileEntries, ...fallbackStoredEntries]
return {
displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries),
reconcileEntries: shouldPersistLinkedOnly
? linkedReconcileEntries
: [...linkedReconcileEntries, ...fallbackStoredEntries]
reconcileEntries
}
}
private _buildPromotionPersistenceState(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
entries: PromotionEntry[],
linkedEntries: LinkedPromotionEntry[]
): {
mergedEntries: Array<{ interiorNodeId: string; widgetName: string }>
shouldPersistLinkedOnly: boolean
mergedEntries: PromotionEntry[]
} {
const { linkedPromotionEntries, fallbackStoredEntries } =
this._collectLinkedAndFallbackEntries(entries, linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
linkedEntries,
fallbackStoredEntries
)
return {
mergedEntries: shouldPersistLinkedOnly
? linkedPromotionEntries
: [...linkedPromotionEntries, ...fallbackStoredEntries],
shouldPersistLinkedOnly
: [...linkedPromotionEntries, ...fallbackStoredEntries]
}
}
private _collectLinkedAndFallbackEntries(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
entries: PromotionEntry[],
linkedEntries: LinkedPromotionEntry[]
): {
linkedPromotionEntries: Array<{
interiorNodeId: string
widgetName: string
}>
fallbackStoredEntries: Array<{ interiorNodeId: string; widgetName: string }>
linkedPromotionEntries: PromotionEntry[]
fallbackStoredEntries: PromotionEntry[]
} {
const linkedPromotionEntries = this._toPromotionEntries(linkedEntries)
const fallbackStoredEntries = this._getFallbackStoredEntries(
const excludedEntryKeys = new Set(
linkedPromotionEntries.map((entry) =>
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
)
)
const connectedEntryKeys = this._getConnectedPromotionEntryKeys()
for (const key of connectedEntryKeys) {
excludedEntryKeys.add(key)
}
const prePruneFallbackStoredEntries = this._getFallbackStoredEntries(
entries,
excludedEntryKeys
)
const fallbackStoredEntries = this._pruneStaleAliasFallbackEntries(
prePruneFallbackStoredEntries,
linkedPromotionEntries
)
@@ -248,14 +392,37 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _shouldPersistLinkedOnly(
linkedEntries: LinkedPromotionEntry[]
linkedEntries: LinkedPromotionEntry[],
fallbackStoredEntries: PromotionEntry[]
): boolean {
return this.inputs.length > 0 && linkedEntries.length === this.inputs.length
if (
!(this.inputs.length > 0 && linkedEntries.length === this.inputs.length)
)
return false
const linkedWidgetNames = new Set(
linkedEntries.map((entry) => entry.widgetName)
)
const hasFallbackToKeep = fallbackStoredEntries.some((entry) => {
const sourceNode = this.subgraph.getNodeById(entry.interiorNodeId)
const hasSourceWidget =
sourceNode?.widgets?.some(
(widget) => widget.name === entry.widgetName
) === true
if (hasSourceWidget) return true
// If the fallback widget name overlaps a linked widget name, keep it
// until aliasing can be positively proven.
return linkedWidgetNames.has(entry.widgetName)
})
return !hasFallbackToKeep
}
private _toPromotionEntries(
linkedEntries: LinkedPromotionEntry[]
): Array<{ interiorNodeId: string; widgetName: string }> {
): PromotionEntry[] {
return linkedEntries.map(({ interiorNodeId, widgetName }) => ({
interiorNodeId,
widgetName
@@ -263,33 +430,98 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _getFallbackStoredEntries(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
linkedPromotionEntries: Array<{
interiorNodeId: string
widgetName: string
}>
): Array<{ interiorNodeId: string; widgetName: string }> {
const linkedKeys = new Set(
linkedPromotionEntries.map((entry) =>
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
)
)
entries: PromotionEntry[],
excludedEntryKeys: Set<string>
): PromotionEntry[] {
return entries.filter(
(entry) =>
!linkedKeys.has(
!excludedEntryKeys.has(
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
)
)
}
private _pruneStaleAliasFallbackEntries(
fallbackStoredEntries: PromotionEntry[],
linkedPromotionEntries: PromotionEntry[]
): PromotionEntry[] {
if (
fallbackStoredEntries.length === 0 ||
linkedPromotionEntries.length === 0
)
return fallbackStoredEntries
const linkedConcreteKeys = new Set(
linkedPromotionEntries
.map((entry) => this._resolveConcretePromotionEntryKey(entry))
.filter((key): key is string => key !== undefined)
)
if (linkedConcreteKeys.size === 0) return fallbackStoredEntries
const prunedEntries: PromotionEntry[] = []
for (const entry of fallbackStoredEntries) {
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue
prunedEntries.push(entry)
}
return prunedEntries
}
private _resolveConcretePromotionEntryKey(
entry: PromotionEntry
): string | undefined {
const result = resolveConcretePromotedWidget(
this,
entry.interiorNodeId,
entry.widgetName
)
if (result.status !== 'resolved') return undefined
return this._makePromotionEntryKey(
String(result.resolved.node.id),
result.resolved.widget.name
)
}
private _getConnectedPromotionEntryKeys(): Set<string> {
const connectedEntryKeys = new Set<string>()
for (const input of this.inputs) {
const subgraphInput = input._subgraphSlot
if (!subgraphInput) continue
const connectedWidgets = subgraphInput.getConnectedWidgets()
for (const widget of connectedWidgets) {
if (!hasWidgetNode(widget)) continue
connectedEntryKeys.add(
this._makePromotionEntryKey(String(widget.node.id), widget.name)
)
}
}
return connectedEntryKeys
}
private _buildLinkedReconcileEntries(
linkedEntries: LinkedPromotionEntry[]
): Array<{ interiorNodeId: string; widgetName: string; viewKey: string }> {
return linkedEntries.map(({ inputName, interiorNodeId, widgetName }) => ({
interiorNodeId,
widgetName,
viewKey: this._makePromotionViewKey(inputName, interiorNodeId, widgetName)
}))
return linkedEntries.map(
({ inputKey, inputName, interiorNodeId, widgetName }) => ({
interiorNodeId,
widgetName,
viewKey: this._makePromotionViewKey(
inputKey,
interiorNodeId,
widgetName,
inputName
)
})
)
}
private _buildDisplayNameByViewKey(
@@ -298,9 +530,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return new Map(
linkedEntries.map((entry) => [
this._makePromotionViewKey(
entry.inputName,
entry.inputKey,
entry.interiorNodeId,
entry.widgetName
entry.widgetName,
entry.inputName
),
entry.inputName
])
@@ -315,11 +548,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _makePromotionViewKey(
inputName: string,
inputKey: string,
interiorNodeId: string,
widgetName: string
widgetName: string,
inputName = ''
): string {
return `${inputName}:${interiorNodeId}:${widgetName}`
return JSON.stringify([inputKey, interiorNodeId, widgetName, inputName])
}
private _resolveLegacyEntry(
@@ -377,22 +611,34 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
(e) => {
const subgraphInput = e.detail.input
const { name, type } = subgraphInput
const existingInput = this.inputs.find((i) => i.name === name)
const existingInput = this.inputs.find(
(input) => input._subgraphSlot === subgraphInput
)
if (existingInput) {
const linkId = subgraphInput.linkIds[0]
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
const widget = inputNode?.widgets?.find?.((w) => w.name === name)
if (widget && inputNode)
if (linkId === undefined) return
const link = this.subgraph.getLink(linkId)
if (!link) return
const { inputNode, input } = link.resolve(subgraph)
if (!inputNode || !input) return
const widget = inputNode.getWidgetFromSlot(input)
if (widget)
this._setWidget(
subgraphInput,
existingInput,
widget,
input?.widget,
input.widget,
inputNode
)
return
}
const input = this.addInput(name, type)
const input = this.addInput(name, type, {
_subgraphSlot: subgraphInput
})
this._invalidatePromotedViewsCache()
this._addSubgraphInputListeners(subgraphInput, input)
},
@@ -406,6 +652,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (widget) this.ensureWidgetRemoved(widget)
this.removeInput(e.detail.index)
this._invalidatePromotedViewsCache()
this._syncPromotions()
this.setDirtyCanvas(true, true)
},
@@ -441,6 +688,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (input._widget) {
input._widget.label = newName
}
this._invalidatePromotedViewsCache()
this.graph?.trigger('node:slot-label:changed', {
nodeId: this.id,
slotType: NodeSlotType.INPUT
})
},
{ signal }
)
@@ -484,6 +736,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput: SubgraphInput,
input: INodeInputSlot & Partial<ISubgraphInput>
) {
input._subgraphSlot = subgraphInput
if (
input._listenerController &&
typeof input._listenerController.abort === 'function'
@@ -496,36 +750,39 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput.events.addEventListener(
'input-connected',
(e) => {
const widget = subgraphInput._widget
if (!widget) return
this._invalidatePromotedViewsCache()
// If this widget is already promoted, demote it first
// so it transitions cleanly to being linked via SubgraphInput.
// `SubgraphInput.connect()` dispatches before appending to `linkIds`,
// so resolve by current links would miss this new connection.
// Keep the earliest bound view once present, and only bind from event
// payload when this input has no representative yet.
const nodeId = String(e.detail.node.id)
if (
usePromotionStore().isPromoted(
this.rootGraph.id,
this.id,
nodeId,
widget.name
e.detail.widget.name
)
) {
usePromotionStore().demote(
this.rootGraph.id,
this.id,
nodeId,
widget.name
e.detail.widget.name
)
}
const widgetLocator = e.detail.input.widget
this._setWidget(
subgraphInput,
input,
widget,
widgetLocator,
e.detail.node
)
const didSetWidgetFromEvent = !input._widget
if (didSetWidgetFromEvent)
this._setWidget(
subgraphInput,
input,
e.detail.widget,
e.detail.input.widget,
e.detail.node
)
this._syncPromotions()
},
{ signal }
@@ -534,9 +791,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput.events.addEventListener(
'input-disconnected',
() => {
// If the input is connected to more than one widget, don't remove the widget
this._invalidatePromotedViewsCache()
// If links remain, rebind to the current representative.
const connectedWidgets = subgraphInput.getConnectedWidgets()
if (connectedWidgets.length > 0) return
if (connectedWidgets.length > 0) {
this._resolveInputWidget(subgraphInput, input)
this._syncPromotions()
return
}
if (input._widget) this.ensureWidgetRemoved(input._widget)
@@ -549,6 +812,62 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
}
private _rebindInputSubgraphSlots(): void {
this._invalidatePromotedViewsCache()
const subgraphSlots = [...this.subgraph.inputNode.slots]
const slotsBySignature = new Map<string, SubgraphInput[]>()
const slotsByName = new Map<string, SubgraphInput[]>()
for (const slot of subgraphSlots) {
const signature = `${slot.name}:${String(slot.type)}`
const signatureSlots = slotsBySignature.get(signature)
if (signatureSlots) {
signatureSlots.push(slot)
} else {
slotsBySignature.set(signature, [slot])
}
const nameSlots = slotsByName.get(slot.name)
if (nameSlots) {
nameSlots.push(slot)
} else {
slotsByName.set(slot.name, [slot])
}
}
const assignedSlotIds = new Set<string>()
const takeUnassignedSlot = (
slots: SubgraphInput[] | undefined
): SubgraphInput | undefined => {
if (!slots) return undefined
return slots.find((slot) => !assignedSlotIds.has(String(slot.id)))
}
for (const input of this.inputs) {
const existingSlot = input._subgraphSlot
if (
existingSlot &&
this.subgraph.inputNode.slots.some((slot) => slot === existingSlot)
) {
assignedSlotIds.add(String(existingSlot.id))
continue
}
const signature = `${input.name}:${String(input.type)}`
const matchedSlot =
takeUnassignedSlot(slotsBySignature.get(signature)) ??
takeUnassignedSlot(slotsByName.get(input.name))
if (matchedSlot) {
input._subgraphSlot = matchedSlot
assignedSlotIds.add(String(matchedSlot.id))
} else {
delete input._subgraphSlot
}
}
}
override configure(info: ExportedSubgraphInstance): void {
for (const input of this.inputs) {
if (
@@ -561,8 +880,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this.inputs.length = 0
this.inputs.push(
...this.subgraph.inputNode.slots.map(
(slot) =>
...this.subgraph.inputNode.slots.map((slot) =>
Object.assign(
new NodeInputSlot(
{
name: slot.name,
@@ -572,7 +891,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
link: null
},
this
)
),
{
_subgraphSlot: slot
}
)
)
)
@@ -597,6 +920,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
override _internalConfigureAfterSlots() {
this._rebindInputSubgraphSlots()
// Ensure proxyWidgets is initialized so it serializes
this.properties.proxyWidgets ??= []
@@ -604,10 +929,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Do NOT clear properties.proxyWidgets — it was already populated
// from serialized data by super.configure(info) before this runs.
this._promotedViewManager.clear()
this._invalidatePromotedViewsCache()
// Hydrate the store from serialized properties.proxyWidgets
const raw = parseProxyWidgets(this.properties.proxyWidgets)
const store = usePromotionStore()
const entries = raw
.map(([nodeId, widgetName]) => {
if (nodeId === '-1') {
@@ -624,6 +951,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return { interiorNodeId: nodeId, widgetName }
})
.filter((e): e is NonNullable<typeof e> => e !== null)
store.setPromotions(this.rootGraph.id, this.id, entries)
// Write back resolved entries so legacy -1 format doesn't persist
@@ -636,9 +964,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Check all inputs for connected widgets
for (const input of this.inputs) {
const subgraphInput = this.subgraph.inputNode.slots.find(
(slot) => slot.name === input.name
)
const subgraphInput = input._subgraphSlot
if (!subgraphInput) {
// Skip inputs that don't exist in the subgraph definition
// This can happen when loading workflows with dynamically added inputs
@@ -702,6 +1028,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
inputWidget: IWidgetLocator | undefined,
interiorNode: LGraphNode
) {
this._invalidatePromotedViewsCache()
this._flushPendingPromotions()
const nodeId = String(interiorNode.id)
@@ -751,8 +1078,18 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
nodeId,
widgetName,
() =>
createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name),
this._makePromotionViewKey(subgraphInput.name, nodeId, widgetName)
createPromotedWidgetView(
this,
nodeId,
widgetName,
input.label ?? subgraphInput.name
),
this._makePromotionViewKey(
String(subgraphInput.id),
nodeId,
widgetName,
input.label ?? input.name
)
)
// NOTE: This code creates linked chains of prototypes for passing across
@@ -808,6 +1145,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return super.addInput(name, type, inputProperties)
}
override getSlotFromWidget(
widget: IBaseWidget | undefined
): INodeInputSlot | undefined {
if (!widget || !isPromotedWidgetView(widget))
return super.getSlotFromWidget(widget)
return this.inputs.find((input) => input._widget === widget)
}
override getWidgetFromSlot(slot: INodeInputSlot): IBaseWidget | undefined {
if (slot._widget) return slot._widget
return super.getWidgetFromSlot(slot)
}
override getInputLink(slot: number): LLink | null {
// Output side: the link from inside the subgraph
const innerLink = this.subgraph.outputNode.slots[slot].getLinks().at(0)
@@ -937,18 +1288,24 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _removePromotedView(view: PromotedWidgetView): void {
this._invalidatePromotedViewsCache()
this._promotedViewManager.remove(view.sourceNodeId, view.sourceWidgetName)
// Reconciled views can also be keyed by inputName-scoped view keys.
// Remove both key shapes to avoid stale cache entries across promote/rebind flows.
this._promotedViewManager.removeByViewKey(
view.sourceNodeId,
view.sourceWidgetName,
this._makePromotionViewKey(
view.name,
for (const input of this.inputs) {
if (input._widget !== view || !input._subgraphSlot) continue
const inputName = input.label ?? input.name
this._promotedViewManager.removeByViewKey(
view.sourceNodeId,
view.sourceWidgetName
view.sourceWidgetName,
this._makePromotionViewKey(
String(input._subgraphSlot.id),
view.sourceNodeId,
view.sourceWidgetName,
inputName
)
)
)
}
}
override removeWidget(widget: IBaseWidget): void {
@@ -987,6 +1344,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override onRemoved(): void {
this._eventAbortController.abort()
this._invalidatePromotedViewsCache()
for (const widget of this.widgets) {
if (isPromotedWidgetView(widget)) {
@@ -1053,9 +1411,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
for (const input of this.inputs) {
if (!input._widget) continue
const subgraphInput = this.subgraph.inputNode.slots.find(
(slot) => slot.name === input.name
)
const subgraphInput =
input._subgraphSlot ??
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
if (!subgraphInput) continue
const connectedWidgets = subgraphInput.getConnectedWidgets()

View File

@@ -0,0 +1,322 @@
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
export const subgraphComplexPromotion1 = {
id: 'e49902fa-ee3e-40e6-a59e-c8931888ad0e',
revision: 0,
last_node_id: 21,
last_link_id: 23,
nodes: [
{
id: 12,
type: 'PreviewAny',
pos: [1367.8236034435063, 305.51100163315823],
size: [225, 166],
flags: {},
order: 3,
mode: 0,
inputs: [
{
name: 'source',
type: '*',
link: 21
}
],
outputs: [],
properties: {
'Node name for S&R': 'PreviewAny'
},
widgets_values: [null, null, null]
},
{
id: 13,
type: 'PreviewAny',
pos: [1271.9742739655217, 551.9124470179938],
size: [225, 166],
flags: {},
order: 1,
mode: 0,
inputs: [
{
name: 'source',
type: '*',
link: 19
}
],
outputs: [],
properties: {
'Node name for S&R': 'PreviewAny'
},
widgets_values: [null, null, null]
},
{
id: 14,
type: 'PreviewAny',
pos: [1414.8695925586444, 847.9456885036253],
size: [225, 166],
flags: {},
order: 2,
mode: 0,
inputs: [
{
name: 'source',
type: '*',
link: 20
}
],
outputs: [],
properties: {
'Node name for S&R': 'PreviewAny'
},
widgets_values: [null, null, null]
},
{
id: 21,
type: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f',
pos: [741.0375276545419, 560.8496560588814],
size: [225, 305.3333435058594],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{
name: 'STRING',
type: 'STRING',
links: [19]
},
{
name: 'STRING_1',
type: 'STRING',
links: [20]
},
{
name: 'STRING_2',
type: 'STRING',
links: [21]
}
],
properties: {
proxyWidgets: [
['20', 'string_a'],
['19', 'string_a'],
['18', 'string_a']
]
},
widgets_values: []
}
],
links: [
[19, 21, 0, 13, 0, 'STRING'],
[20, 21, 1, 14, 0, 'STRING'],
[21, 21, 2, 12, 0, 'STRING']
],
groups: [],
definitions: {
subgraphs: [
{
id: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f',
version: 1,
state: {
lastGroupId: 0,
lastNodeId: 21,
lastLinkId: 23,
lastRerouteId: 0
},
revision: 0,
config: {},
name: 'New Subgraph',
inputNode: {
id: -10,
bounding: [596.9206067268835, 805.5404332481304, 120, 60]
},
outputNode: {
id: -20,
bounding: [1376.7286067268833, 769.5404332481304, 120, 100]
},
inputs: [
{
id: '78479bf4-8145-41d5-9d11-c38e3149fc59',
name: 'string_a',
type: 'STRING',
linkIds: [22, 23],
pos: [696.9206067268835, 825.5404332481304]
}
],
outputs: [
{
id: 'aa263e4e-b558-4dbf-bcb9-ff0c1c72cbef',
name: 'STRING',
type: 'STRING',
linkIds: [16],
localized_name: 'STRING',
pos: [1396.7286067268833, 789.5404332481304]
},
{
id: '8eee6fe3-dc2f-491a-9e01-04ef83309dad',
name: 'STRING_1',
type: 'STRING',
linkIds: [17],
localized_name: 'STRING_1',
pos: [1396.7286067268833, 809.5404332481304]
},
{
id: 'a446d5b9-6042-434d-848a-5d3af5e8e0d4',
name: 'STRING_2',
type: 'STRING',
linkIds: [18],
localized_name: 'STRING_2',
pos: [1396.7286067268833, 829.5404332481304]
}
],
widgets: [],
nodes: [
{
id: 18,
type: 'StringConcatenate',
pos: [818.5102631756379, 706.4562049408103],
size: [480, 268],
flags: {},
order: 0,
mode: 0,
inputs: [
{
localized_name: 'string_a',
name: 'string_a',
type: 'STRING',
widget: {
name: 'string_a'
},
link: 23
}
],
outputs: [
{
localized_name: 'STRING',
name: 'STRING',
type: 'STRING',
links: [16]
}
],
title: 'InnerCatB',
properties: {
'Node name for S&R': 'StringConcatenate'
},
widgets_values: ['Poop', '_B', '']
},
{
id: 19,
type: 'StringConcatenate',
pos: [812.9370280206649, 1040.648423402667],
size: [480, 268],
flags: {},
order: 1,
mode: 0,
inputs: [],
outputs: [
{
localized_name: 'STRING',
name: 'STRING',
type: 'STRING',
links: [17]
}
],
title: 'InnerCatC',
properties: {
'Node name for S&R': 'StringConcatenate'
},
widgets_values: ['', '_C', '']
},
{
id: 20,
type: 'StringConcatenate',
pos: [824.7110975088726, 386.4230523609899],
size: [480, 268],
flags: {},
order: 2,
mode: 0,
inputs: [
{
localized_name: 'string_a',
name: 'string_a',
type: 'STRING',
widget: {
name: 'string_a'
},
link: 22
}
],
outputs: [
{
localized_name: 'STRING',
name: 'STRING',
type: 'STRING',
links: [18]
}
],
title: 'InnerCatA',
properties: {
'Node name for S&R': 'StringConcatenate'
},
widgets_values: ['Poop', '_A', '']
}
],
groups: [],
links: [
{
id: 16,
origin_id: 18,
origin_slot: 0,
target_id: -20,
target_slot: 0,
type: 'STRING'
},
{
id: 17,
origin_id: 19,
origin_slot: 0,
target_id: -20,
target_slot: 1,
type: 'STRING'
},
{
id: 18,
origin_id: 20,
origin_slot: 0,
target_id: -20,
target_slot: 2,
type: 'STRING'
},
{
id: 22,
origin_id: -10,
origin_slot: 0,
target_id: 20,
target_slot: 0,
type: 'STRING'
},
{
id: 23,
origin_id: -10,
origin_slot: 0,
target_id: 18,
target_slot: 0,
type: 'STRING'
}
],
extra: {
workflowRendererVersion: 'Vue'
}
}
]
},
config: {},
extra: {
ds: {
scale: 0.6638894832438259,
offset: [-408.2009703049473, -183.8039508449224]
},
workflowRendererVersion: 'Vue',
frontendVersion: '1.42.3'
},
version: 0.4
} as const as unknown as ISerialisedGraph

View File

@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
cleanupComplexPromotionFixtureNodeType,
setupComplexPromotionFixture
} from './subgraphHelpers'
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
describe('setupComplexPromotionFixture', () => {
afterEach(() => {
cleanupComplexPromotionFixtureNodeType()
})
it('can clean up the globally registered fixture node type', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
setupComplexPromotionFixture()
expect(
LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]
).toBeDefined()
cleanupComplexPromotionFixtureNodeType()
expect(
LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]
).toBeUndefined()
})
})

View File

@@ -8,7 +8,12 @@
import { expect } from 'vitest'
import type { ISlotType, NodeId } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphNode,
LiteGraph,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type {
ExportedSubgraph,
@@ -17,6 +22,27 @@ import type {
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
import { subgraphComplexPromotion1 } from './subgraphComplexPromotion1'
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
class FixtureStringConcatenateNode extends LGraphNode {
constructor() {
super('StringConcatenate')
const input = this.addInput('string_a', 'STRING')
input.widget = { name: 'string_a' }
this.addOutput('STRING', 'STRING')
this.addWidget('text', 'string_a', '', () => {})
this.addWidget('text', 'string_b', '', () => {})
this.addWidget('text', 'delimiter', '', () => {})
}
}
export function cleanupComplexPromotionFixtureNodeType(): void {
if (!LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]) return
LiteGraph.unregisterNodeType(FIXTURE_STRING_CONCAT_TYPE)
}
interface TestSubgraphOptions {
id?: UUID
name?: string
@@ -209,6 +235,48 @@ export function createTestSubgraphNode(
return new SubgraphNode(parentGraph, subgraph, instanceData)
}
export function setupComplexPromotionFixture(): {
graph: LGraph
subgraph: Subgraph
hostNode: SubgraphNode
} {
const fixture = structuredClone(subgraphComplexPromotion1)
const subgraphData = fixture.definitions?.subgraphs?.[0]
if (!subgraphData)
throw new Error('Expected fixture to contain one subgraph definition')
cleanupComplexPromotionFixtureNodeType()
LiteGraph.registerNodeType(
FIXTURE_STRING_CONCAT_TYPE,
FixtureStringConcatenateNode
)
for (const node of subgraphData.nodes as Array<{ type: string }>) {
if (node.type === 'StringConcatenate')
node.type = FIXTURE_STRING_CONCAT_TYPE
}
const hostNodeData = fixture.nodes.find((node) => node.id === 21)
if (!hostNodeData)
throw new Error('Expected fixture to contain subgraph instance node id 21')
const graph = new LGraph()
const subgraph = graph.createSubgraph(subgraphData as ExportedSubgraph)
subgraph.configure(subgraphData as ExportedSubgraph)
const hostNode = new SubgraphNode(
graph,
subgraph,
hostNodeData as ExportedSubgraphInstance
)
graph.add(hostNode)
return {
graph,
subgraph,
hostNode
}
}
/**
* Creates a nested hierarchy of subgraphs for testing deep nesting scenarios.
* @param options Configuration for the nested structure

View File

@@ -3229,10 +3229,6 @@
"addedToWorkspace": "تمت إضافتك إلى {workspaceName}",
"inviteAccepted": "تم قبول الدعوة",
"inviteFailed": "فشل في قبول الدعوة",
"unsavedChanges": {
"message": "لديك تغييرات غير محفوظة. هل تريد تجاهلها والانتقال إلى مساحة عمل أخرى؟",
"title": "تغييرات غير محفوظة"
},
"viewWorkspace": "عرض مساحة العمل"
},
"workspaceAuth": {

View File

@@ -3178,6 +3178,10 @@
"backToWorkflow": "Back to workflow",
"loadTemplate": "Load a template",
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"viewGraph": "View node graph",
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",
@@ -3222,6 +3226,19 @@
"outputPlaceholder": "Output nodes will show up here",
"outputRequiredPlaceholder": "At least one node is required"
},
"error": {
"header": "This app encountered an error",
"log": "Error Logs",
"mobileFixable": "Check {0} for errors",
"requiresGraph": "Something went wrong during generation. This could be due to invalid hidden inputs, missing resources, or workflow configuration issues.",
"promptVisitGraph": "View the node graph to see the full error.",
"getHelp": "For help, view our {0}, {1}, or {2} with the copied error.",
"goto": "Show errors in graph",
"github": "submit a GitHub issue",
"guide": "troubleshooting guide",
"support": "contact our support",
"promptShow": "Show error report"
},
"queue": {
"clickToClear": "Click to clear queue",
"clear": "Clear queue"
@@ -3404,14 +3421,11 @@
"retryDownload": "Retry download"
},
"workspace": {
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
},
"inviteAccepted": "Invite Accepted",
"addedToWorkspace": "You have been added to:",
"inviteFailed": "Failed to Accept Invite",
"viewWorkspace": "View workspace"
"viewWorkspace": "View workspace",
"switchFailed": "Failed to switch workspace. Please try again."
},
"workspaceAuth": {
"errors": {

View File

@@ -3229,10 +3229,6 @@
"addedToWorkspace": "Has sido añadido a {workspaceName}",
"inviteAccepted": "Invitación aceptada",
"inviteFailed": "No se pudo aceptar la invitación",
"unsavedChanges": {
"message": "Tienes cambios no guardados. ¿Quieres descartarlos y cambiar de espacio de trabajo?",
"title": "Cambios no guardados"
},
"viewWorkspace": "Ver espacio de trabajo"
},
"workspaceAuth": {

View File

@@ -3241,10 +3241,6 @@
"addedToWorkspace": "شما به {workspaceName} اضافه شدید",
"inviteAccepted": "دعوت پذیرفته شد",
"inviteFailed": "پذیرش دعوت ناموفق بود",
"unsavedChanges": {
"message": "شما تغییرات ذخیره‌نشده دارید. آیا می‌خواهید آن‌ها را رها کرده و فضای کاری را تغییر دهید؟",
"title": "تغییرات ذخیره‌نشده"
},
"viewWorkspace": "مشاهده workspace"
},
"workspaceAuth": {

View File

@@ -3229,10 +3229,6 @@
"addedToWorkspace": "Vous avez été ajouté à {workspaceName}",
"inviteAccepted": "Invitation acceptée",
"inviteFailed": "Échec de l'acceptation de l'invitation",
"unsavedChanges": {
"message": "Vous avez des modifications non enregistrées. Voulez-vous les abandonner et changer despace de travail ?",
"title": "Modifications non enregistrées"
},
"viewWorkspace": "Voir lespace de travail"
},
"workspaceAuth": {

View File

@@ -3229,10 +3229,6 @@
"addedToWorkspace": "{workspaceName}に追加されました",
"inviteAccepted": "招待を承諾しました",
"inviteFailed": "招待の承諾に失敗しました",
"unsavedChanges": {
"message": "未保存の変更があります。破棄してワークスペースを切り替えますか?",
"title": "未保存の変更"
},
"viewWorkspace": "ワークスペースを見る"
},
"workspaceAuth": {

View File

@@ -3229,10 +3229,6 @@
"addedToWorkspace": "{workspaceName} 워크스페이스에 추가되었습니다",
"inviteAccepted": "초대 수락됨",
"inviteFailed": "초대 수락에 실패했습니다",
"unsavedChanges": {
"message": "저장되지 않은 변경 사항이 있습니다. 변경 사항을 취소하고 워크스페이스를 전환하시겠습니까?",
"title": "저장되지 않은 변경 사항"
},
"viewWorkspace": "워크스페이스 보기"
},
"workspaceAuth": {

View File

@@ -3241,10 +3241,6 @@
"addedToWorkspace": "Você foi adicionado ao {workspaceName}",
"inviteAccepted": "Convite aceito",
"inviteFailed": "Falha ao aceitar convite",
"unsavedChanges": {
"message": "Você tem alterações não salvas. Deseja descartá-las e trocar de espaço de trabalho?",
"title": "Alterações não salvas"
},
"viewWorkspace": "Ver workspace"
},
"workspaceAuth": {

View File

@@ -3229,10 +3229,6 @@
"addedToWorkspace": "Вы были добавлены в {workspaceName}",
"inviteAccepted": "Приглашение принято",
"inviteFailed": "Не удалось принять приглашение",
"unsavedChanges": {
"message": "У вас есть несохранённые изменения. Хотите их отменить и переключиться на другое рабочее пространство?",
"title": "Несохранённые изменения"
},
"viewWorkspace": "Просмотреть рабочее пространство"
},
"workspaceAuth": {

View File

@@ -3229,10 +3229,6 @@
"addedToWorkspace": "{workspaceName} çalışma alanına eklendiniz",
"inviteAccepted": "Davet kabul edildi",
"inviteFailed": "Davet kabul edilemedi",
"unsavedChanges": {
"message": "Kaydedilmemiş değişiklikleriniz var. Bunları iptal edip çalışma alanlarını değiştirmek istiyor musunuz?",
"title": "Kaydedilmemiş Değişiklikler"
},
"viewWorkspace": "Çalışma alanını görüntüle"
},
"workspaceAuth": {

View File

@@ -3229,10 +3229,6 @@
"addedToWorkspace": "你已被加入 {workspaceName}",
"inviteAccepted": "已接受邀請",
"inviteFailed": "接受邀請失敗",
"unsavedChanges": {
"message": "您有未儲存的變更。是否要捨棄這些變更並切換工作區?",
"title": "未儲存的變更"
},
"viewWorkspace": "檢視工作區"
},
"workspaceAuth": {

View File

@@ -3241,10 +3241,6 @@
"addedToWorkspace": "您已被加入 {workspaceName}",
"inviteAccepted": "邀请已接受",
"inviteFailed": "接受邀请失败",
"unsavedChanges": {
"message": "您有未保存的更改。是否要放弃这些更改并切换工作区?",
"title": "未保存的更改"
},
"viewWorkspace": "查看工作区"
},
"workspaceAuth": {

View File

@@ -235,7 +235,7 @@ const adaptedAsset = computed(() => {
id: asset.id,
name: asset.name,
kind: fileKind.value,
src: asset.preview_url || '',
src: asset.thumbnail_url || asset.preview_url || '',
size: asset.size,
tags: asset.tags || [],
created_at: asset.created_at,

View File

@@ -44,7 +44,8 @@ export function mapTaskOutputToAssetItem(
? new Date(taskItem.executionStartTimestamp).toISOString()
: new Date().toISOString(),
tags: ['output'],
preview_url: output.previewUrl,
thumbnail_url: output.previewUrl,
preview_url: output.url,
user_metadata: metadata
}
}
@@ -62,6 +63,7 @@ export function mapInputFileToAssetItem(
directory: 'input' | 'output' = 'input'
): AssetItem {
const params = new URLSearchParams({ filename, type: directory })
const preview_url = api.apiURL(`/view?${params}`)
appendCloudResParam(params, filename)
return {
@@ -70,6 +72,7 @@ export function mapInputFileToAssetItem(
size: 0,
created_at: new Date().toISOString(),
tags: [directory],
preview_url: api.apiURL(`/view?${params}`)
thumbnail_url: api.apiURL(`/view?${params}`),
preview_url
}
}

View File

@@ -84,8 +84,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage'),
life: 3000
detail: t('g.failedToDownloadImage')
})
}
}
@@ -126,8 +125,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage'),
life: 3000
detail: t('g.failedToDownloadImage')
})
}
}
@@ -182,8 +180,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('exportToast.exportFailedSingle'),
life: 3000
detail: t('exportToast.exportFailedSingle')
})
}
}
@@ -238,8 +235,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.nodeTypeNotFound', { nodeType }),
life: 3000
detail: t('mediaAsset.nodeTypeNotFound', { nodeType })
})
return
}
@@ -252,8 +248,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.failedToCreateNode'),
life: 3000
detail: t('mediaAsset.failedToCreateNode')
})
return
}
@@ -443,8 +438,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.selection.failedToAddNodes'),
life: 3000
detail: t('mediaAsset.selection.failedToAddNodes')
})
} else {
toast.add({
@@ -676,8 +670,7 @@ export function useMediaAssetActions() {
summary: t('g.error'),
detail: isSingle
? t('mediaAsset.failedToDeleteAsset')
: t('mediaAsset.selection.failedToDeleteAssets'),
life: 3000
: t('mediaAsset.selection.failedToDeleteAssets')
})
} else {
// Partial success (only possible with multiple assets)
@@ -698,8 +691,7 @@ export function useMediaAssetActions() {
summary: t('g.error'),
detail: isSingle
? t('mediaAsset.failedToDeleteAsset')
: t('mediaAsset.selection.failedToDeleteAssets'),
life: 3000
: t('mediaAsset.selection.failedToDeleteAssets')
})
} finally {
// Hide loading overlay for all assets

View File

@@ -10,6 +10,7 @@ const zAsset = z.object({
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
preview_url: z.string().optional(),
thumbnail_url: z.string().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
is_immutable: z.boolean().optional(),

View File

@@ -73,8 +73,7 @@ export function createAssetWidget(
toastStore.add({
severity: 'error',
summary: t('assetBrowser.invalidAsset'),
detail: t('assetBrowser.invalidAssetDetail'),
life: 5000
detail: t('assetBrowser.invalidAssetDetail')
})
return
}
@@ -92,8 +91,7 @@ export function createAssetWidget(
toastStore.add({
severity: 'error',
summary: t('assetBrowser.invalidFilename'),
detail: t('assetBrowser.invalidFilenameDetail'),
life: 5000
detail: t('assetBrowser.invalidFilenameDetail')
})
return
}

View File

@@ -72,7 +72,8 @@ function mapOutputsToAssetItems({
size: 0,
created_at: createdAtValue,
tags: ['output'],
preview_url: output.previewUrl,
thumbnail_url: output.previewUrl,
preview_url: output.url,
user_metadata: {
jobId,
nodeId: output.nodeId,

View File

@@ -217,22 +217,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
commandId: 'Comfy.Canvas.SelectAll',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'c'
},
commandId: 'Comfy.Canvas.CopySelected',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'v'
},
commandId: 'Comfy.Canvas.PasteFromClipboard',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,

View File

@@ -141,7 +141,7 @@ describe('keybindingService - Canvas Keybindings', () => {
)
})
it('should execute CopySelected for Ctrl+C on canvas', async () => {
it('should not intercept Ctrl+C to allow native copy event', async () => {
const event = createTestKeyboardEvent('c', {
ctrlKey: true,
target: canvasChild
@@ -149,12 +149,10 @@ describe('keybindingService - Canvas Keybindings', () => {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.CopySelected'
)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should execute PasteFromClipboard for Ctrl+V on canvas', async () => {
it('should not intercept Ctrl+V to allow native paste event', async () => {
const event = createTestKeyboardEvent('v', {
ctrlKey: true,
target: canvasChild
@@ -162,9 +160,7 @@ describe('keybindingService - Canvas Keybindings', () => {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.PasteFromClipboard'
)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should execute PasteFromClipboardWithConnect for Ctrl+Shift+V on canvas', async () => {

View File

@@ -317,8 +317,7 @@ export function useNodeReplacement() {
toastStore.add({
severity: 'error',
summary: t('g.error', 'Error'),
detail: t('nodeReplacement.replaceFailed', 'Failed to replace nodes'),
life: 5000
detail: t('nodeReplacement.replaceFailed', 'Failed to replace nodes')
})
return replacedTypes
} finally {

View File

@@ -150,6 +150,41 @@ describe('fetchJobs', () => {
expect(result).toEqual([])
})
it('parses batch containing text-only preview outputs', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve(
createMockResponse([
createMockJob('image-job', 'completed', {
preview_output: {
filename: 'output.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createMockJob('text-job', 'completed', {
preview_output: {
content: 'some generated text',
nodeId: '5',
mediaType: 'text'
}
}),
createMockJob('no-preview-job', 'completed')
])
)
})
const result = await fetchHistory(mockFetch)
expect(result).toHaveLength(3)
expect(result[0].id).toBe('image-job')
expect(result[1].id).toBe('text-job')
expect(result[2].id).toBe('no-preview-job')
})
})
describe('fetchQueue', () => {

View File

@@ -18,13 +18,16 @@ const zJobStatus = z.enum([
'cancelled'
])
const zPreviewOutput = z.object({
filename: z.string(),
subfolder: z.string(),
type: resultItemType,
nodeId: z.string(),
mediaType: z.string()
})
const zPreviewOutput = z
.object({
filename: z.string().optional(),
subfolder: z.string().optional(),
type: resultItemType.optional(),
nodeId: z.string(),
mediaType: z.string(),
display_name: z.string().optional()
})
.passthrough()
/**
* Execution error from Jobs API.

View File

@@ -83,8 +83,7 @@ describe('useSecrets', () => {
expect(mockAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'Network error',
life: 5000
detail: 'Network error'
})
})
})
@@ -130,8 +129,7 @@ describe('useSecrets', () => {
expect(mockAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'Delete failed',
life: 5000
detail: 'Delete failed'
})
})
})

View File

@@ -33,16 +33,14 @@ export function useSecrets() {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: err.message,
life: 5000
detail: err.message
})
} else {
console.error('Unexpected error fetching secrets:', err)
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.unknownError'),
life: 5000
detail: t('g.unknownError')
})
}
} finally {
@@ -60,16 +58,14 @@ export function useSecrets() {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: err.message,
life: 5000
detail: err.message
})
} else {
console.error('Unexpected error deleting secret:', err)
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.unknownError'),
life: 5000
detail: t('g.unknownError')
})
}
} finally {

View File

@@ -370,8 +370,7 @@ export const useWorkflowService = () => {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToSaveDraft'),
life: 3000
detail: t('toastMessages.failedToSaveDraft')
})
}
}

View File

@@ -0,0 +1,360 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as I18n from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowPersistenceV2 } from './useWorkflowPersistenceV2'
const settingMocks = vi.hoisted(() => ({
persistRef: null as { value: boolean } | null
}))
vi.mock('@/platform/settings/settingStore', async () => {
const { ref } = await import('vue')
settingMocks.persistRef = ref(true)
return {
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Workflow.Persist')
return settingMocks.persistRef!.value
return undefined
}),
set: vi.fn()
}))
}
})
const mockToastAdd = vi.fn()
vi.mock('primevue', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock(
'@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader',
() => ({
useSharedWorkflowUrlLoader: () => ({
loadSharedWorkflowFromUrl: vi.fn().mockResolvedValue('not-present')
})
})
)
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof I18n>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
const openWorkflowMock = vi.fn()
const loadBlankWorkflowMock = vi.fn()
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
openWorkflow: openWorkflowMock,
loadBlankWorkflow: loadBlankWorkflowMock
})
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
() => ({
useTemplateUrlLoader: () => ({
loadTemplateFromUrl: vi.fn()
})
})
)
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn()
})
}))
vi.mock('vue-router', () => ({
useRoute: () => ({
query: {}
}),
useRouter: () => ({
replace: vi.fn()
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserLogout: vi.fn()
})
}))
vi.mock('@/platform/navigation/preservedQueryManager', () => ({
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn(() => null)
}))
vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template' }
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('../migration/migrateV1toV2', () => ({
migrateV1toV2: vi.fn()
}))
type GraphChangedHandler = (() => void) | null
const mocks = vi.hoisted(() => {
const state = {
graphChangedHandler: null as GraphChangedHandler,
currentGraph: {} as Record<string, unknown>
}
const serializeMock = vi.fn(() => state.currentGraph)
const loadGraphDataMock = vi.fn()
const apiMock = {
clientId: 'test-client',
initialClientId: 'test-client',
addEventListener: vi.fn((event: string, handler: () => void) => {
if (event === 'graphChanged') {
state.graphChangedHandler = handler
}
}),
removeEventListener: vi.fn()
}
return { state, serializeMock, loadGraphDataMock, apiMock }
})
vi.mock('@/scripts/app', () => ({
app: {
graph: {
serialize: () => mocks.serializeMock()
},
rootGraph: {
serialize: () => mocks.serializeMock()
},
loadGraphData: (...args: unknown[]) => mocks.loadGraphDataMock(...args),
canvas: {}
}
}))
vi.mock('@/scripts/api', () => ({
api: mocks.apiMock
}))
describe('useWorkflowPersistenceV2', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
setActivePinia(createTestingPinia({ stubActions: false }))
localStorage.clear()
sessionStorage.clear()
vi.clearAllMocks()
settingMocks.persistRef!.value = true
mocks.state.graphChangedHandler = null
mocks.state.currentGraph = { initial: true }
mocks.serializeMock.mockImplementation(() => mocks.state.currentGraph)
mocks.loadGraphDataMock.mockReset()
mocks.apiMock.clientId = 'test-client'
mocks.apiMock.initialClientId = 'test-client'
mocks.apiMock.addEventListener.mockImplementation(
(event: string, handler: () => void) => {
if (event === 'graphChanged') {
mocks.state.graphChangedHandler = handler
}
}
)
mocks.apiMock.removeEventListener.mockImplementation(() => {})
openWorkflowMock.mockReset()
loadBlankWorkflowMock.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
function writeTabState(paths: string[], activeIndex: number) {
const pointer = {
workspaceId: 'personal',
paths,
activeIndex
}
sessionStorage.setItem(
`Comfy.Workflow.OpenPaths:test-client`,
JSON.stringify(pointer)
)
}
function writeActivePath(path: string) {
const pointer = {
workspaceId: 'personal',
path
}
sessionStorage.setItem(
`Comfy.Workflow.ActivePath:test-client`,
JSON.stringify(pointer)
)
}
describe('loadPreviousWorkflowFromStorage', () => {
it('loads saved workflow when draft is missing for session path', async () => {
const workflowStore = useWorkflowStore()
const savedWorkflow = workflowStore.createTemporary('SavedWorkflow.json')
// Set session path to the saved workflow but do NOT create a draft
writeActivePath(savedWorkflow.path)
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
// Should call workflowService.openWorkflow with the saved workflow
expect(openWorkflowMock).toHaveBeenCalledWith(savedWorkflow)
// Should NOT fall through to loadGraphData (fallbackToLatestDraft)
expect(mocks.loadGraphDataMock).not.toHaveBeenCalled()
})
it('prefers draft over saved workflow when draft exists', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
const workflow = workflowStore.createTemporary('DraftWorkflow.json')
const draftData = JSON.stringify({ nodes: [], title: 'draft' })
draftStore.saveDraft(workflow.path, draftData, {
name: 'DraftWorkflow.json',
isTemporary: true
})
writeActivePath(workflow.path)
mocks.loadGraphDataMock.mockResolvedValue(undefined)
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
// Should load draft via loadGraphData, not via workflowService.openWorkflow
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
expect(openWorkflowMock).not.toHaveBeenCalled()
})
it('falls back to latest draft only when no session path exists', async () => {
const draftStore = useWorkflowDraftStoreV2()
// No session path set, but a draft exists
const draftData = JSON.stringify({ nodes: [], title: 'latest' })
draftStore.saveDraft('workflows/Other.json', draftData, {
name: 'Other.json',
isTemporary: true
})
mocks.loadGraphDataMock.mockResolvedValue(undefined)
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
// Should load via fallbackToLatestDraft
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
expect(openWorkflowMock).not.toHaveBeenCalled()
})
})
describe('restoreWorkflowTabsState', () => {
it('activates the correct workflow at storedActiveIndex', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
// Create two temporary workflows with drafts
const workflowA = workflowStore.createTemporary('WorkflowA.json')
const workflowB = workflowStore.createTemporary('WorkflowB.json')
draftStore.saveDraft(workflowA.path, JSON.stringify({ title: 'A' }), {
name: 'WorkflowA.json',
isTemporary: true
})
draftStore.saveDraft(workflowB.path, JSON.stringify({ title: 'B' }), {
name: 'WorkflowB.json',
isTemporary: true
})
// storedActiveIndex = 1 → WorkflowB should be activated
writeTabState([workflowA.path, workflowB.path], 1)
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
expect(openWorkflowMock).toHaveBeenCalledWith(workflowB)
})
it('activates first tab when storedActiveIndex is 0', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
const workflowA = workflowStore.createTemporary('WorkflowA.json')
const workflowB = workflowStore.createTemporary('WorkflowB.json')
draftStore.saveDraft(workflowA.path, JSON.stringify({ title: 'A' }), {
name: 'WorkflowA.json',
isTemporary: true
})
draftStore.saveDraft(workflowB.path, JSON.stringify({ title: 'B' }), {
name: 'WorkflowB.json',
isTemporary: true
})
writeTabState([workflowA.path, workflowB.path], 0)
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
expect(openWorkflowMock).toHaveBeenCalledWith(workflowA)
})
it('does not call openWorkflow when no restorable state', async () => {
// No tab state written to sessionStorage
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
expect(openWorkflowMock).not.toHaveBeenCalled()
})
it('restores temporary workflows and adds them to tabs', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStoreV2()
// Save a draft for a workflow that doesn't exist in the store yet
const path = 'workflows/Unsaved.json'
draftStore.saveDraft(path, JSON.stringify({ title: 'Unsaved' }), {
name: 'Unsaved.json',
isTemporary: true
})
writeTabState([path], 0)
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
const restored = workflowStore.getWorkflowByPath(path)
expect(restored).toBeTruthy()
expect(restored?.isTemporary).toBe(true)
expect(workflowStore.openWorkflows.map((w) => w?.path)).toContain(path)
})
it('skips activation when persistence is disabled', async () => {
settingMocks.persistRef!.value = false
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
await restoreWorkflowTabsState()
expect(openWorkflowMock).not.toHaveBeenCalled()
})
})
})

View File

@@ -112,8 +112,7 @@ export function useWorkflowPersistenceV2() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToSaveDraft'),
life: 3000
detail: t('toastMessages.failedToSaveDraft')
})
return
}
@@ -133,19 +132,28 @@ export function useWorkflowPersistenceV2() {
const debouncedPersist = debounce(persistCurrentWorkflow, PERSIST_DEBOUNCE_MS)
const loadPreviousWorkflowFromStorage = async () => {
// 1. Try session pointer (for tab restoration)
const sessionPath = tabState.getActivePath()
// 1. Try draft for session path
if (
sessionPath &&
(await draftStore.loadPersistedWorkflow({
workflowName: null,
preferredPath: sessionPath
}))
) {
)
return true
// 2. Try saved workflow by path (draft may not exist for saved+unmodified workflows)
if (sessionPath) {
const saved = workflowStore.getWorkflowByPath(sessionPath)
if (saved) {
await useWorkflowService().openWorkflow(saved)
return true
}
}
// 2. Fall back to most recent draft
// 3. Fall back to most recent draft
return await draftStore.loadPersistedWorkflow({
workflowName: null,
fallbackToLatestDraft: true
@@ -243,7 +251,7 @@ export function useWorkflowPersistenceV2() {
}
})
const restoreWorkflowTabsState = () => {
const restoreWorkflowTabsState = async () => {
if (!workflowPersistenceEnabled.value) {
tabStateRestored = true
return
@@ -255,10 +263,11 @@ export function useWorkflowPersistenceV2() {
const storedWorkflows = storedTabState?.paths ?? []
const storedActiveIndex = storedTabState?.activeIndex ?? -1
tabStateRestored = true
const isRestorable = storedWorkflows.length > 0 && storedActiveIndex >= 0
if (!isRestorable) return
if (!isRestorable) {
tabStateRestored = true
return
}
storedWorkflows.forEach((path: string) => {
if (workflowStore.getWorkflowByPath(path)) return
@@ -281,6 +290,17 @@ export function useWorkflowPersistenceV2() {
left: storedWorkflows.slice(0, storedActiveIndex),
right: storedWorkflows.slice(storedActiveIndex)
})
tabStateRestored = true
// Activate the correct workflow at storedActiveIndex
const activePath = storedWorkflows[storedActiveIndex]
const workflow = activePath
? workflowStore.getWorkflowByPath(activePath)
: null
if (workflow) {
await useWorkflowService().openWorkflow(workflow)
}
}
return {

View File

@@ -484,8 +484,7 @@ describe('ShareWorkflowDialogContent', () => {
expect(mockToast.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Publish failed',
life: 5000
detail: 'Publish failed'
})
})

View File

@@ -352,8 +352,7 @@ const { isLoading: isSaving, execute: handleSave } = useAsyncState(
toast.add({
severity: 'error',
summary: t('shareWorkflow.saveFailedTitle'),
detail: t('shareWorkflow.saveFailedDescription'),
life: 5000
detail: t('shareWorkflow.saveFailedDescription')
})
}
}
@@ -391,8 +390,7 @@ const {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : t('g.error'),
life: 5000
detail: error instanceof Error ? error.message : t('g.error')
})
}
}

View File

@@ -183,8 +183,7 @@ async function handleCreate() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : t('g.error'),
life: 5000
detail: error instanceof Error ? error.message : t('g.error')
})
} finally {
isCreating.value = false

View File

@@ -338,8 +338,7 @@ describe('useSharedWorkflowUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Failed to load shared workflow',
life: 3000
detail: 'Failed to load shared workflow'
})
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(

View File

@@ -118,8 +118,7 @@ export function useSharedWorkflowUrlLoader() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('shareWorkflow.loadFailed'),
life: 3000
detail: t('shareWorkflow.loadFailed')
})
cleanupUrlParams()
clearPreservedQuery(SHARE_NAMESPACE)
@@ -148,8 +147,7 @@ export function useSharedWorkflowUrlLoader() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('shareWorkflow.loadFailed'),
life: 5000
detail: t('shareWorkflow.loadFailed')
})
return 'failed'
}

View File

@@ -145,8 +145,7 @@ describe('useTemplateUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Template "invalid-template" not found',
life: 3000
detail: 'Template "invalid-template" not found'
})
})
@@ -239,8 +238,7 @@ describe('useTemplateUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Failed to load template',
life: 3000
detail: 'Failed to load template'
})
})

View File

@@ -117,8 +117,7 @@ export function useTemplateUrlLoader() {
summary: t('g.error'),
detail: t('templateWorkflows.error.templateNotFound', {
templateName: templateParam
}),
life: 3000
})
})
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
@@ -132,8 +131,7 @@ export function useTemplateUrlLoader() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.errorLoadingTemplate'),
life: 3000
detail: t('g.errorLoadingTemplate')
})
} finally {
cleanupUrlParams()

View File

@@ -428,8 +428,7 @@ async function handleResubscribe() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: message,
life: 5000
detail: message
})
} finally {
isResubscribing.value = false

View File

@@ -148,8 +148,7 @@ async function handleSubscribeClick(payload: {
toast.add({
severity: 'error',
summary: 'Unable to subscribe',
detail: 'This plan is not available',
life: 5000
detail: 'This plan is not available'
})
return
}
@@ -159,8 +158,7 @@ async function handleSubscribeClick(payload: {
toast.add({
severity: 'error',
summary: 'Unable to subscribe',
detail: response?.reason || 'This plan is not available',
life: 5000
detail: response?.reason || 'This plan is not available'
})
return
}
@@ -175,8 +173,7 @@ async function handleSubscribeClick(payload: {
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
detail: message
})
} finally {
isLoadingPreview.value = false
@@ -236,8 +233,7 @@ async function handleAddCreditCard() {
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
detail: message
})
} finally {
isSubscribing.value = false
@@ -291,8 +287,7 @@ async function handleConfirmTransition() {
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
detail: message
})
} finally {
isSubscribing.value = false
@@ -316,8 +311,7 @@ async function handleResubscribe() {
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000
detail: message
})
} finally {
isResubscribing.value = false

View File

@@ -273,8 +273,7 @@ async function handleBuy() {
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.unknownError'),
life: 5000
detail: t('credits.topUp.unknownError')
})
}
} catch (error) {
@@ -285,8 +284,7 @@ async function handleBuy() {
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage })
})
} finally {
loading.value = false

View File

@@ -139,7 +139,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { switchWithConfirmation } = useWorkspaceSwitch()
const { switchWorkspace } = useWorkspaceSwitch()
const { subscription } = useBillingContext()
const tierKeyMap: Record<string, string> = {
@@ -226,7 +226,7 @@ function getTierLabel(workspace: AvailableWorkspace): string | null {
}
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
const success = await switchWithConfirmation(workspace.id)
const success = await switchWorkspace(workspace.id)
if (success) {
emit('select', workspace)
}

View File

@@ -102,8 +102,7 @@ async function onCreate() {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
detail: error instanceof Error ? error.message : t('g.unknownError')
})
} finally {
loading.value = false

View File

@@ -79,8 +79,7 @@ async function onDelete() {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToDeleteWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
detail: error instanceof Error ? error.message : t('g.unknownError')
})
} finally {
loading.value = false

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