Compare commits

...

51 Commits

Author SHA1 Message Date
Alexander Brown
8ca6a1799b [backport cloud/1.41] fix: stabilize subgraph promoted widget identity and rendering (#9929)
Backport of #9896 to `cloud/1.41`.

Conflict resolution notes:
- Resolved expected conflicts in `useGraphNodeManager.test.ts`,
`SubgraphNode.ts`, and `NodeWidgets.vue`.
- Kept core promoted-widget identity/rendering fixes from #9896.
- Dropped missing-model/source-execution specific test/runtime additions
not available on `cloud/1.41` APIs.

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

Co-authored-by: Amp <amp@ampcode.com>
2026-03-14 15:43:29 -07:00
Comfy Org PR Bot
02d6ecf897 [backport cloud/1.41] fix: clear stale widget slotMetadata on link disconnect (#9920)
Backport of #9885 to `cloud/1.41`

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9917-backport-cloud-1-41-fix-restore-native-copy-paste-events-for-image-paste-support-3236d73d365081eab749f1e82ab599b2)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2026-03-14 01:21:59 -07:00
Comfy Org PR Bot
d1827eecf3 [backport cloud/1.41] fix: hide template selector after shared workflow accept (#9915)
Backport of #9913 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-14 08:12:19 +00:00
Comfy Org PR Bot
73435ee2d9 [backport cloud/1.41] feat: add model-to-node mappings for CogVideo, inpaint, and LayerDiffuse (#9907)
Backport of #9890 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9907-backport-cloud-1-41-feat-add-model-to-node-mappings-for-CogVideo-inpaint-and-LayerDi-3236d73d365081c0acdfcf4302f95d7f)
by [Unito](https://www.unito.io)

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:49:34 -07:00
Comfy Org PR Bot
fa8716572d [backport cloud/1.41] feat(telemetry): add view_mode and is_app_mode to run_button_click event (#9889)
Backport of #9881 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9889-backport-cloud-1-41-feat-telemetry-add-view_mode-and-is_app_mode-to-run_button_click--3226d73d365081b587cbc91201a0edc1)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-13 11:40:25 -07:00
Comfy Org PR Bot
951a26775c [backport cloud/1.41] Add support for values factory function in widget select combo (#9883)
Backport of #8775 to `cloud/1.41`

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9871-backport-cloud-1-41-feat-show-ComfyUI-context-menu-on-textarea-widget-right-click-3226d73d3650819a9c81caaccbc2f89a)
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:30 -07:00
Comfy Org PR Bot
d13061b943 [backport cloud/1.41] fix: advanced widgets always visible regardless of setting (#9867)
Backport of #9857 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9867-backport-cloud-1-41-fix-advanced-widgets-always-visible-regardless-of-setting-3226d73d365081b0a02fe06dcd192418)
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:56 -07:00
Robin Huang
c0ea8f1e31 [backport cloud/1.41] feat: add server-side PostHog config overrides (#9758) (#9855) 2026-03-12 23:29:53 -07:00
Comfy Org PR Bot
2c8ad1380f [backport cloud/1.41] feat: bake frontend commit hash into build (#9848)
Backport of #9832 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9848-backport-cloud-1-41-feat-bake-frontend-commit-hash-into-build-3226d73d3650817aa852f91f44a13091)
by [Unito](https://www.unito.io)

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-03-13 01:14:02 +00:00
Comfy Org PR Bot
af8d502d81 [backport cloud/1.41] fix: update workspace creation modal phrasing for credit pool clarity (#9845)
Backport of #9811 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9845-backport-cloud-1-41-fix-update-workspace-creation-modal-phrasing-for-credit-pool-clari-3226d73d365081a1b79ffa3e7b11ab95)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-12 17:21:51 -07:00
Comfy Org PR Bot
c48953839f [backport cloud/1.41] fix: omit job_asset_name_filters when all job outputs selected (#9834)
Backport of #9684 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9834-backport-cloud-1-41-fix-omit-job_asset_name_filters-when-all-job-outputs-selected-3216d73d36508187b5b1c04d2fe1b6c0)
by [Unito](https://www.unito.io)

Co-authored-by: Simon Pinfold <synap5e@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-12 17:13:37 -07:00
Comfy Org PR Bot
86e67d163d [backport cloud/1.41] feat: set subscription tier as PostHog user property (#9836)
Backport of #9764 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9836-backport-cloud-1-41-feat-set-subscription-tier-as-PostHog-user-property-3216d73d365081989911cc0c18906556)
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: Christian Byrne <cbyrne@comfy.org>
2026-03-12 15:07:55 -07:00
Comfy Org PR Bot
7ad2f195e4 [backport cloud/1.41] fix: dropdown widget fetching output files (#9831)
Backport of #6734 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9831-backport-cloud-1-41-fix-dropdown-widget-fetching-output-files-3216d73d3650810d83c7f3dd325c0735)
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:05 -07:00
Comfy Org PR Bot
eedf03a709 [backport cloud/1.41] fix: preserve input asset previews across execution updates (#9816)
Backport of #9123 to `cloud/1.41`

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9814-backport-cloud-1-41-fix-skip-node-metadata-paste-when-media-node-is-selected-3216d73d365081b18d4ac8878607387d)
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 09:00:50 -07:00
Comfy Org PR Bot
32f757b54a [backport cloud/1.41] fix: restore correct workflow on page reload (#9800)
Backport of #9318 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9800-backport-cloud-1-41-fix-restore-correct-workflow-on-page-reload-3216d73d36508130b880d55524b987de)
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:07 -07:00
Comfy Org PR Bot
37b9593b9a [backport cloud/1.41] feat: add copy button to System Info panel (#9721)
Backport of #9719 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9721-backport-cloud-1-41-feat-add-copy-button-to-System-Info-panel-31f6d73d365081209262f46e34a62570)
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>
2026-03-12 08:51:41 -07:00
Comfy Org PR Bot
e3baf6df0b [backport cloud/1.41] fix: call checkState after image input changes for proper undo tracking (#9810)
Backport of #9623 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9810-backport-cloud-1-41-fix-call-checkState-after-image-input-changes-for-proper-undo-trac-3216d73d365081669860d7a44ebea499)
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:44 -07:00
Comfy Org PR Bot
34953a7f98 [backport cloud/1.41] fix: app mode widgets disappear after hard refresh (#9772)
Backport of #9621 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9772-backport-cloud-1-41-fix-app-mode-widgets-disappear-after-hard-refresh-3216d73d3650813a9437f1ae6e7e04d7)
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:04:50 -07:00
Luke Mino-Altherr
b1bfe5fb46 [backport cloud/1.41] fix: make zPreviewOutput accept text-only job outputs (#9765)
Backport of #9724 to `cloud/1.41`

Cherry-pick applied cleanly with no conflicts.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9765-backport-cloud-1-41-fix-make-zPreviewOutput-accept-text-only-job-outputs-3216d73d365081f4880dca7bcf1d4bc9)
by [Unito](https://www.unito.io)
2026-03-11 19:11:53 -07:00
Comfy Org PR Bot
a5e5e4813a [backport cloud/1.41] fix: restore widget.inputEl backward compatibility for custom nodes (#9761)
Backport of #9759 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9761-backport-cloud-1-41-fix-restore-widget-inputEl-backward-compatibility-for-custom-nodes-3216d73d365081aa82ecda6871d0c351)
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:16 +00:00
Comfy Org PR Bot
1d05f08edd [backport cloud/1.41] feat: add remote config support for PostHog debug mode (#9756)
Backport of #9755 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:29:13 -07:00
Robin Huang
936291eb85 [backport cloud/1.41] feat: add telemetry for workflow save and default view (#9753)
Backport of #9734 to cloud/1.41. Adds telemetry events for workflow save
and default view in App Builder.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9753-backport-cloud-1-41-feat-add-telemetry-for-workflow-save-and-default-view-3206d73d3650818b833adb3062c0ae62)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:59:18 -07:00
Comfy Org PR Bot
fd352a4a8f [backport cloud/1.41] fix: update PostHog api_host fallback domain (#9736)
Backport of #9733 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9736-backport-cloud-1-41-fix-update-PostHog-api_host-fallback-domain-3206d73d36508178a7e1ef3ccf8dbb9a)
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>
2026-03-11 11:49:50 -07:00
Comfy Org PR Bot
99db641662 [backport cloud/1.41] feat: track app mode entry and shared workflow loading (#9723)
Backport of #9720 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9723-backport-cloud-1-41-feat-track-app-mode-entry-and-shared-workflow-loading-31f6d73d365081f1b37dcfba85d779ec)
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>
2026-03-10 22:12:15 +00:00
Comfy Org PR Bot
4e933d504c [backport cloud/1.41] Mobile input tweaks (#9711)
Backport of #9686 to `cloud/1.41`

Automatically created by backport workflow.

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

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 23:35:07 -07:00
Comfy Org PR Bot
e3dc4a96e8 [backport cloud/1.41] Redirect authenticated users from signup page to cloud (#9693)
Backport of #9691 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-03-09 23:11:40 -07:00
Robin Huang
ce40e5ef85 [backport cloud/1.41] feat: show user avatar for personal workspace (#9705)
Backport of #9687 to cloud/1.41.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9705-backport-cloud-1-41-feat-show-user-avatar-for-personal-workspace-31f6d73d36508193b3adfe0d9e404695)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:31:34 -07:00
Robin Huang
7302f550bd [backport cloud/1.41] feat: show App/Node Graph type indicator on template cards (#9706)
Backport of #9695 to cloud/1.41.

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

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-09 22:31:17 -07:00
Comfy Org PR Bot
37be2eca91 [backport cloud/1.41] Use preview downscaling in fewer places (#9683)
Backport of #9678 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9683-backport-cloud-1-41-Use-preview-downscaling-in-fewer-places-31e6d73d3650816aac69c4d1f01fa35a)
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:32 -07:00
Comfy Org PR Bot
ca66943826 [backport cloud/1.41] Restore hiding of linked inputs in app mode (#9677)
Backport of #9671 to `cloud/1.41`

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 13:25:20 -07:00
Comfy Org PR Bot
2c8974d250 [backport cloud/1.41] fix: show load widget inputs in media dropdown (#9673)
Backport of #9670 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-09 19:58:01 +00:00
Comfy Org PR Bot
a77b452186 [backport cloud/1.41] style: Update share icon to be a send icon instead (#9668)
Backport of #9667 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9668-backport-cloud-1-41-style-Update-share-icon-to-be-a-send-icon-instead-31e6d73d365081edad8ef5df7d3ce8be)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 10:14:13 -07:00
Comfy Org PR Bot
9b3d80955b [backport cloud/1.41] fix: Add a tooltip to account for assets with really long names. (#9666)
Backport of #9665 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9666-backport-cloud-1-41-fix-Add-a-tooltip-to-account-for-assets-with-really-long-names-31e6d73d36508148ac30f80fc7e94005)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 10:05:48 -07:00
Comfy Org PR Bot
c1cfe6ac73 [backport cloud/1.41] Even further app fixes (#9664)
Backport of #9617 to `cloud/1.41`

Automatically created by backport workflow.

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

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 09:45:08 -07:00
Comfy Org PR Bot
653c278f91 [backport cloud/1.41] feat: display original asset names instead of hashes in assets panel (#9638)
Backport of #9626 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9638-backport-cloud-1-41-feat-display-original-asset-names-instead-of-hashes-in-assets-pane-31e6d73d365081ac9364f28eafbdf6a6)
by [Unito](https://www.unito.io)

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-03-09 09:41:29 -07:00
Comfy Org PR Bot
165856e1a0 [backport cloud/1.41] fix: prevent showing outputs in app mode when no output nodes configured (#9633)
Backport of #9625 to `cloud/1.41`

Automatically created by backport workflow.

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

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-08 17:44:34 -07:00
Comfy Org PR Bot
f27da404f0 [backport cloud/1.41] fix: restore backend outputs_count for asset sidebar multi-output badge (#9628)
Backport of #9627 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9628-backport-cloud-1-41-fix-restore-backend-outputs_count-for-asset-sidebar-multi-output-b-31d6d73d365081d0ba1ccaa88dcd8ce5)
by [Unito](https://www.unito.io)

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-03-08 17:35:57 -07:00
Comfy Org PR Bot
0f4ad8098d [backport cloud/1.41] fix: use previewable output count for asset sidebar badge (#9560)
Backport of #9535 to `cloud/1.41`

Automatically created by backport workflow.

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

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-07 19:02:37 -08:00
Comfy Org PR Bot
f4828c4a25 [backport cloud/1.41] fix: remove timeouts from error toasts so they persist until dismissed (#9553)
Backport of #9543 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9553-backport-cloud-1-41-fix-remove-timeouts-from-error-toasts-so-they-persist-until-dismis-31d6d73d365081d39183dcd7c83b3aa0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-07 18:06:01 -08:00
Comfy Org PR Bot
af17ab440d [backport cloud/1.41] feat/fix: App mode further updates (#9547)
Backport of #9545 to `cloud/1.41`

Automatically created by backport workflow.

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

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-07 17:06:33 -08:00
Comfy Org PR Bot
444816053f [backport cloud/1.41] fix: prevent non-widget inputs on nested subgraphs from appearing as button widgets (#9544)
Backport of #9542 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 23:06:54 +00:00
Comfy Org PR Bot
0483c6819e [backport cloud/1.41] fix: remove workspace switching confirmation dialog (#9540)
Backport of #9250 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9540-backport-cloud-1-41-fix-remove-workspace-switching-confirmation-dialog-31c6d73d365081cb87dbfbe65de23cd9)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-07 14:55:45 -08:00
Comfy Org PR Bot
df9b359cc3 [backport cloud/1.41] Yet further app fixes (#9541)
Backport of #9523 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-07 22:29:02 +00:00
Comfy Org PR Bot
1c9edeb604 [backport cloud/1.41] fix: Prevent corruption of workflow data due to checkState during graph loading (#9537)
Backport of #9531 to `cloud/1.41`

Automatically created by backport workflow.

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

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-07 14:01:55 -08:00
Comfy Org PR Bot
859070f3fe [backport cloud/1.41] feat: Integrated tab UI updates (#9538)
Backport of #8516 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9538-backport-cloud-1-41-feat-Integrated-tab-UI-updates-31c6d73d36508199a52cf9312ce5ad6c)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-07 14:01:43 -08:00
Comfy Org PR Bot
65ac0e586d [backport cloud/1.41] feat/fix: App mode QA fixes (#9536)
Backport of #9530 to `cloud/1.41`

Automatically created by backport workflow.

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

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-07 14:01:17 -08:00
231 changed files with 6545 additions and 1308 deletions

View File

@@ -38,6 +38,9 @@ TEST_COMFYUI_DIR=/home/ComfyUI
ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

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
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

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: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 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

@@ -24,6 +24,7 @@ const extraFileExtensions = ['.vue']
const commonGlobals = {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__COMFYUI_FRONTEND_COMMIT__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
} as const

2
global.d.ts vendored
View File

@@ -1,4 +1,5 @@
declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __COMFYUI_FRONTEND_COMMIT__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
@@ -35,6 +36,7 @@ interface Window {
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Record<string, unknown>
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -166,13 +166,22 @@ describe('TopMenuSection', () => {
})
describe('authentication state', () => {
function createLegacyTabBarWrapper() {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.UI.TabBarLayout' ? 'Legacy' : undefined
)
return createWrapper({ pinia })
}
describe('when user is logged in', () => {
beforeEach(() => {
mockData.isLoggedIn = true
})
it('should display CurrentUserButton and not display LoginButton', () => {
const wrapper = createWrapper()
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})
@@ -186,7 +195,7 @@ describe('TopMenuSection', () => {
describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => {
mockData.isDesktop = true
const wrapper = createWrapper()
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
})
@@ -194,7 +203,7 @@ describe('TopMenuSection', () => {
describe('on web platform', () => {
it('should not display CurrentUserButton and not display LoginButton', () => {
const wrapper = createWrapper()
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})

View File

@@ -70,7 +70,7 @@
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--share-2] size-4" />
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
@@ -183,7 +183,7 @@ const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()

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

@@ -1,6 +1,7 @@
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
@@ -42,6 +43,9 @@ export function useAppSetDefaultView() {
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})
closeDialog()
showAppliedDialog(openAsApp)
}
@@ -54,6 +58,7 @@ export function useAppSetDefaultView() {
appliedAsApp,
onViewApp: () => {
closeAppliedDialog()
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
setMode('app')
},
onExitToWorkflow: () => {

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

@@ -1,9 +1,15 @@
<template>
<div class="system-stats">
<div class="mb-6">
<h2 class="mb-4 text-2xl font-semibold">
{{ $t('g.systemInfo') }}
</h2>
<div class="mb-4 flex items-center gap-2">
<h2 class="text-2xl font-semibold">
{{ $t('g.systemInfo') }}
</h2>
<Button variant="secondary" @click="copySystemInfo">
<i class="pi pi-copy" />
{{ $t('g.copySystemInfo') }}
</Button>
</div>
<div class="grid grid-cols-2 gap-2">
<template v-for="col in systemColumns" :key="col.field">
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
@@ -46,15 +52,21 @@ import TabView from 'primevue/tabview'
import { computed } from 'vue'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
const props = defineProps<{
stats: SystemStats
}>()
const { copyToClipboard } = useCopyToClipboard()
const systemInfo = computed(() => ({
...props.stats.system,
argv: props.stats.system.argv.join(' ')
@@ -67,6 +79,7 @@ type SystemInfoKey = keyof SystemStats['system']
type ColumnDef = {
field: SystemInfoKey
header: string
getValue?: () => string
format?: (value: string) => string
formatNumber?: (value: number) => string
}
@@ -94,6 +107,7 @@ const cloudColumns: ColumnDef[] = [
{
field: 'comfyui_frontend_version',
header: 'Frontend Version',
getValue: () => frontendCommit,
format: formatCommitHash
},
{ field: 'workflow_templates_version', header: 'Templates Version' }
@@ -108,8 +122,10 @@ function isOutdated(column: ColumnDef): boolean {
return !!installed && !!required && installed !== required
}
const getDisplayValue = (column: ColumnDef) => {
const value = systemInfo.value[column.field]
function getDisplayValue(column: ColumnDef) {
const value = column.getValue
? column.getValue()
: systemInfo.value[column.field]
if (column.formatNumber && typeof value === 'number') {
return column.formatNumber(value)
}
@@ -118,4 +134,33 @@ const getDisplayValue = (column: ColumnDef) => {
}
return value
}
function formatSystemInfoText(): string {
const lines: string[] = ['## System Info']
for (const col of systemColumns.value) {
const display = getDisplayValue(col)
if (display !== undefined && display !== '') {
lines.push(`${col.header}: ${display}`)
}
}
if (hasDevices.value) {
lines.push('')
lines.push('## Devices')
for (const device of props.stats.devices) {
lines.push(`- ${device.name} (${device.type})`)
lines.push(` VRAM Total: ${formatSize(device.vram_total)}`)
lines.push(` VRAM Free: ${formatSize(device.vram_free)}`)
lines.push(` Torch VRAM Total: ${formatSize(device.torch_vram_total)}`)
lines.push(` Torch VRAM Free: ${formatSize(device.torch_vram_free)}`)
}
}
return lines.join('\n')
}
function copySystemInfo() {
copyToClipboard(formatSystemInfoText())
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<Avatar
class="bg-interface-panel-selected-surface"
class="aspect-square bg-interface-panel-selected-surface"
:image="photoUrl ?? undefined"
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
:pt:icon:class="{ 'size-4': !hasAvatar }"

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

@@ -5,11 +5,8 @@
v-if="isHelpCenterVisible"
class="help-center-popup"
:class="{
'sidebar-left':
triggerLocation === 'sidebar' && sidebarLocation === 'left',
'sidebar-right':
triggerLocation === 'sidebar' && sidebarLocation === 'right',
'topbar-right': triggerLocation === 'topbar',
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
>
@@ -63,7 +60,6 @@ const { isSmall = false } = defineProps<{
const {
isHelpCenterVisible,
triggerLocation,
sidebarLocation,
closeHelpCenter,
handleWhatsNewDismissed
@@ -101,25 +97,6 @@ const {
right: 1rem;
}
.help-center-popup.topbar-right {
top: 2rem;
right: 1rem;
bottom: auto;
animation: slideInDown 0.2s ease-out;
}
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInUp {
from {
opacity: 0;

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

@@ -11,12 +11,13 @@
}"
@click="onLogoMenuClick($event)"
>
<div class="flex size-8 items-center justify-center rounded-lg bg-black">
<div class="flex items-center gap-0.5">
<ComfyLogo
alt="ComfyUI Logo"
class="comfyui-logo h-[18px] w-[18px] text-white"
class="comfyui-logo h-[18px] w-[18px]"
mode="fill"
/>
<i class="icon-[lucide--chevron-down] size-3 text-muted-foreground" />
</div>
</div>

View File

@@ -41,7 +41,7 @@
v-if="userStore.isMultiUserServer"
:is-small="isSmall"
/>
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
@@ -95,9 +95,6 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const isConnected = computed(
() =>
selectedTab.value ||

View File

@@ -7,7 +7,7 @@
:icon-badge="shouldShowRedDot ? '' : ''"
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
:is-small="isSmall"
@click="toggleHelpCenter"
@click="toggleHelpCenter()"
/>
</template>

View File

@@ -33,7 +33,7 @@
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
name: getAssetDisplayName(item.asset),
type: getAssetMediaType(item.asset)
})
"
@@ -44,7 +44,7 @@
)
"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="item.asset.name"
:preview-alt="getAssetDisplayName(item.asset)"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
@@ -133,8 +133,12 @@ const listGridStyle = {
gap: '0.5rem'
}
function getAssetDisplayName(asset: AssetItem): string {
return asset.display_name || asset.name
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
return truncateFilename(getAssetDisplayName(asset))
}
function getAssetMediaType(asset: AssetItem) {

View File

@@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => {
const dialogStore = useDialogStore()
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: asset.name,
title: asset.display_name || asset.name,
component: Load3dViewerContent,
props: {
modelUrl: asset.preview_url || ''
@@ -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

@@ -9,6 +9,18 @@ import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserButton from './CurrentUserButton.vue'
const mockFeatureFlags = vi.hoisted(() => ({
teamWorkspacesEnabled: false
}))
const mockTeamWorkspaceStore = vi.hoisted(() => ({
workspaceName: { value: '' },
initState: { value: 'idle' },
isInPersonalWorkspace: { value: false }
}))
const mockIsCloud = vi.hoisted(() => ({ value: false }))
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
@@ -32,16 +44,19 @@ vi.mock('pinia', () => ({
// Mock the useFeatureFlags composable
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: { teamWorkspacesEnabled: false }
flags: mockFeatureFlags
}))
}))
// Mock the useTeamWorkspaceStore
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: vi.fn(() => ({
workspaceName: { value: '' },
initState: { value: 'idle' }
}))
useTeamWorkspaceStore: vi.fn(() => mockTeamWorkspaceStore)
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
// Mock the useCurrentUser composable
@@ -64,6 +79,16 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
// Mock the WorkspaceProfilePic component
vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
default: {
name: 'WorkspaceProfilePicMock',
render() {
return h('div', 'WorkspaceProfilePic')
}
}
}))
// Mock the CurrentUserPopoverLegacy component
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
default: {
@@ -78,9 +103,15 @@ vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
describe('CurrentUserButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFeatureFlags.teamWorkspacesEnabled = false
mockTeamWorkspaceStore.workspaceName.value = ''
mockTeamWorkspaceStore.initState.value = 'idle'
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
mockIsCloud.value = false
})
const mountComponent = (): VueWrapper => {
const mountComponent = (options?: { stubButton?: boolean }): VueWrapper => {
const { stubButton = true } = options ?? {}
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -99,7 +130,7 @@ describe('CurrentUserButton', () => {
hide: vi.fn()
}
},
Button: true
...(stubButton ? { Button: true } : {})
}
}
})
@@ -137,4 +168,27 @@ describe('CurrentUserButton', () => {
// Verify that popover.hide was called
expect(popoverHideSpy).toHaveBeenCalled()
})
it('shows UserAvatar in personal workspace', () => {
mockIsCloud.value = true
mockFeatureFlags.teamWorkspacesEnabled = true
mockTeamWorkspaceStore.initState.value = 'ready'
mockTeamWorkspaceStore.isInPersonalWorkspace.value = true
const wrapper = mountComponent({ stubButton: false })
expect(wrapper.html()).toContain('Avatar')
expect(wrapper.html()).not.toContain('WorkspaceProfilePic')
})
it('shows WorkspaceProfilePic in team workspace', () => {
mockIsCloud.value = true
mockFeatureFlags.teamWorkspacesEnabled = true
mockTeamWorkspaceStore.initState.value = 'ready'
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
mockTeamWorkspaceStore.workspaceName.value = 'My Team'
const wrapper = mountComponent({ stubButton: false })
expect(wrapper.html()).toContain('WorkspaceProfilePic')
expect(wrapper.html()).not.toContain('Avatar')
})
})

View File

@@ -30,7 +30,7 @@
<UserAvatar
v-else
:photo-url="photoURL"
:class="compact && 'size-full'"
:class="compact && 'h-full w-auto'"
/>
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />
@@ -98,15 +98,21 @@ const photoURL = computed<string | undefined>(
() => userPhotoUrl.value ?? undefined
)
const { workspaceName: teamWorkspaceName, initState } = storeToRefs(
useTeamWorkspaceStore()
)
const {
workspaceName: teamWorkspaceName,
initState,
isInPersonalWorkspace
} = storeToRefs(useTeamWorkspaceStore())
const showWorkspaceSkeleton = computed(
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'loading'
)
const showWorkspaceIcon = computed(
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'ready'
() =>
isCloud &&
teamWorkspacesEnabled.value &&
initState.value === 'ready' &&
!isInPersonalWorkspace.value
)
const workspaceName = computed(() => {

View File

@@ -1,21 +0,0 @@
<template>
<Button
class="comfy-help-center-btn relative text-base-foreground"
variant="textonly"
@click="toggleHelpCenter"
>
<div class="not-md:hidden">{{ $t('menu.helpAndFeedback') }}</div>
<i class="ml-0.5 icon-[lucide--circle-help]" />
<span
v-if="shouldShowRedDot"
class="absolute top-[7px] right-[7px] size-1.5 rounded-full bg-[#ff3b30]"
/>
</Button>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useHelpCenter } from '@/composables/useHelpCenter'
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter('topbar')
</script>

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

@@ -83,13 +83,18 @@
v-if="isIntegratedTabBar"
class="ml-auto flex shrink-0 items-center gap-2 px-2"
>
<TopMenuHelpButton />
<CurrentUserButton
v-if="isLoggedIn"
:show-arrow="false"
compact
class="grid w-10 shrink-0 p-1"
/>
<Button
v-if="isCloud || isNightly"
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
class="shrink-0 text-base-foreground"
:aria-label="$t('actionbar.feedback')"
@click="openFeedback"
>
<i class="icon-[lucide--message-square-text]" />
</Button>
<CurrentUserButton v-if="showCurrentUser" compact class="shrink-0 p-1" />
<LoginButton v-else-if="isDesktop" class="p-1" />
</div>
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
@@ -102,21 +107,20 @@ import ScrollPanel from 'primevue/scrollpanel'
import SelectButton from 'primevue/selectbutton'
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackUrl } from '@/platform/support/config'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isDesktop } from '@/platform/distribution/types'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { whileMouseDown } from '@/utils/mouseDownUtil'
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
@@ -138,8 +142,14 @@ const commandStore = useCommandStore()
const { isLoggedIn } = useCurrentUser()
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
const feedbackUrl = buildFeedbackUrl()
function openFeedback() {
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
}
const containerRef = ref<HTMLElement | null>(null)
const showOverflowArrows = ref(false)

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,160 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
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()
})
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')
})
})
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 +469,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

@@ -5,19 +5,15 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import type { HelpCenterTriggerLocation } from '@/stores/helpCenterStore'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useNodeConflictDialog } from '@/workbench/extensions/manager/composables/useNodeConflictDialog'
export function useHelpCenter(
triggerFrom: HelpCenterTriggerLocation = 'sidebar'
) {
export function useHelpCenter() {
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const helpCenterStore = useHelpCenterStore()
const { isVisible: isHelpCenterVisible, triggerLocation } =
storeToRefs(helpCenterStore)
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const conflictDetection = useConflictDetection()
@@ -42,9 +38,9 @@ export function useHelpCenter(
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: `${triggerFrom}_help_center_toggled`
button_id: 'sidebar_help_center_toggled'
})
helpCenterStore.toggle(triggerFrom)
helpCenterStore.toggle()
}
const closeHelpCenter = () => {
@@ -90,7 +86,6 @@ export function useHelpCenter(
return {
isHelpCenterVisible,
triggerLocation,
shouldShowRedDot,
sidebarLocation,
toggleHelpCenter,

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

@@ -1,6 +1,7 @@
import { computed } from 'vue'
import { computed, watch } from 'vue'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { t } from '@/i18n'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExtensionService } from '@/services/extensionService'
import type { TopbarBadge } from '@/types/comfy'
@@ -17,16 +18,20 @@ const badges = computed<TopbarBadge[]>(() => {
tooltip: alert.tooltip
})
}
// Always add cloud badge last (furthest right)
result.push({
icon: 'icon-[lucide--cloud]',
text: 'Comfy Cloud'
})
return result
})
const canvasStore = useCanvasStore()
watch(
() => canvasStore.canvas,
(canvas) => {
if (canvas) {
canvas.info_text = t('g.comfyCloud')
}
},
{ immediate: true }
)
useExtensionService().registerExtension({
name: 'Comfy.Cloud.Badges',
get topbarBadges() {

View File

@@ -1,21 +1,14 @@
import { t } from '@/i18n'
import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackUrl } from '@/platform/support/config'
import { useExtensionService } from '@/services/extensionService'
import type { ActionBarButton } from '@/types/comfy'
const ZENDESK_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
const distribution = getDistribution()
const params = new URLSearchParams({
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
[ZENDESK_FIELDS.DISTRIBUTION]: distribution
})
const feedbackUrl = `${ZENDESK_BASE_URL}?${params.toString()}`
const feedbackUrl = buildFeedbackUrl()
const buttons: ActionBarButton[] = [
{
icon: 'icon-[lucide--message-circle-question-mark]',
icon: 'icon-[lucide--message-square-text]',
label: t('actionbar.feedback'),
tooltip: t('actionbar.feedbackTooltip'),
onClick: () => {
@@ -25,6 +18,10 @@ const buttons: ActionBarButton[] = [
]
useExtensionService().registerExtension({
name: 'Comfy.Cloud.FeedbackButton',
actionBarButtons: buttons
name: 'Comfy.FeedbackButton',
get actionBarButtons() {
return useSettingStore().get('Comfy.UI.TabBarLayout') === 'Legacy'
? buttons
: []
}
})

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

@@ -559,6 +559,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
clear_background_color: string
render_only_selected: boolean
show_info: boolean
/** Additional text appended to the canvas info overlay (rendered by {@link renderInfo}). */
info_text: string | undefined
allow_dragcanvas: boolean
allow_dragnodes: boolean
allow_interaction: boolean
@@ -5180,8 +5182,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* draws some useful stats in the corner of the canvas
*/
renderInfo(ctx: CanvasRenderingContext2D, x: number, y: number): void {
const lineHeight = 13
const lineCount = (this.graph ? 5 : 1) + (this.info_text ? 1 : 0)
x = x || 10
y = y || this.canvas.offsetHeight - 80
y = y || this.canvas.offsetHeight - (lineCount + 1) * lineHeight
ctx.save()
ctx.translate(x, y)
@@ -5189,18 +5193,26 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.font = `10px ${LiteGraph.DEFAULT_FONT}`
ctx.fillStyle = '#888'
ctx.textAlign = 'left'
let line = 1
if (this.graph) {
ctx.fillText(`T: ${this.graph.globaltime.toFixed(2)}s`, 5, 13 * 1)
ctx.fillText(`I: ${this.graph.iteration}`, 5, 13 * 2)
ctx.fillText(
`T: ${this.graph.globaltime.toFixed(2)}s`,
5,
lineHeight * line++
)
ctx.fillText(`I: ${this.graph.iteration}`, 5, lineHeight * line++)
ctx.fillText(
`N: ${this.graph._nodes.length} [${this.visible_nodes.length}]`,
5,
13 * 3
lineHeight * line++
)
ctx.fillText(`V: ${this.graph._version}`, 5, 13 * 4)
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, 13 * 5)
ctx.fillText(`V: ${this.graph._version}`, 5, lineHeight * line++)
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, lineHeight * line++)
} else {
ctx.fillText('No graph selected', 5, 13 * 1)
ctx.fillText('No graph selected', 5, lineHeight * line++)
}
if (this.info_text) {
ctx.fillText(this.info_text, 5, lineHeight * line++)
}
ctx.restore()
}

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

@@ -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 }
)
@@ -453,6 +705,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (!output) throw new Error('Subgraph output not found')
output.label = newName
this.graph?.trigger('node:slot-label:changed', {
nodeId: this.id,
slotType: NodeSlotType.OUTPUT
})
},
{ signal }
)
@@ -484,6 +740,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 +754,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 +795,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 +816,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 +884,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 +895,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
link: null
},
this
)
),
{
_subgraphSlot: slot
}
)
)
)
@@ -597,6 +924,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
override _internalConfigureAfterSlots() {
this._rebindInputSubgraphSlots()
// Ensure proxyWidgets is initialized so it serializes
this.properties.proxyWidgets ??= []
@@ -604,10 +933,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 +955,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 +968,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 +1032,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
inputWidget: IWidgetLocator | undefined,
interiorNode: LGraphNode
) {
this._invalidatePromotedViewsCache()
this._flushPendingPromotions()
const nodeId = String(interiorNode.id)
@@ -751,8 +1082,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 +1149,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 +1292,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 +1348,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 +1415,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()

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