Compare commits

...

57 Commits

Author SHA1 Message Date
Comfy Org PR Bot
765ae28c53 [backport cloud/1.41] [bugfix] Align advanced footer design with subgraph footer layout (#10012)
Backport of #9879 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10012-backport-cloud-1-41-bugfix-Align-advanced-footer-design-with-subgraph-footer-layout-3256d73d3650810e8f18ca77cfa409e0)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-16 02:12:15 -07:00
Comfy Org PR Bot
ab5e360391 [backport cloud/1.41] fix: tree explorer nodes not filling parent container width (#9982)
Backport of #9964 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9982-backport-cloud-1-41-fix-tree-explorer-nodes-not-filling-parent-container-width-3246d73d3650818cabdff63e1be3fea0)
by [Unito](https://www.unito.io)

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 02:11:37 -07:00
Comfy Org PR Bot
3d4d324019 [backport cloud/1.41] fix: add reve and elevenlabs to icon safelist (#9992)
Backport of #9990 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9992-backport-cloud-1-41-fix-add-reve-and-elevenlabs-to-icon-safelist-3256d73d3650814ab426c6c4d5e7eeaf)
by [Unito](https://www.unito.io)

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 02:11:30 -07:00
Comfy Org PR Bot
1829fe32da [backport cloud/1.41] fix: mask editor save shows blank image in Load Image node (#10019)
Backport of #9984 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10019-backport-cloud-1-41-fix-mask-editor-save-shows-blank-image-in-Load-Image-node-3256d73d36508178ae70e4889fbfd3f7)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:14:22 +09:00
Comfy Org PR Bot
0de694ce8d [backport cloud/1.41] fix: LGraphGroup paste position (#9971)
Backport of #9962 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Jukka Seppänen <40791699+kijai@users.noreply.github.com>
2026-03-15 18:54:51 +00:00
Comfy Org PR Bot
03d192e605 [backport cloud/1.41] fix: prevent live preview dimension flicker between frames (#9956)
Backport of #9937 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9956-backport-cloud-1-41-fix-prevent-live-preview-dimension-flicker-between-frames-3246d73d365081f19349cf0dba7d32c2)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-15 03:01:38 -07:00
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
242 changed files with 6869 additions and 1327 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,44 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('Pasted group is offset from original position', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
const titlePos = await comfyPage.page.evaluate(() => {
const app = window.app!
const group = app.graph.groups[0]
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
})
await comfyPage.canvas.click({ position: titlePos })
await comfyPage.nextFrame()
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await comfyPage.nextFrame()
const positions = await comfyPage.page.evaluate(() =>
window.app!.graph.groups.map((g: { pos: number[] }) => ({
x: g.pos[0],
y: g.pos[1]
}))
)
expect(positions).toHaveLength(2)
const dx = Math.abs(positions[0].x - positions[1].x)
const dy = Math.abs(positions[0].y - positions[1].y)
expect(dx).toBeCloseTo(50, 0)
expect(dy).toBeCloseTo(15, 0)
})
})

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

@@ -15,7 +15,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent}]");

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ElevenLabs</title><path d="M5 0h5v24H5V0zM14 0h5v24h-5V0z"></path></svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@@ -0,0 +1,10 @@
<svg width="182" height="148" viewBox="0 0 182 148" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2902_38097)">
<path d="M116 16H110V0H116V16ZM105 96H99V132H105V96ZM105 24H99V36H105V24ZM149 96H143V128H149V96ZM160 100H154V116H160V100ZM105 52H99V76H105V52ZM116 40H110V84H116V40ZM160 16H154V72H160V16ZM171 20H165V52H171V20ZM182 24H176V36H182V24ZM94 44H88V112H94V44ZM94 44H88V112H94V44ZM127 32H121V48H127V32ZM138 24H132V40H138V24ZM149 16H143V48H149V16ZM127 60H121V84H127V60ZM138 68H132V84H138V68ZM149 60H143V84H149V60ZM116 92H110V112H116V92ZM127 92H121V104H127V92ZM138 92H132V112H138V92ZM116 120H110V144H116V120ZM127 128H121V148H127V128ZM138 120H132V140H138V120ZM72 0H66V16H72V0ZM83 96H77V132H83V96ZM83 24H77V36H83V24ZM39 96H33V128H39V96ZM28 100H22V116H28V100ZM83 52H77V76H83V52ZM72 40H66V84H72V40ZM28 16H22V72H28V16ZM17 20H11V52H17V20ZM6 24H0V36H6V24ZM61 32H55V48H61V32ZM50 24H44V40H50V24ZM39 16H33V48H39V16ZM61 60H55V84H61V60ZM50 68H44V84H50V68ZM39 60H33V84H39V60ZM72 92H66V112H72V92ZM61 92H55V104H61V92ZM50 92H44V112H50V92ZM72 120H66V144H72V120ZM61 128H55V148H61V128ZM50 120H44V140H50V120Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_2902_38097">
<rect width="182" height="148" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

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

@@ -8,7 +8,7 @@
:get-children="
(item) => (item.children?.length ? item.children : undefined)
"
class="m-0 p-0 pb-6"
class="m-0 min-w-0 p-0 pb-6"
>
<TreeVirtualizer
v-slot="{ item }"

View File

@@ -99,7 +99,7 @@ import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
const ROW_CLASS =
'group/tree-node flex cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'
const { item } = defineProps<{
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>

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

@@ -0,0 +1,183 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useMaskEditorSaver } from './useMaskEditorSaver'
// ---- Module Mocks ----
const mockDataStore: Record<string, unknown> = {
sourceNode: null,
inputData: null,
outputData: null
}
vi.mock('@/stores/maskEditorDataStore', () => ({
useMaskEditorDataStore: vi.fn(() => mockDataStore)
}))
function createMockCtx(): CanvasRenderingContext2D {
return {
drawImage: vi.fn(),
getImageData: vi.fn(() => ({
data: new Uint8ClampedArray(4 * 4 * 4),
width: 4,
height: 4
})),
putImageData: vi.fn(),
globalCompositeOperation: 'source-over'
} as unknown as CanvasRenderingContext2D
}
function createMockCanvas(): HTMLCanvasElement {
return {
width: 4,
height: 4,
getContext: vi.fn(() => createMockCtx()),
toBlob: vi.fn((cb: BlobCallback) => {
cb(new Blob(['x'], { type: 'image/png' }))
}),
toDataURL: vi.fn(() => 'data:image/png;base64,mock')
} as unknown as HTMLCanvasElement
}
const mockEditorStore: Record<string, HTMLCanvasElement | null> = {
maskCanvas: null,
rgbCanvas: null,
imgCanvas: null
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockEditorStore)
}))
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
apiURL: vi.fn((route: string) => `http://localhost:8188${route}`)
}
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: { setDirty: vi.fn() },
nodeOutputs: {} as Record<string, unknown>,
nodePreviewImages: {} as Record<string, string[]>,
getPreviewFormatParam: vi.fn(() => ''),
getRandParam: vi.fn(() => '')
}
}))
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
nodeIdToNodeLocatorId: vi.fn((id: string | number) => String(id)),
nodeToNodeLocatorId: vi.fn((node: { id: number }) => String(node.id))
}))
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: vi.fn((_rootGraph: unknown, id: string) => id)
}))
describe('useMaskEditorSaver', () => {
let mockNode: LGraphNode
const originalCreateElement = document.createElement.bind(document)
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
mockNode = {
id: 42,
type: 'LoadImage',
images: [],
imgs: undefined,
widgets: [
{ name: 'image', value: 'original.png [input]', callback: vi.fn() }
],
widgets_values: ['original.png [input]'],
properties: { image: 'original.png [input]' },
graph: { setDirtyCanvas: vi.fn() }
} as unknown as LGraphNode
mockDataStore.sourceNode = mockNode
mockDataStore.inputData = {
baseLayer: { image: {} as HTMLImageElement, url: 'base.png' },
maskLayer: { image: {} as HTMLImageElement, url: 'mask.png' },
sourceRef: { filename: 'original.png', subfolder: '', type: 'input' },
nodeId: 42
}
mockDataStore.outputData = null
mockEditorStore.maskCanvas = createMockCanvas()
mockEditorStore.rgbCanvas = createMockCanvas()
mockEditorStore.imgCanvas = createMockCanvas()
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
name: 'clipspace-painted-masked-123.png',
subfolder: 'clipspace',
type: 'input'
})
} as Response)
vi.spyOn(document, 'createElement').mockImplementation(
(tagName: string, options?: ElementCreationOptions) => {
if (tagName === 'canvas')
return createMockCanvas() as unknown as HTMLCanvasElement
return originalCreateElement(tagName, options)
}
)
// Mock Image constructor so loadImageFromUrl resolves
vi.stubGlobal(
'Image',
class MockImage {
crossOrigin = ''
onload: ((ev: Event) => void) | null = null
onerror: ((ev: unknown) => void) | null = null
private _src = ''
get src() {
return this._src
}
set src(value: string) {
this._src = value
queueMicrotask(() => this.onload?.(new Event('load')))
}
}
)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('registers node outputs in store after save for node without prior execution outputs', async () => {
const store = useNodeOutputStore()
const locatorId = String(mockNode.id)
// Precondition: node has never been executed, no outputs exist
expect(app.nodeOutputs[locatorId]).toBeUndefined()
const { save } = useMaskEditorSaver()
await save()
// After mask editor save, the node must have outputs in the store
// so the image preview displays correctly (not blank).
// Bug: the old code used updateNodeImages which silently no-ops
// when there are no pre-existing outputs for the node.
expect(store.nodeOutputs[locatorId]).toBeDefined()
expect(store.nodeOutputs[locatorId]?.images?.length).toBeGreaterThan(0)
})
})

View File

@@ -9,6 +9,7 @@ import type {
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
// Private layer filename functions
@@ -347,11 +348,15 @@ export function useMaskEditorSaver() {
node.widgets_values[widgetIndex] = widgetValue
}
}
imageWidget.callback?.(widgetValue)
}
nodeOutputStore.updateNodeImages(node)
node.imgs = undefined
const annotatedPath = createAnnotatedPath(mainRef.filename, {
subfolder: mainRef.subfolder,
rootFolder: mainRef.type
})
nodeOutputStore.setNodeOutputs(node, annotatedPath, { folder: 'input' })
node.graph?.setDirtyCanvas(true)
}
function loadImageFromUrl(url: string): Promise<HTMLImageElement> {

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
: []
}
})

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