Compare commits

..

135 Commits

Author SHA1 Message Date
Christian Byrne
cae988effe [backport cloud/1.41] fix: tree explorer row height and width overflow (#10501) (#10924)
Backport of #10501. Clean cherry-pick.

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-06 15:30:04 -07:00
Christian Byrne
ad2612009e [backport cloud/1.41] fix: handle clipboard errors in Copy Image and useCopyToClipboard (#9299) (#10923)
Backport of #9299. Clean cherry-pick.
2026-04-06 15:29:25 -07:00
Christian Byrne
54e488ad7f [backport cloud/1.41] fix: keep job details popover on-screen in sidebar (#9679) (#10922)
Backport of #9679 with useJobDetailsHover composable. Used correct file
versions from merge commit.

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-04-06 15:28:41 -07:00
Arthur R Longbottom
662e5368c1 [backport cloud/1.41] fix: subgraph output slot labels not updating in v2 renderer (#10728)
Backport of #9266 to cloud/1.41.

Cherry-pick of merge commit 5640eb7 with conflict resolution in:
- `useGraphNodeManager.ts` — kept target's `extractVueNodeData` re-call
- `SubgraphNode.ts` — kept target's comment and
`_invalidatePromotedViewsCache`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10728-backport-cloud-1-41-fix-subgraph-output-slot-labels-not-updating-in-v2-renderer-3336d73d365081e587bff11b35972f91)
by [Unito](https://www.unito.io)
2026-03-30 17:03:33 -07:00
jaeone94
5851925937 [backport cloud/1.41] fix: prevent saving active workflow content to inactive tab on close (#10755)
Backport of #10745 to cloud/1.41.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10755-backport-cloud-1-41-fix-prevent-saving-active-workflow-content-to-inactive-tab-on-clo-3336d73d365081849837df818e91b09e)
by [Unito](https://www.unito.io)
2026-03-30 13:03:16 -07:00
Christian Byrne
8983fdd49d [backport cloud/1.41] feat: add SHA-256 hashed email to GTM dataLayer for sign_up/login events (#10637)
Backport of #10591 to `cloud/1.41`

Conflict resolution: adapted the two new email-hashing tests to use the
cloud/1.41 test style (direct `new GtmTelemetryProvider()` with inline
config) since the helper functions (`createInitializedProvider`,
`lastDataLayerEntry`) from #9770 were never backported to this branch.

All 6 tests pass locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10637-backport-cloud-1-41-feat-add-SHA-256-hashed-email-to-GTM-dataLayer-for-sign_up-login--3316d73d365081638938f3a5653d972e)
by [Unito](https://www.unito.io)
2026-03-27 19:40:19 -07:00
Comfy Org PR Bot
6869b5c6db [backport cloud/1.41] fix: hide inaccurate resolution subtitle on cloud asset cards (#10605)
Backport of #10602 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10605-backport-cloud-1-41-fix-hide-inaccurate-resolution-subtitle-on-cloud-asset-cards-3306d73d365081178dd0e4a84fc12524)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-26 22:06:59 -07:00
Christian Byrne
e9e3d6d10f [backport cloud/1.41] feat: fire subscription_success telemetry on subscription activation (#10186) (#10593)
Backport of #10186 to cloud/1.41.

Fires a client-side `subscription_success` event to GTM when a
subscription activates, enabling LinkedIn and Meta conversion tracking
tags.

Cherry-picked from merge commit 6c14802.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10593-backport-cloud-1-41-feat-fire-subscription_success-telemetry-on-subscription-activati-3306d73d3650810ea583ce3ae437e199)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-03-26 19:45:29 -07:00
Comfy Org PR Bot
9632e33e39 [backport cloud/1.41] fix: normalize legacy prefixed proxyWidget entries on configure (#10577)
Backport of #10573 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10577-backport-cloud-1-41-fix-normalize-legacy-prefixed-proxyWidget-entries-on-configure-32f6d73d36508179a1e2e2345b307511)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 23:00:29 +00:00
Comfy Org PR Bot
db3801f21b [backport cloud/1.41] fix: create initial workflow tab when persistence is disabled (#10567)
Backport of #10565 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10567-backport-cloud-1-41-fix-create-initial-workflow-tab-when-persistence-is-disabled-32f6d73d365081d9ac10f454fc143ce2)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-26 11:50:06 -07:00
Comfy Org PR Bot
af9bb6c222 [backport cloud/1.41] fix: repoint ancestor promoted widget bindings when packing nested subgraphs (#10557)
Backport of #10532 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10557-backport-cloud-1-41-fix-repoint-ancestor-promoted-widget-bindings-when-packing-nested-32f6d73d365081968360e2936d7762d1)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: Yourz <crazilou@vip.qq.com>
2026-03-26 05:51:30 +00:00
Comfy Org PR Bot
46be292a3d [backport cloud/1.41] fix: App mode - workaround for alt+m producing alt+μ on mac (#10536)
Backport of #10528 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10536-backport-cloud-1-41-fix-App-mode-workaround-for-alt-m-producing-alt-on-mac-32e6d73d365081369e92c8acd0432d6f)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-25 17:13:29 -07:00
pythongosssss
fdd68d4e04 [backport cloud/1.41] fix: App mode - renaming widgets on subgraphs (#10245) (#10507)
## Summary
- Cherry-pick of #10245 onto `cloud/1.41`
- Fixes App mode widget renaming on subgraph nodes by resolving promoted
widget sources without requiring explicit parents

## Test plan
- [ ] Unit tests pass (`pnpm test:unit`)
- [ ] E2E `appModeWidgetRename` tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10507-backport-cloud-1-41-fix-App-mode-renaming-widgets-on-subgraphs-10245-32e6d73d365081059663ce4e9431f849)
by [Unito](https://www.unito.io)
2026-03-25 14:00:53 -07:00
Comfy Org PR Bot
3233ea001c [backport cloud/1.41] fix: prune stale proxyWidgets referencing nodes removed by nested subgraph packing (#10519)
Backport of #10390 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10519-backport-cloud-1-41-fix-prune-stale-proxyWidgets-referencing-nodes-removed-by-nested--32e6d73d365081ac8e6cc9f1402c2282)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-25 19:23:39 +00:00
Comfy Org PR Bot
617e2cd259 [backport cloud/1.41] 🧙 feat: wire ComfyHub publish wizard with profile gate, asset upload, and submission (#10491)
Backport of #10128 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10491-backport-cloud-1-41-feat-wire-ComfyHub-publish-wizard-with-profile-gate-asset-upl-32e6d73d3650819f8cb0d995d45fb06e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-25 10:22:20 +09:00
Comfy Org PR Bot
2e1977dc7d [backport cloud/1.41] fix: 3D asset disappears when switching to image output in app mode (#10231)
Backport of #9622 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10231-backport-cloud-1-41-fix-3D-asset-disappears-when-switching-to-image-output-in-app-mod-3276d73d3650819da889e3c1c24aa254)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-24 17:24:08 -07:00
Arthur R Longbottom
0fa937205c [backport cloud/1.41] fix: subgraph promoted widget input label rename (#10422)
Backport of #10195 to cloud/1.41.

Cherry-picked from the core/1.41 backport commit with the same test fix:
`renameWidget` test passes `parents` explicitly since 1.41 uses
`parents?.length` (not `node.isSubgraphNode()` fallback). Includes
negative test for subgraph node without parents.

All 14 unit tests pass locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10422-backport-cloud-1-41-fix-subgraph-promoted-widget-input-label-rename-32d6d73d36508133bdd1d37ebd2e542e)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-24 17:23:20 -07:00
Comfy Org PR Bot
925a5d2d63 [backport cloud/1.41] fix: restore Firebase getAdditionalUserInfo for sign-up telemetry OR logic (#10467)
Backport of #10453 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10467-backport-cloud-1-41-fix-restore-Firebase-getAdditionalUserInfo-for-sign-up-telemetry--32d6d73d365081daa1fff97d13f29266)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-24 17:12:36 -07:00
Comfy Org PR Bot
6d36f83a83 [backport cloud/1.41] feat: App mode - add lightbox to view image in drop zone (#10447)
Backport of #9888 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10447-backport-cloud-1-41-feat-App-mode-add-lightbox-to-view-image-in-drop-zone-32d6d73d3650810bad05f6863346e74b)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-24 08:40:13 -07:00
Comfy Org PR Bot
82d92a6ea0 [backport cloud/1.41] feat: App mode - enable mask editor (#10442)
Backport of #9876 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10442-backport-cloud-1-41-feat-App-mode-enable-mask-editor-32d6d73d365081828f69fb510adef7ad)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-24 08:28:45 -07:00
Comfy Org PR Bot
323d457958 [backport cloud/1.41] fix: restore workflow tabs on browser restart (#10435)
Backport of #10336 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10435-backport-cloud-1-41-fix-restore-workflow-tabs-on-browser-restart-32d6d73d36508121b463c40c1ac8d87e)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-24 08:38:56 +00:00
Comfy Org PR Bot
40d3b16133 [backport cloud/1.41] fix: sync advanced inputs button color with node header (#10429)
Backport of #10427 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10429-backport-cloud-1-41-fix-sync-advanced-inputs-button-color-with-node-header-32d6d73d365081eea462d909ea863ea4)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-24 16:09:43 +09:00
Hunter
df3fb6ca2a [backport cloud/1.41] feat: differentiate personal/team pricing table with two-stage team workspace flow (#9901) (#10401)
Backport of #9901 to cloud/1.41.

Cherry-picked merge commit cd45efa983.

**Conflict resolution:**
-
`src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts`:
modify/delete — file was new in the PR, kept PR version (git add).
-
`src/platform/cloud/subscription/composables/useSubscriptionDialog.ts`:
auto-merge dropped the `isCloud` import and early-return guard; restored
both to match the main branch version.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10401-backport-cloud-1-41-feat-differentiate-personal-team-pricing-table-with-two-stage-tea-32c6d73d365081328accfa6e0a410219)
by [Unito](https://www.unito.io)
2026-03-23 19:22:49 -04:00
Comfy Org PR Bot
b0da48e168 [backport cloud/1.41] fix: use UI flow context for sign_up vs login telemetry (#10399)
Backport of #10388 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10399-backport-cloud-1-41-fix-use-UI-flow-context-for-sign_up-vs-login-telemetry-32c6d73d3650810e89afccd7e6b85a3b)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-23 10:41:59 -07:00
Comfy Org PR Bot
9f470b22c0 [backport cloud/1.41] feat: App mode - allow resizing of textarea and image previews (#10345)
Backport of #9792 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10345-backport-cloud-1-41-feat-App-mode-allow-resizing-of-textarea-and-image-previews-3296d73d36508173be60e0fc10292292)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-20 10:11:07 -07:00
Comfy Org PR Bot
80cc488e55 [backport cloud/1.41] feat: add DropZone Storybook coverage for file upload states (#10340)
Backport of #9690 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10340-backport-cloud-1-41-feat-add-DropZone-Storybook-coverage-for-file-upload-states-3296d73d365081a1b8fdc7973af2424e)
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-20 09:37:53 -07:00
Comfy Org PR Bot
ac8f75c8a7 [backport cloud/1.41] refactor: Unify app builder & app widget lists (#10335)
Backport of #9829 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10335-backport-cloud-1-41-refactor-Unify-app-builder-app-widget-lists-3296d73d3650814cb17ae9b652156de3)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-20 03:50:43 -07:00
Alexander Brown
19a31f2204 [backport cloud/1.41] test: subgraph integration contracts and expanded Playwright coverage (#10328)
Backport of #10123, #9967, and #9972 to `cloud/1.41`

Includes three cherry-picks in dependency order:
1. #9972 — `fix: resolve all lint warnings` (clean)
2. #9967 — `test: harden subgraph test coverage and remove low-value
tests` (clean)
3. #10123 — `test: subgraph integration contracts and expanded
Playwright coverage` (2 conflicts, auto-resolved by rerere from #10326)

See #10326 for core/1.41 backport with detailed conflict resolution
notes.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-19 18:54:38 -07:00
Comfy Org PR Bot
72ab4d5ec9 [backport cloud/1.41] fix: configure nested subgraph definitions in dependency order (#10316)
Backport of #10314 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-19 15:46:29 -07:00
Comfy Org PR Bot
7883f03196 [backport cloud/1.41] fix: _removeDuplicateLinks incorrectly removes valid link when slot indices shift (#10298)
Backport of #10289 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10298-backport-cloud-1-41-fix-_removeDuplicateLinks-incorrectly-removes-valid-link-when-slo-3286d73d365081c88411efa19a1cce41)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-19 15:54:55 +09:00
Comfy Org PR Bot
5a9c7980d6 [backport cloud/1.41] fix: prevent nested SubgraphNode input slots from doubling on reload (#10280)
Backport of #10187 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10280-backport-cloud-1-41-fix-prevent-nested-SubgraphNode-input-slots-from-doubling-on-relo-3276d73d36508130855ec81c1e96a50a)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-19 09:24:39 +09:00
Comfy Org PR Bot
2b25755e4f [backport cloud/1.41] fix: resync slot layouts when switching between app mode and graph mode (#10281)
Backport of #10273 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10281-backport-cloud-1-41-fix-resync-slot-layouts-when-switching-between-app-mode-and-graph-3276d73d365081b5bd7be61796c397a2)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 17:02:22 -07:00
Comfy Org PR Bot
3b0cbd4035 [backport cloud/1.41] feat: App mode - update keybindings (#10249)
Backport of #9794 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10249-backport-cloud-1-41-feat-App-mode-update-keybindings-3276d73d3650814d9ef6ce9431679045)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-18 14:34:37 -07:00
Comfy Org PR Bot
c954ee1a77 [backport cloud/1.41] fix: App mode - handle socket/response race when tracking jobs (#10266)
Backport of #10244 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10266-backport-cloud-1-41-fix-App-mode-handle-socket-response-race-when-tracking-jobs-3276d73d36508180b559c6dea9b0457c)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-18 14:34:30 -07:00
Comfy Org PR Bot
fc15e6a329 [backport cloud/1.41] fix: resync vue node layout store after legacy normalization (#10262)
Backport of #10256 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10262-backport-cloud-1-41-fix-resync-vue-node-layout-store-after-legacy-normalization-3276d73d365081da8d9eed872ec34f2f)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 13:30:04 -07:00
Alexander Brown
5f99f7bdba fix: resolve cloud/1.41 unit test regressions (#10269)
## Summary

Fixes three cloud/1.41 test regressions by restoring the intended legacy
topbar mutation scheduling behavior, re-enabling multi-output test
mocking in assetsStore tests, and backporting execution ID locator
caching in executionStore.

## Changes

- **What**: Reintroduced RAF-coalesced legacy topbar content checks in
`TopMenuSection`, restored `mockOutputOverrides` handling in
`assetsStore.test.ts`, and added cached execution-id-to-locator
resolution in `executionStore` with cache resets at execution
boundaries.

## Review Focus

Confirm the cache lifecycle boundaries in `executionStore`
(`handleExecutionStart` and `resetExecutionState`) and that the
`TopMenuSection` mutation observer now matches the coalescing contract
expected by unit tests.

Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 13:20:55 -07:00
Comfy Org PR Bot
6bb46d688f [backport cloud/1.41] fix: track nodePreviewImages in usePromotedPreviews (#10198)
Backport of #10165 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10198-backport-cloud-1-41-fix-track-nodePreviewImages-in-usePromotedPreviews-3266d73d365081e3b176ffad3b061dd8)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-17 14:39:13 -07:00
Comfy Org PR Bot
2fb463a200 [backport cloud/1.41] fix: replace stale-request guard with single-flight coalescing in queueStore.update() (#10216)
Backport of #10203 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10216-backport-cloud-1-41-fix-replace-stale-request-guard-with-single-flight-coalescing-in--3266d73d365081329667e34fac063cbe)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-17 14:37:34 -07:00
Comfy Org PR Bot
8c54bfd9a9 [backport cloud/1.41] fix: enable 3D thumbnail support for cloud environments (#10206)
Backport of #10121 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10206-backport-cloud-1-41-fix-enable-3D-thumbnail-support-for-cloud-environments-3266d73d365081bb822efce5409352af)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-17 13:52:08 -07:00
Comfy Org PR Bot
72547cdc9d [backport cloud/1.41] feat: resolveVirtualOutput for cross-subgraph virtual nodes (eg. Set/Get) (#10180)
Backport of #10111 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10180-backport-cloud-1-41-feat-resolveVirtualOutput-for-cross-subgraph-virtual-nodes-eg-S-3266d73d365081fa9f89d8622b4aac9c)
by [Unito](https://www.unito.io)

Co-authored-by: Jukka Seppänen <40791699+kijai@users.noreply.github.com>
2026-03-17 07:19:08 -07:00
Christian Byrne
a73d31e538 [backport cloud/1.41] feat: add linear interpolation type to CURVE widget (#10166)
Backport of #10118 to cloud/1.41.

Cherry-pick of 46d8567f10.

## Original PR
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10118

## Summary
Change the CURVE widget value from CurvePoint[] to CurveData ({ points,
interpolation }) to support multiple interpolation types. Add a Select
dropdown in the widget UI for switching between Smooth (monotone cubic)
and Linear interpolation, with the SVG preview updating accordingly.

## Conflict Resolution
- CurveEditor.vue, WidgetCurve.vue, curveUtils.ts: Took main version
which merges both #9896 (disabled/upstream) and #10118 (interpolation)
features
- locales/en/main.json: Auto-merged cleanly
- package.json: Kept target branch version

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10166-backport-cloud-1-41-feat-add-linear-interpolation-type-to-CURVE-widget-3266d73d365081e8ba22c73c1590eb09)
by [Unito](https://www.unito.io)
2026-03-17 03:04:25 -07:00
Christian Byrne
855c4d74b6 [backport cloud/1.41] feat: improve essentials tab blueprint support and display names (#10161)
Backport of #10113 to `cloud/1.41`

Cherry-picked merge commit d6c1dd2e59.
Resolved import conflict in `NodeLibrarySidebarTabV2.vue` — kept
cloud/1.41's `SearchBox` component import, added new
`resolveBlueprintSuffix` import from the PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10161-backport-cloud-1-41-feat-improve-essentials-tab-blueprint-support-and-display-names-3266d73d3650818ba12af2ac5631b4a6)
by [Unito](https://www.unito.io)

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-17 01:49:27 -07:00
Comfy Org PR Bot
ecdca891c0 [backport cloud/1.41] fix: prune orphaned SubgraphNode inputs after configure (#10148)
Backport of #10020 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10148-backport-cloud-1-41-fix-prune-orphaned-SubgraphNode-inputs-after-configure-3266d73d36508122b6ffd63a32397750)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-17 01:25:00 -07:00
Comfy Org PR Bot
f151941eec [backport cloud/1.41] Feat/3d thumbnail inline rendering (#10044)
Backport of #9471 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10044-backport-cloud-1-41-Feat-3d-thumbnail-inline-rendering-3256d73d3650816fa383cc675152d920)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-16 06:46:42 -07:00
Christian Byrne
591c43f2f5 [cloud/1.41] fix: revert incorrectly backported code from #9908 and #10024 (#10101)
Fix typecheck failures caused by backport conflict resolutions that
brought in code depending on features not present on 1.41 branch.
2026-03-16 06:28:17 -07:00
Christian Byrne
93b7b7305a [backport cloud/1.41] fix: inline splash CSS to prevent SPA fallback breakage on cloud environments (#9849) (#10092)
Backport of #9849 to cloud/1.41

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 06:17:43 -07:00
Christian Byrne
0024cc6ff7 [backport cloud/1.41] fix: allow URL input for free tier users, gate on import button (#10024) (#10091)
Backport of #10024 to cloud/1.41

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-16 06:16:59 -07:00
Christian Byrne
21f23b95b8 [backport cloud/1.41] fix: address PR review feedback for upstream value composable (#9908) (#10090)
Backport of #9908 to cloud/1.41

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-16 06:16:28 -07:00
Christian Byrne
76e5620109 [backport cloud/1.41] fix: default frontend preview variant to cpu (#9718) (#10089)
Backport of #9718 to cloud/1.41

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-03-16 06:15:52 -07:00
Christian Byrne
2cba8ff108 [backport cloud/1.41] fix: make zPreviewOutput accept text-only job outputs (#9724) (#10088)
Backport of #9724 to cloud/1.41

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
2026-03-16 06:15:20 -07:00
Christian Byrne
2cb1f580b1 [backport cloud/1.41] fix: hide empty actionbar container and relocate error border to floating actionbar (#9657) (#10087)
Backport of #9657 to cloud/1.41

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-16 06:13:53 -07:00
Christian Byrne
63a2a36b3f [backport cloud/1.41] fix: center ComfyUI logo in sidebar menu button with chevron (#9722) (#10086)
Backport of #9722 to cloud/1.41

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-16 06:13:12 -07:00
Comfy Org PR Bot
6f2eb0ebea [backport cloud/1.41] fix: prevent WebGLRenderer leak in app mode 3D preview (#10084)
Backport of #9766 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-16 06:09:25 -07:00
Comfy Org PR Bot
65e9e79313 [backport cloud/1.41] fix: cloud login page stuck on splash loader for unauthenticated users (#10083)
Backport of #9725 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 06:09:18 -07:00
Comfy Org PR Bot
b4022e1de6 [backport cloud/1.41] fix: cloud subscribe redirect hangs waiting for billing init (#10082)
Backport of #9965 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 06:09:09 -07:00
Comfy Org PR Bot
ff2e7c47ad [backport cloud/1.41] fix(maskeditor): make brush size slider logarithmic (#8097) (#10071)
Backport of #9534 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10071-backport-cloud-1-41-fix-maskeditor-make-brush-size-slider-logarithmic-8097-3256d73d3650813f83c6f0e2d81a185d)
by [Unito](https://www.unito.io)

Co-authored-by: Kelly Yang <124ykl@gmail.com>
2026-03-16 06:09:01 -07:00
Comfy Org PR Bot
ec5527123c [backport cloud/1.41] fix: rename docked queue panel setting (#10070)
Backport of #9620 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10070-backport-cloud-1-41-fix-rename-docked-queue-panel-setting-3256d73d3650819b96c0ec6afe0a6266)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-03-16 06:08:54 -07:00
Comfy Org PR Bot
a8fc9d67a7 [backport cloud/1.41] fix: update WidgetLayoutField border styling (#10069)
Backport of #9456 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10069-backport-cloud-1-41-fix-update-WidgetLayoutField-border-styling-3256d73d3650817f9f12cffc7ed4dcf9)
by [Unito](https://www.unito.io)

Co-authored-by: Kelly Yang <124ykl@gmail.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-16 06:08:46 -07:00
Comfy Org PR Bot
f8191837ec [backport cloud/1.41] fix: respect 'always snap to grid' when auto-scale layout from nodes 1.0 to 2.0 (#10068)
Backport of #9332 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10068-backport-cloud-1-41-fix-respect-always-snap-to-grid-when-auto-scale-layout-from-nod-3256d73d365081f79234d019c2a238b2)
by [Unito](https://www.unito.io)

Co-authored-by: woctordho <woctordho@outlook.com>
2026-03-16 06:08:38 -07:00
Comfy Org PR Bot
a0f0b27eee [backport cloud/1.41] fix: show most recent image first in asset sidebar batch view (#10067)
Backport of #9467 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10067-backport-cloud-1-41-fix-show-most-recent-image-first-in-asset-sidebar-batch-view-3256d73d365081c595ece0f7e9370619)
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-16 06:08:29 -07:00
Comfy Org PR Bot
f9efc35abd [backport cloud/1.41] fix: improve canvas menu keyboard navigation and ARIA accessibility (#10066)
Backport of #9526 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10066-backport-cloud-1-41-fix-improve-canvas-menu-keyboard-navigation-and-ARIA-accessibilit-3256d73d365081259b03ec81725efd75)
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-16 06:08:21 -07:00
Comfy Org PR Bot
464ec47c5e [backport cloud/1.41] fix: show correct empty state on Missing tab instead of misleading registry error (#10065)
Backport of #9640 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10065-backport-cloud-1-41-fix-show-correct-empty-state-on-Missing-tab-instead-of-misleading-3256d73d3650819aae75f6244541b266)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-16 06:08:13 -07:00
Comfy Org PR Bot
64fcbeee29 [backport cloud/1.41] fix: virtual scroll pagination not working in media asset list view (#10064)
Backport of #9646 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10064-backport-cloud-1-41-fix-virtual-scroll-pagination-not-working-in-media-asset-list-vie-3256d73d365081c2b5e2d3f2a6bafb09)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-16 06:08:05 -07:00
Comfy Org PR Bot
89aae59a4f [backport cloud/1.41] fix: add text color and increase size for nav badge count (#10063)
Backport of #9713 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10063-backport-cloud-1-41-fix-add-text-color-and-increase-size-for-nav-badge-count-3256d73d3650812d9777d5cbe66781a3)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 06:07:57 -07:00
Comfy Org PR Bot
83d1aebc21 [backport cloud/1.41] fix: prevent HoneyToast from collapsing to minimum width in collapsed state (#10062)
Backport of #9701 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10062-backport-cloud-1-41-fix-prevent-HoneyToast-from-collapsing-to-minimum-width-in-collap-3256d73d365081fba15bd2475ac88089)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-16 06:07:49 -07:00
Comfy Org PR Bot
dc64fc2fe1 [backport cloud/1.41] fix: use order-independent tag matching in asset browser categories (#10061)
Backport of #9843 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10061-backport-cloud-1-41-fix-use-order-independent-tag-matching-in-asset-browser-categorie-3256d73d36508153be6cc940ec943795)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 06:07:41 -07:00
Christian Byrne
7918a79d61 [backport cloud/1.41] fix: return undefined for muted node output resolution (#9302) (#10055)
Backport of #9302 to cloud/1.41
2026-03-16 05:58:05 -07:00
Christian Byrne
4ee2bbd65f [backport cloud/1.41] fix: cap nodeProgressStatesByJob to prevent unbounded memory growth (#9249) (#10054)
Backport of #9249 to cloud/1.41

Co-authored-by: John Haugeland <stonecypher@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-16 05:57:44 -07:00
Christian Byrne
196a171893 [backport cloud/1.41] fix: subgraph output slot labels not updating in v2 renderer (#9266) (#10053)
Backport of #9266 to cloud/1.41

Co-authored-by: Arthur R Longbottom <art.longbottom.jr@gmail.com>
2026-03-16 05:57:08 -07:00
Christian Byrne
a3c9d3e84a [backport cloud/1.41] fix: add isGraphReady guard to prevent premature graph access error logs (#9672) (#10052)
Backport of #9672 to cloud/1.41

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-16 05:56:29 -07:00
Christian Byrne
a6c99423c8 [backport cloud/1.41] fix: simplify ensureCorrectLayoutScale and fix link sync during Vue node drag (#9680) (#10051)
Backport of #9680 to cloud/1.41

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-16 05:56:04 -07:00
Christian Byrne
a1634cedea [backport cloud/1.41] fix: clear stale progress bar on SubgraphNode after navigation (#9865) (#10050)
Backport of #9865 to cloud/1.41
2026-03-16 05:55:32 -07:00
Comfy Org PR Bot
7fe1c1e8c5 [backport cloud/1.41] fix: prevent middle-click paste duplicating workflow on Linux (#10040)
Backport of #8259 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10040-backport-cloud-1-41-fix-prevent-middle-click-paste-duplicating-workflow-on-Linux-3256d73d36508136884fd82505fb37d9)
by [Unito](https://www.unito.io)

Co-authored-by: Gregorius Bima Kharisma Wicaksana <51526537+bimakw@users.noreply.github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-16 05:53:43 -07:00
Comfy Org PR Bot
876592fe9b [backport cloud/1.41] fix: detect and remove duplicate links in subgraph unpacking (#10039)
Backport of #9120 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10039-backport-cloud-1-41-fix-detect-and-remove-duplicate-links-in-subgraph-unpacking-3256d73d365081aabd35e07f65a537b5)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-16 05:53:35 -07:00
Comfy Org PR Bot
da1d915898 [backport cloud/1.41] fix: detect missing nodes when registry API fails to resolve packs (#10038)
Backport of #9697 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10038-backport-cloud-1-41-fix-detect-missing-nodes-when-registry-API-fails-to-resolve-packs-3256d73d36508143a512c580163a4f4a)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-16 05:53:27 -07:00
Comfy Org PR Bot
2d93372e67 [backport cloud/1.41] fix: load API format workflows with missing node types (#10037)
Backport of #9694 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10037-backport-cloud-1-41-fix-load-API-format-workflows-with-missing-node-types-3256d73d3650811782fad8b489bcf253)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-16 05:53:19 -07:00
Comfy Org PR Bot
67432c5db5 [backport cloud/1.41] fix: prevent white flash when opening mask editor (#10036)
Backport of #9860 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10036-backport-cloud-1-41-fix-prevent-white-flash-when-opening-mask-editor-3256d73d36508141a578eaa4b14645d3)
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-16 05:53:11 -07:00
Comfy Org PR Bot
9c93f11abe [backport cloud/1.41] fix: prevent animated preview duplication on Vue↔Litegraph switch (#10031)
Backport of #9938 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10031-backport-cloud-1-41-fix-prevent-animated-preview-duplication-on-Vue-Litegraph-switch-3256d73d3650812882a3cd7ef0f121c1)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-16 05:48:32 -07:00
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
158 changed files with 6023 additions and 1505 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
global.d.ts vendored
View File

@@ -36,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

@@ -47,6 +47,60 @@
border: 0;
}
</style>
<style>
/* Pre-Vue splash loader — inlined to avoid SPA fallback serving
index.html instead of CSS on cloud/ephemeral environments */
#splash-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
contain: strict;
}
#splash-loader svg {
width: min(200px, 50vw);
height: auto;
transform: translateZ(0);
}
#splash-loader .wave-group {
animation: splash-rise 4s ease-in-out infinite alternate;
will-change: transform;
transform: translateZ(0);
}
#splash-loader .wave-path {
animation: splash-wave 1.2s linear infinite;
will-change: transform;
transform: translateZ(0);
}
@keyframes splash-rise {
from {
transform: translateY(280px);
}
to {
transform: translateY(-80px);
}
}
@keyframes splash-wave {
from {
transform: translateX(0);
}
to {
transform: translateX(-880px);
}
}
@media (prefers-reduced-motion: reduce) {
#splash-loader .wave-group,
#splash-loader .wave-path {
animation: none;
}
#splash-loader .wave-group {
transform: translateY(-80px);
}
}
</style>
<link rel="manifest" href="manifest.json" />
</head>

View File

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

View File

@@ -13,19 +13,18 @@
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, onUnmounted } from 'vue'
import { computed, onMounted } from 'vue'
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { electronAPI } from '@/utils/envUtil'
import { isDesktop } from '@/platform/distribution/types'
import { app } from '@/scripts/app'
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
@@ -66,26 +65,5 @@ onMounted(() => {
// Initialize conflict detection in background
// This runs async and doesn't block UI setup
void conflictDetection.initializeConflictDetection()
// Show cloud notification for macOS desktop users (one-time)
if (isDesktop && electronAPI()?.getPlatform() === 'darwin') {
const settingStore = useSettingStore()
if (!settingStore.get('Comfy.Desktop.CloudNotificationShown')) {
const dialogService = useDialogService()
cloudNotificationTimer = setTimeout(async () => {
try {
await dialogService.showCloudNotification()
} catch (e) {
console.warn('[CloudNotification] Failed to show', e)
}
await settingStore.set('Comfy.Desktop.CloudNotificationShown', true)
}, 2000)
}
}
})
let cloudNotificationTimer: ReturnType<typeof setTimeout> | undefined
onUnmounted(() => {
if (cloudNotificationTimer) clearTimeout(cloudNotificationTimer)
})
</script>

51
src/assets/splash.css Normal file
View File

@@ -0,0 +1,51 @@
/* Pre-Vue splash loader — colors set by inline script */
#splash-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
contain: strict;
}
#splash-loader svg {
width: min(200px, 50vw);
height: auto;
transform: translateZ(0);
}
#splash-loader .wave-group {
animation: splash-rise 4s ease-in-out infinite alternate;
will-change: transform;
transform: translateZ(0);
}
#splash-loader .wave-path {
animation: splash-wave 1.2s linear infinite;
will-change: transform;
transform: translateZ(0);
}
@keyframes splash-rise {
from {
transform: translateY(280px);
}
to {
transform: translateY(-80px);
}
}
@keyframes splash-wave {
from {
transform: translateX(0);
}
to {
transform: translateX(-880px);
}
}
@media (prefers-reduced-motion: reduce) {
#splash-loader .wave-group,
#splash-loader .wave-path {
animation: none;
}
#splash-loader .wave-group {
transform: translateY(-80px);
}
}

View File

@@ -186,13 +186,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)
})
@@ -206,7 +215,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)
})
@@ -214,7 +223,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

@@ -62,7 +62,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>
@@ -214,7 +214,7 @@ const actionbarContainerClass = computed(() => {
return cn(base, 'px-2', borderClass)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
@@ -289,7 +289,8 @@ function scheduleLegacyContentCheck() {
useMutationObserver(legacyCommandsContainerRef, scheduleLegacyContentCheck, {
childList: true,
subtree: true
subtree: true,
characterData: true
})
onMounted(() => {

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

@@ -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,6 +52,8 @@ 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'
@@ -57,6 +65,8 @@ const props = defineProps<{
stats: SystemStats
}>()
const { copyToClipboard } = useCopyToClipboard()
const systemInfo = computed(() => ({
...props.stats.system,
argv: props.stats.system.argv.join(' ')
@@ -124,4 +134,33 @@ function getDisplayValue(column: ColumnDef) {
}
return value
}
function formatSystemInfoText(): string {
const lines: string[] = ['## System Info']
for (const col of systemColumns.value) {
const display = getDisplayValue(col)
if (display !== undefined && display !== '') {
lines.push(`${col.header}: ${display}`)
}
}
if (hasDevices.value) {
lines.push('')
lines.push('## Devices')
for (const device of props.stats.devices) {
lines.push(`- ${device.name} (${device.type})`)
lines.push(` VRAM Total: ${formatSize(device.vram_total)}`)
lines.push(` VRAM Free: ${formatSize(device.vram_free)}`)
lines.push(` Torch VRAM Total: ${formatSize(device.torch_vram_total)}`)
lines.push(` Torch VRAM Free: ${formatSize(device.torch_vram_free)}`)
}
}
return lines.join('\n')
}
function copySystemInfo() {
copyToClipboard(formatSystemInfoText())
}
</script>

View File

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

View File

@@ -30,33 +30,31 @@
</div>
<div class="flex shrink-0 items-center gap-2">
<Skeleton v-if="showSkeleton(model)" class="ml-1.5 h-4 w-12" />
<template v-else-if="model.isDownloadable">
<span
v-if="fileSizes.get(model.url)"
class="pl-1.5 text-xs text-muted-foreground"
>
{{ formatSize(fileSizes.get(model.url)) }}
</span>
<a
v-if="gatedModelUrls.has(model.url)"
:href="gatedModelUrls.get(model.url)"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline"
>
{{ $t('missingModelsDialog.acceptTerms') }}
</a>
<Button
v-else
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.download')"
@click="downloadModel(model, paths)"
>
<i class="icon-[lucide--download] size-4" />
</Button>
</template>
<span
v-else-if="model.isDownloadable && fileSizes.get(model.url)"
class="pl-1.5 text-xs text-muted-foreground"
>
{{ formatSize(fileSizes.get(model.url)) }}
</span>
<a
v-else-if="gatedModelUrls.has(model.url)"
:href="gatedModelUrls.get(model.url)"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline"
>
{{ $t('missingModelsDialog.acceptTerms') }}
</a>
<Button
v-else-if="model.isDownloadable"
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.download')"
@click="downloadModel(model, paths)"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<Button
v-else
variant="textonly"

View File

@@ -183,13 +183,13 @@ const toggleState = () => {
}
const signInWithGoogle = async () => {
if (await authActions.signInWithGoogle()) {
if (await authActions.signInWithGoogle({ isNewUser: !isSignIn.value })) {
onSuccess()
}
}
const signInWithGithub = async () => {
if (await authActions.signInWithGithub()) {
if (await authActions.signInWithGithub({ isNewUser: !isSignIn.value })) {
onSuccess()
}
}

View File

@@ -193,6 +193,7 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
const { t } = useI18n()
@@ -471,8 +472,9 @@ useEventListener(
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
const { flags } = useFeatureFlags()
// Set up invite loader during setup phase so useRoute/useRouter work correctly
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
@@ -584,6 +586,18 @@ onMounted(async () => {
await inviteUrlLoader.loadInviteFromUrl()
}
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1)
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
try {
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
} catch (error) {
console.error(
'[GraphCanvas] Failed to load create workspace from URL:',
error
)
}
}
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =
await import('@/platform/updates/common/releaseStore')

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

@@ -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

@@ -35,7 +35,7 @@
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
name: getAssetDisplayName(item.asset),
type: getAssetMediaType(item.asset)
})
"
@@ -46,7 +46,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)"
@@ -135,8 +135,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 || ''

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

@@ -167,12 +167,9 @@ vi.mock('@/platform/telemetry', () => ({
}))
}))
// Mock isCloud with hoisted state for per-test toggling
const mockIsCloud = vi.hoisted(() => ({ value: true }))
// Mock isCloud
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
isCloud: true
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
@@ -187,7 +184,6 @@ vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 100_000,
@@ -429,60 +425,4 @@ describe('CurrentUserPopoverLegacy', () => {
expect(wrapper.text()).toContain('0')
})
})
describe('non-cloud distribution', () => {
beforeEach(() => {
mockIsCloud.value = false
})
it('hides credits section', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="add-credits-button"]').exists()).toBe(
false
)
expect(
wrapper.find('[data-testid="upgrade-to-add-credits-button"]').exists()
).toBe(false)
})
it('hides subscribe button', () => {
const wrapper = mountComponent()
expect(wrapper.text()).not.toContain('Subscribe Button')
})
it('hides partner nodes menu item', () => {
const wrapper = mountComponent()
expect(
wrapper.find('[data-testid="partner-nodes-menu-item"]').exists()
).toBe(false)
})
it('hides plans & pricing menu item', () => {
const wrapper = mountComponent()
expect(
wrapper.find('[data-testid="plans-pricing-menu-item"]').exists()
).toBe(false)
})
it('hides manage plan menu item', () => {
const wrapper = mountComponent()
expect(
wrapper.find('[data-testid="manage-plan-menu-item"]').exists()
).toBe(false)
})
it('still shows user settings menu item', () => {
const wrapper = mountComponent()
expect(
wrapper.find('[data-testid="user-settings-menu-item"]').exists()
).toBe(true)
})
it('still shows logout menu item', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="logout-menu-item"]').exists()).toBe(
true
)
})
})
})

View File

@@ -29,11 +29,8 @@
</span>
</div>
<!-- Credits Section (cloud only) -->
<div
v-if="isCloud && isActiveSubscription"
class="flex items-center gap-2 px-4 py-2"
>
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="authStore.isFetchingBalance"
@@ -69,7 +66,7 @@
</Button>
</div>
<div v-else-if="isCloud" class="flex justify-center px-4">
<div v-else class="flex justify-center px-4">
<SubscribeButton
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
@@ -82,7 +79,7 @@
<Divider class="mx-0 my-2" />
<div
v-if="isCloud && isActiveSubscription"
v-if="isActiveSubscription"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="partner-nodes-menu-item"
@click="handleOpenPartnerNodesInfo"
@@ -94,7 +91,6 @@
</div>
<div
v-if="isCloud"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="plans-pricing-menu-item"
@click="handleOpenPlansAndPricing"
@@ -112,7 +108,7 @@
</div>
<div
v-if="isCloud && isActiveSubscription"
v-if="isActiveSubscription"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="manage-plan-menu-item"
@click="handleOpenPlanAndCreditsSettings"

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

@@ -1,79 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import TopbarSubscribeButton from './TopbarSubscribeButton.vue'
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
const mockShowPricingTable = vi.fn()
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: vi.fn(() => ({
showPricingTable: mockShowPricingTable
}))
})
)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isFreeTier: { value: true }
}))
}))
vi.mock('pinia')
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signOut: vi.fn()
}))
function mountComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(TopbarSubscribeButton, {
global: {
plugins: [i18n]
}
})
}
describe('TopbarSubscribeButton', () => {
it('renders on cloud when isFreeTier is true', () => {
mockIsCloud.value = true
const wrapper = mountComponent()
expect(
wrapper.find('[data-testid="topbar-subscribe-button"]').exists()
).toBe(true)
})
it('hides on non-cloud distribution', () => {
mockIsCloud.value = false
const wrapper = mountComponent()
expect(
wrapper.find('[data-testid="topbar-subscribe-button"]').exists()
).toBe(false)
})
})

View File

@@ -1,6 +1,6 @@
<template>
<Button
v-if="isCloud && isFreeTier"
v-if="isFreeTier"
class="mr-2 shrink-0 whitespace-nowrap"
variant="gradient"
size="sm"
@@ -15,7 +15,6 @@
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
const { isFreeTier } = useBillingContext()
const subscriptionDialog = useSubscriptionDialog()

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

@@ -140,13 +140,19 @@ export const useFirebaseAuthActions = () => {
return result
}, reportError)
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
return await authStore.loginWithGoogle()
}, reportError)
const signInWithGoogle = wrapWithErrorHandlingAsync(
async (options?: { isNewUser?: boolean }) => {
return await authStore.loginWithGoogle(options)
},
reportError
)
const signInWithGithub = wrapWithErrorHandlingAsync(async () => {
return await authStore.loginWithGithub()
}, reportError)
const signInWithGithub = wrapWithErrorHandlingAsync(
async (options?: { isNewUser?: boolean }) => {
return await authStore.loginWithGithub(options)
},
reportError
)
const signInWithEmail = wrapWithErrorHandlingAsync(
async (email: string, password: string) => {

View File

@@ -241,6 +241,32 @@ 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: [
@@ -296,31 +322,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(secondMappedWidget.name).not.toBe('stale_widget')
})
it('clears stale slotMetadata when input no longer matches widget', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(widgetData.slotMetadata?.linked).toBe(true)
node.inputs[0].name = 'other'
node.inputs[0].widget = { name: 'other' }
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
expect(widgetData.slotMetadata).toBeUndefined()
})
})
describe('Subgraph output slot label reactivity', () => {

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

@@ -35,7 +35,8 @@ vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
removeEventListener: vi.fn(),
getServerFeature: vi.fn(() => false)
}
}))

View File

@@ -5,6 +5,10 @@ import { nextTick, ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
import type {
AnimationItem,
CameraConfig,
@@ -514,19 +518,21 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
// Reset skeleton visibility when loading new model
modelConfig.value.showSkeleton = false
if (load3d) {
if (load3d && isAssetPreviewSupported()) {
const node = nodeRef.value
const modelWidget = node?.widgets?.find(
(w) => w.name === 'model_file' || w.name === 'image'
)
const value = modelWidget?.value
if (typeof value === 'string') {
void Load3dUtils.generateThumbnailIfNeeded(
load3d,
value,
isPreview.value ? 'output' : 'input'
)
if (typeof value === 'string' && value) {
const filename = value.trim().replace(/\s*\[output\]$/, '')
const modelName = Load3dUtils.splitFilePath(filename)[1]
load3d
.captureThumbnail(256, 256)
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(modelName, blob))
.catch(() => {})
}
}
},

View File

@@ -357,7 +357,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
/**
* Initialize viewer in standalone mode (for asset preview)
* Initialize viewer in standalone mode (for asset preview).
* Creates the Load3d instance once; subsequent calls reuse it.
*/
const initializeStandaloneViewer = async (
containerRef: HTMLElement,
@@ -366,6 +367,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
if (!containerRef) return
try {
if (load3d) {
await loadStandaloneModel(modelUrl)
return
}
isStandaloneMode.value = true
load3d = new Load3d(containerRef, {
@@ -392,6 +398,23 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
setupAnimationEvents()
} catch (error) {
console.error('Error initializing standalone 3D viewer:', error)
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
}
}
/**
* Load a new model into an existing standalone viewer,
* reusing the same WebGLRenderer.
*/
const loadStandaloneModel = async (modelUrl: string) => {
if (!load3d) return
try {
await load3d.loadModel(modelUrl)
isSplatModel.value = load3d.isSplatModel()
isPlyModel.value = load3d.isPlyModel()
} catch (error) {
console.error('Error loading model in standalone viewer:', error)
useToastStore().addAlert('Failed to load 3D model')
}
}

View File

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

View File

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

View File

@@ -20,6 +20,25 @@ import {
type UpDirection
} from './interfaces'
function positionThumbnailCamera(
camera: THREE.PerspectiveCamera,
model: THREE.Object3D
) {
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const distance = maxDim * 1.5
camera.position.set(
center.x + distance * 0.7,
center.y + distance * 0.5,
center.z + distance * 0.7
)
camera.lookAt(center)
camera.updateProjectionMatrix()
}
class Load3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
@@ -781,25 +800,18 @@ class Load3d {
this.cameraManager.toggleCamera('perspective')
}
const box = new THREE.Box3().setFromObject(this.modelManager.currentModel)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const distance = maxDim * 1.5
const cameraPosition = new THREE.Vector3(
center.x - distance * 0.8,
center.y + distance * 0.4,
center.z + distance * 0.3
positionThumbnailCamera(
this.cameraManager.perspectiveCamera,
this.modelManager.currentModel
)
this.cameraManager.perspectiveCamera.position.copy(cameraPosition)
this.cameraManager.perspectiveCamera.lookAt(center)
this.cameraManager.perspectiveCamera.updateProjectionMatrix()
if (this.controlsManager.controls) {
this.controlsManager.controls.target.copy(center)
const box = new THREE.Box3().setFromObject(
this.modelManager.currentModel
)
this.controlsManager.controls.target.copy(
box.getCenter(new THREE.Vector3())
)
this.controlsManager.controls.update()
}

View File

@@ -1,34 +1,9 @@
import type Load3d from '@/extensions/core/load3d/Load3d'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
class Load3dUtils {
static async generateThumbnailIfNeeded(
load3d: Load3d,
modelPath: string,
folderType: 'input' | 'output'
): Promise<void> {
const [subfolder, filename] = this.splitFilePath(modelPath)
const thumbnailFilename = this.getThumbnailFilename(filename)
const exists = await this.fileExists(
subfolder,
thumbnailFilename,
folderType
)
if (exists) return
const imageData = await load3d.captureThumbnail(256, 256)
await this.uploadThumbnail(
imageData,
subfolder,
thumbnailFilename,
folderType
)
}
static async uploadTempImage(
imageData: string,
prefix: string,
@@ -147,46 +122,6 @@ class Load3dUtils {
await Promise.all(uploadPromises)
}
static getThumbnailFilename(modelFilename: string): string {
return `${modelFilename}.png`
}
static async fileExists(
subfolder: string,
filename: string,
type: string = 'input'
): Promise<boolean> {
try {
const url = api.apiURL(this.getResourceURL(subfolder, filename, type))
const response = await fetch(url, { method: 'HEAD' })
return response.ok
} catch {
return false
}
}
static async uploadThumbnail(
imageData: string,
subfolder: string,
filename: string,
type: string = 'input'
): Promise<boolean> {
const blob = await fetch(imageData).then((r) => r.blob())
const file = new File([blob], filename, { type: 'image/png' })
const body = new FormData()
body.append('image', file)
body.append('subfolder', subfolder)
body.append('type', type)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
return resp.status === 200
}
}
export default Load3dUtils

View File

@@ -4,7 +4,6 @@ import Load3D from '@/components/load3d/Load3D.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
@@ -14,6 +13,10 @@ type SaveMeshOutput = NodeOutputWith<{
'3d'?: ResultItem[]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
@@ -100,17 +103,20 @@ useExtensionService().registerExtension({
const loadFolder = fileInfo.type as 'input' | 'output'
const onModelLoaded = () => {
load3d.removeEventListener('modelLoadingEnd', onModelLoaded)
void Load3dUtils.generateThumbnailIfNeeded(
load3d,
filePath,
loadFolder
)
}
load3d.addEventListener('modelLoadingEnd', onModelLoaded)
config.configureForSaveMesh(loadFolder, filePath)
if (isAssetPreviewSupported()) {
const filename = fileInfo.filename ?? ''
const onModelLoaded = () => {
load3d.removeEventListener('modelLoadingEnd', onModelLoaded)
load3d
.captureThumbnail(256, 256)
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(filename, blob))
.catch(() => {})
}
load3d.addEventListener('modelLoadingEnd', onModelLoaded)
}
}
})
}

View File

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

View File

@@ -1765,7 +1765,6 @@
"execute": "تنفيذ",
"fullscreen": "ملء الشاشة",
"help": "مساعدة",
"helpAndFeedback": "المساعدة والتعليقات",
"hideMenu": "إخفاء القائمة",
"instant": "فوري",
"instantTooltip": "سيتم وضع سير العمل في قائمة الانتظار فور انتهاء التوليد",
@@ -2827,7 +2826,6 @@
"title": "تم إلغاء اشتراكك"
},
"changeTo": "تغيير إلى {plan}",
"chooseBestPlanWorkspace": "اختر أفضل خطة لمساحة العمل الخاصة بك",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "شعار Comfy Cloud",
"contactOwnerToSubscribe": "يرجى التواصل مع مالك مساحة العمل للاشتراك",

View File

@@ -94,6 +94,7 @@
"reportIssueTooltip": "Submit the error report to Comfy Org",
"reportSent": "Report Submitted",
"copyToClipboard": "Copy to Clipboard",
"copySystemInfo": "Copy System Info",
"copyAll": "Copy All",
"openNewIssue": "Open New Issue",
"showReport": "Show Report",
@@ -302,6 +303,7 @@
"1x": "1x",
"2x": "2x",
"beta": "BETA",
"comfyCloud": "Comfy Cloud",
"nightly": "NIGHTLY",
"profile": "Profile",
"noItems": "No items",
@@ -974,7 +976,6 @@
"customNodesManager": "Custom Nodes Manager",
"settings": "Settings",
"help": "Help",
"helpAndFeedback": "Help & Feedback",
"queue": "Queue Panel",
"fullscreen": "Fullscreen"
},
@@ -2223,7 +2224,12 @@
"topupTimeout": "Top-up verification timed out"
},
"subscription": {
"chooseBestPlanWorkspace": "Choose the best plan for your workspace",
"plansForWorkspace": "Plans for {workspace}",
"personalWorkspace": "Personal Workspace",
"teamWorkspace": "Team Workspace",
"soloUseOnly": "Solo use only",
"needTeamWorkspace": "Need team workspace?",
"inviteUpTo": "Invite up to",
"title": "Subscription",
"titleUnsubscribed": "Subscribe to Comfy Cloud",
"comfyCloud": "Comfy Cloud",
@@ -2505,7 +2511,7 @@
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
"message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.",
"message": "Workspaces create a new credit pool that can be shared among members. You'll become the owner after creating this.",
"nameLabel": "Workspace name*",
"namePlaceholder": "Enter workspace name",
"create": "Create"
@@ -2535,6 +2541,18 @@
"failedToFetchWorkspaces": "Failed to load workspaces"
}
},
"teamWorkspacesDialog": {
"title": "Team Workspaces",
"subtitle": "Switch to an existing one or create a new workspace",
"subtitleNoWorkspaces": "Create a new team workspace to share credits",
"confirmCallbackFailed": "Workspace created but setup incomplete",
"yourTeamWorkspaces": "Your team workspaces",
"switch": "Switch",
"newWorkspace": "New workspace",
"namePlaceholder": "e.g. Marketing Team",
"createWorkspace": "Create workspace",
"nameValidationError": "Name must be 150 characters using letters, numbers, spaces, or common punctuation."
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
"subscribe": "Subscribe",
@@ -2542,7 +2560,8 @@
"roleOwner": "Owner",
"roleMember": "Member",
"createWorkspace": "Create new workspace",
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one."
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
"failedToSwitch": "Failed to switch workspace"
},
"selectionToolbox": {
"executeButton": {
@@ -3077,6 +3096,7 @@
},
"comfyHubPublish": {
"title": "Publish to ComfyHub",
"unsavedDescription": "You must save your workflow before publishing to ComfyHub. Save it now to continue.",
"stepDescribe": "Describe your workflow",
"stepExamples": "Add output examples",
"stepFinish": "Finish publishing",
@@ -3084,12 +3104,6 @@
"workflowNamePlaceholder": "Tip: enter a descriptive name that's easy to search",
"workflowDescription": "Workflow description",
"workflowDescriptionPlaceholder": "What makes your workflow exciting and special? Be specific so people know what to expect.",
"workflowType": "Workflow type",
"workflowTypePlaceholder": "Select the type",
"workflowTypeImageGeneration": "Image generation",
"workflowTypeVideoGeneration": "Video generation",
"workflowTypeUpscaling": "Upscaling",
"workflowTypeEditing": "Editing",
"tags": "Tags",
"tagsDescription": "Select tags so people can find your workflow faster",
"tagsPlaceholder": "Enter tags that match your workflow to help people find it e.g #nanobanana, #anime or #faceswap",
@@ -3116,11 +3130,17 @@
"examplesDescription": "Add up to {total} additional sample images",
"uploadAnImage": "Click to browse or drag an image",
"uploadExampleImage": "Upload example image",
"removeExampleImage": "Remove example image",
"exampleImage": "Example image {index}",
"exampleImagePosition": "Example image {index} of {total}",
"videoPreview": "Video thumbnail preview",
"maxExamples": "You can select up to {max} examples",
"shareAs": "Share as",
"additionalInfo": "Additional information",
"createProfileToPublish": "Create a profile to publish to ComfyHub",
"createProfileCta": "Create a profile"
"createProfileCta": "Create a profile",
"publishFailedTitle": "Publish failed",
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again."
},
"comfyHubProfile": {
"checkingAccess": "Checking your publishing access...",
@@ -3139,6 +3159,7 @@
"namePlaceholder": "Enter your name here",
"usernameLabel": "Your username (required)",
"usernamePlaceholder": "@",
"usernameError": "342 lowercase alphanumeric characters and hyphens, must start and end with a letter or number",
"descriptionLabel": "Your description",
"descriptionPlaceholder": "Tell the community about yourself...",
"createProfile": "Create profile",
@@ -3568,16 +3589,5 @@
"builderMenu": {
"enterAppMode": "Enter app mode",
"exitAppBuilder": "Exit app builder"
},
"cloudNotification": {
"title": "Run ComfyUI in the Cloud",
"message": "From setup to creation in seconds. Popular models, extensions, and powerful GPUs — ready when you are.",
"feature1Title": "400 Free Credits Monthly",
"feature2Title": "Works Anywhere, Instantly",
"feature3Title": "Models Ready to Use",
"feature4Title": "Top Custom Node Packs Pre-installed",
"footer": "ComfyUI stays free and open source. Cloud is optional.",
"continueLocally": "Continue Locally",
"exploreCloud": "Try Cloud for Free"
}
}

View File

@@ -1765,7 +1765,6 @@
"execute": "Ejecutar",
"fullscreen": "Pantalla completa",
"help": "Ayuda",
"helpAndFeedback": "Ayuda y comentarios",
"hideMenu": "Ocultar menú",
"instant": "Instantáneo",
"instantTooltip": "El flujo de trabajo se encolará instantáneamente después de que finalice una generación",
@@ -2827,7 +2826,6 @@
"title": "Tu suscripción ha sido cancelada"
},
"changeTo": "Cambiar a {plan}",
"chooseBestPlanWorkspace": "Elige el mejor plan para tu espacio de trabajo",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Logo de Comfy Cloud",
"contactOwnerToSubscribe": "Contacta al propietario del espacio de trabajo para suscribirte",

View File

@@ -1765,7 +1765,6 @@
"execute": "اجرا",
"fullscreen": "تمام‌صفحه",
"help": "راهنما",
"helpAndFeedback": "راهنما و بازخورد",
"hideMenu": "مخفی کردن منو",
"instant": "فوری",
"instantTooltip": "workflow بلافاصله پس از پایان تولید در صف قرار می‌گیرد",
@@ -2839,7 +2838,6 @@
"title": "اشتراک شما لغو شده است"
},
"changeTo": "تغییر به {plan}",
"chooseBestPlanWorkspace": "بهترین طرح را برای فضای کاری خود انتخاب کنید",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "لوگوی Comfy Cloud",
"contactOwnerToSubscribe": "برای فعال‌سازی اشتراک با مالک محیط کاری تماس بگیرید",

View File

@@ -1765,7 +1765,6 @@
"execute": "Exécuter",
"fullscreen": "Plein écran",
"help": "Aide",
"helpAndFeedback": "Aide et commentaires",
"hideMenu": "Masquer le menu",
"instant": "Instantané",
"instantTooltip": "Le flux de travail sera mis en file d'attente immédiatement après la fin d'une génération",
@@ -2827,7 +2826,6 @@
"title": "Votre abonnement a été annulé"
},
"changeTo": "Changer pour {plan}",
"chooseBestPlanWorkspace": "Choisissez la meilleure offre pour votre espace de travail",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Logo Comfy Cloud",
"contactOwnerToSubscribe": "Contactez le propriétaire de lespace de travail pour vous abonner",

View File

@@ -1765,7 +1765,6 @@
"execute": "実行",
"fullscreen": "全画面表示",
"help": "ヘルプ",
"helpAndFeedback": "ヘルプとフィードバック",
"hideMenu": "メニューを隠す",
"instant": "即時",
"instantTooltip": "生成完了後すぐにキューに追加",
@@ -2827,7 +2826,6 @@
"title": "サブスクリプションはキャンセルされました"
},
"changeTo": "{plan}に変更",
"chooseBestPlanWorkspace": "ワークスペースに最適なプランを選択してください",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud ロゴ",
"contactOwnerToSubscribe": "サブスクリプションのためにワークスペースのオーナーに連絡してください",

View File

@@ -1765,7 +1765,6 @@
"execute": "실행",
"fullscreen": "전체 화면",
"help": "도움말",
"helpAndFeedback": "도움말 및 피드백",
"hideMenu": "메뉴 숨기기",
"instant": "즉시",
"instantTooltip": "워크플로 실행이 완료되면 즉시 실행 대기열에 추가합니다.",
@@ -2827,7 +2826,6 @@
"title": "구독이 취소되었습니다"
},
"changeTo": "{plan}로 변경",
"chooseBestPlanWorkspace": "워크스페이스에 가장 적합한 플랜을 선택하세요",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud 로고",
"contactOwnerToSubscribe": "워크스페이스 소유자에게 구독을 요청하세요",

View File

@@ -1765,7 +1765,6 @@
"execute": "Executar",
"fullscreen": "Tela cheia",
"help": "Ajuda",
"helpAndFeedback": "Ajuda e feedback",
"hideMenu": "Ocultar menu",
"instant": "Instantâneo",
"instantTooltip": "O fluxo de trabalho será enfileirado instantaneamente após uma geração terminar",
@@ -2839,7 +2838,6 @@
"title": "Sua assinatura foi cancelada"
},
"changeTo": "Mudar para {plan}",
"chooseBestPlanWorkspace": "Escolha o melhor plano para seu workspace",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Logo do Comfy Cloud",
"contactOwnerToSubscribe": "Entre em contato com o proprietário do espaço de trabalho para assinar",

View File

@@ -1765,7 +1765,6 @@
"execute": "Выполнить",
"fullscreen": "Полноэкранный режим",
"help": "Справка",
"helpAndFeedback": "Помощь и обратная связь",
"hideMenu": "Скрыть меню",
"instant": "Мгновенно",
"instantTooltip": "Рабочий процесс будет помещён в очередь сразу же после завершения генерации",
@@ -2827,7 +2826,6 @@
"title": "Ваша подписка отменена"
},
"changeTo": "Перейти на {plan}",
"chooseBestPlanWorkspace": "Выберите лучший тариф для вашего рабочего пространства",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Логотип Comfy Cloud",
"contactOwnerToSubscribe": "Свяжитесь с владельцем рабочего пространства для оформления подписки",

View File

@@ -1765,7 +1765,6 @@
"execute": "Yürüt",
"fullscreen": "Tam ekran",
"help": "Yardım",
"helpAndFeedback": "Yardım ve Geri Bildirim",
"hideMenu": "Menüyü Gizle",
"instant": "Anında",
"instantTooltip": "İş akışı, bir oluşturma işlemi bittikten sonra anında kuyruğa alınacak",
@@ -2827,7 +2826,6 @@
"title": "Aboneliğiniz iptal edildi"
},
"changeTo": "{plan} planına geç",
"chooseBestPlanWorkspace": "Çalışma alanınız için en iyi planı seçin",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud Logosu",
"contactOwnerToSubscribe": "Abone olmak için çalışma alanı sahibiyle iletişime geçin",

View File

@@ -1765,7 +1765,6 @@
"execute": "執行",
"fullscreen": "全螢幕",
"help": "說明",
"helpAndFeedback": "說明與回饋",
"hideMenu": "隱藏選單",
"instant": "立即",
"instantTooltip": "每次產生完成後,工作流程會立即排入佇列",
@@ -2827,7 +2826,6 @@
"title": "您的訂閱已取消"
},
"changeTo": "切換至 {plan}",
"chooseBestPlanWorkspace": "為您的工作區選擇最佳方案",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud 標誌",
"contactOwnerToSubscribe": "請聯絡工作區擁有者以訂閱",

View File

@@ -1765,7 +1765,6 @@
"execute": "执行",
"fullscreen": "全屏",
"help": "说明",
"helpAndFeedback": "帮助与反馈",
"hideMenu": "隐藏菜单",
"instant": "实时",
"instantTooltip": "工作流将会在生成完成后立即执行",
@@ -2839,7 +2838,6 @@
"title": "您的订阅已被取消"
},
"changeTo": "更改为 {plan}",
"chooseBestPlanWorkspace": "为您的工作区选择最佳方案",
"comfyCloud": "Comfy 云",
"comfyCloudLogo": "Comfy Cloud Logo",
"contactOwnerToSubscribe": "请联系工作区所有者进行订阅",

View File

@@ -186,7 +186,7 @@ const tooltipDelay = computed<number>(() =>
const { isLoading, error } = useImage({
src: asset.preview_url ?? '',
alt: asset.name
alt: asset.display_name || asset.name
})
function handleSelect() {

View File

@@ -1,11 +1,10 @@
<template>
<div class="relative size-full overflow-hidden rounded-sm">
<div ref="containerRef" class="relative size-full overflow-hidden rounded-sm">
<img
v-if="!thumbnailError"
v-if="thumbnailSrc"
:src="thumbnailSrc"
:alt="asset?.name"
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
@error="thumbnailError = true"
/>
<div
v-else
@@ -20,16 +19,60 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
import { onBeforeUnmount, ref, watch } from 'vue'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import {
findServerPreviewUrl,
isAssetPreviewSupported
} from '../utils/assetPreviewUtil'
const { asset } = defineProps<{ asset: AssetMeta }>()
const thumbnailError = ref(false)
const containerRef = ref<HTMLElement>()
const thumbnailSrc = ref<string | null>(null)
const hasAttempted = ref(false)
const thumbnailSrc = computed(() => {
if (!asset?.src) return ''
return asset.src.replace(/([?&]filename=)([^&]*)/, '$1$2.png')
useIntersectionObserver(containerRef, ([entry]) => {
if (entry?.isIntersecting && !hasAttempted.value) {
hasAttempted.value = true
void loadThumbnail()
}
})
async function loadThumbnail() {
if (asset?.preview_id && asset?.preview_url) {
thumbnailSrc.value = asset.preview_url
return
}
if (!asset?.src) return
if (asset.name && isAssetPreviewSupported()) {
const serverPreviewUrl = await findServerPreviewUrl(asset.name)
if (serverPreviewUrl) {
thumbnailSrc.value = serverPreviewUrl
}
}
}
function revokeThumbnail() {
if (thumbnailSrc.value?.startsWith('blob:')) {
URL.revokeObjectURL(thumbnailSrc.value)
}
thumbnailSrc.value = null
}
watch(
() => asset?.src,
() => {
if (hasAttempted.value) {
hasAttempted.value = false
revokeThumbnail()
}
}
)
onBeforeUnmount(revokeThumbnail)
</script>

View File

@@ -5,7 +5,7 @@
:aria-label="
asset
? $t('assetBrowser.ariaLabel.assetCard', {
name: asset.name,
name: asset.display_name || asset.name,
type: fileKind
})
: $t('assetBrowser.ariaLabel.loadingAsset')
@@ -139,6 +139,7 @@ import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import {
formatDuration,
@@ -150,6 +151,7 @@ import {
import { cn } from '@/utils/tailwindUtil'
import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { MediaKind } from '../schemas/mediaAssetSchema'
@@ -225,7 +227,7 @@ const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
// Get filename without extension
const fileName = computed(() => {
return getFilenameDetails(asset?.name || '').filename
return getFilenameDetails(asset?.display_name || asset?.name || '').filename
})
// Adapt AssetItem to legacy AssetMeta format for existing components
@@ -234,8 +236,14 @@ const adaptedAsset = computed(() => {
return {
id: asset.id,
name: asset.name,
display_name: asset.display_name,
kind: fileKind.value,
src: asset.thumbnail_url || asset.preview_url || '',
src:
fileKind.value === '3D'
? getAssetUrl(asset)
: asset.thumbnail_url || asset.preview_url || '',
preview_url: asset.preview_url,
preview_id: asset.preview_id,
size: asset.size,
tags: asset.tags || [],
created_at: asset.created_at,
@@ -269,7 +277,8 @@ const formattedDuration = computed(() => {
// Get metadata info based on file kind
const metaInfo = computed(() => {
if (!asset) return ''
if (fileKind.value === 'image' && imageDimensions.value) {
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
}
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {

View File

@@ -6,7 +6,7 @@
<img
v-if="!error"
:src="asset.src"
:alt="asset.name"
:alt="asset.display_name || asset.name"
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
/>
<div
@@ -34,7 +34,7 @@ const emit = defineEmits<{
const { state, error, isReady } = useImage({
src: asset.src ?? '',
alt: asset.name
alt: asset.display_name || asset.name
})
whenever(

View File

@@ -39,6 +39,7 @@ export function mapTaskOutputToAssetItem(
return {
id: taskItem.jobId,
name: output.filename,
display_name: output.display_name,
size: 0,
created_at: taskItem.executionStartTimestamp
? new Date(taskItem.executionStartTimestamp).toISOString()

View File

@@ -119,17 +119,31 @@ vi.mock('@/platform/assets/utils/assetTypeUtil', () => ({
getAssetType: mockGetAssetType
}))
const mockGetOutputAssetMetadata = vi.hoisted(() =>
vi.fn().mockReturnValue(null)
)
vi.mock('../schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: vi.fn().mockReturnValue(null)
getOutputAssetMetadata: mockGetOutputAssetMetadata
}))
const mockDeleteAsset = vi.hoisted(() => vi.fn())
const mockCreateAssetExport = vi.hoisted(() =>
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
)
vi.mock('../services/assetService', () => ({
assetService: {
deleteAsset: mockDeleteAsset
deleteAsset: mockDeleteAsset,
createAssetExport: mockCreateAssetExport
}
}))
const mockTrackExport = vi.hoisted(() => vi.fn())
vi.mock('@/stores/assetExportStore', () => ({
useAssetExportStore: () => ({
trackExport: mockTrackExport
})
}))
vi.mock('@/scripts/api', () => ({
api: {
deleteItem: vi.fn(),
@@ -259,6 +273,106 @@ describe('useMediaAssetActions', () => {
})
})
describe('downloadMultipleAssets - job_asset_name_filters', () => {
beforeEach(() => {
mockIsCloud.value = true
mockCreateAssetExport.mockClear()
mockTrackExport.mockClear()
mockGetAssetType.mockReturnValue('output')
mockGetOutputAssetMetadata.mockImplementation(
(meta: Record<string, unknown> | undefined) =>
meta && 'jobId' in meta ? meta : null
)
})
function createOutputAsset(
id: string,
name: string,
jobId: string,
outputCount?: number
): AssetItem {
return createMockAsset({
id,
name,
tags: ['output'],
user_metadata: { jobId, nodeId: '1', subfolder: '', outputCount }
})
}
it('should omit name filters for job-level selections (outputCount known)', async () => {
const assets = [
createOutputAsset('a1', 'img1.png', 'job1', 3),
createOutputAsset('a2', 'img2.png', 'job1', 3),
createOutputAsset('a3', 'img3.png', 'job1', 3)
]
const actions = useMediaAssetActions()
actions.downloadMultipleAssets(assets)
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.job_ids).toEqual(['job1'])
expect(payload.job_asset_name_filters).toBeUndefined()
})
it('should omit name filters for multiple job-level selections', async () => {
const j1a = createOutputAsset('a1', 'out1a.png', 'job1', 2)
const j1b = createOutputAsset('a2', 'out1b.png', 'job1', 2)
const j2 = createOutputAsset('a3', 'out2.png', 'job2', 1)
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([j1a, j1b, j2])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.job_ids).toEqual(['job1', 'job2'])
expect(payload.job_asset_name_filters).toBeUndefined()
})
it('should include name filters when outputCount is unknown', async () => {
const asset1 = createOutputAsset('a1', 'img1.png', 'job1')
const asset2 = createOutputAsset('a2', 'img2.png', 'job2')
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([asset1, asset2])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.job_asset_name_filters).toEqual({
job1: ['img1.png'],
job2: ['img2.png']
})
})
it('should mix: omit filters for known outputCount, keep for unknown', async () => {
const j1a = createOutputAsset('a1', 'img1a.png', 'job1', 2)
const j1b = createOutputAsset('a2', 'img1b.png', 'job1', 2)
const j2 = createOutputAsset('a3', 'img2.png', 'job2')
const actions = useMediaAssetActions()
actions.downloadMultipleAssets([j1a, j1b, j2])
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.job_ids).toEqual(['job1', 'job2'])
expect(payload.job_asset_name_filters).toEqual({
job2: ['img2.png']
})
})
})
describe('deleteAssets - model cache invalidation', () => {
beforeEach(() => {
mockIsCloud.value = true

View File

@@ -68,7 +68,7 @@ export function useMediaAssetActions() {
if (!targetAsset) return
try {
const filename = targetAsset.name
const filename = targetAsset.display_name || targetAsset.name
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset)
@@ -109,7 +109,7 @@ export function useMediaAssetActions() {
try {
assets.forEach((asset) => {
const filename = asset.name
const filename = asset.display_name || asset.name
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})
@@ -145,7 +145,10 @@ export function useMediaAssetActions() {
if (!jobIds.includes(jobId)) {
jobIds.push(jobId)
}
if (metadata?.jobId && asset.name) {
// Only add name filters when outputCount is unknown.
// When outputCount is set, the asset is a job-level selection
// from the gallery and the user wants all outputs for that job.
if (metadata?.jobId && asset.name && metadata.outputCount == null) {
if (!jobAssetNameFilters[metadata.jobId]) {
jobAssetNameFilters[metadata.jobId] = []
}

View File

@@ -9,6 +9,7 @@ const zAsset = z.object({
mime_type: z.string().nullish(),
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
display_name: z.string().optional(),
preview_url: z.string().optional(),
thumbnail_url: z.string().optional(),
created_at: z.string().optional(),
@@ -94,7 +95,7 @@ export type ModelFile = z.infer<typeof zModelFile>
/** Payload for updating an asset via PUT /assets/:id */
export type AssetUpdatePayload = Partial<
Pick<AssetItem, 'name' | 'tags' | 'user_metadata'>
Pick<AssetItem, 'name' | 'tags' | 'user_metadata' | 'preview_id'>
>
/** User-editable metadata fields for model assets */

View File

@@ -0,0 +1,267 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
findOutputAsset,
findServerPreviewUrl,
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
const mockFetchApi = vi.hoisted(() => vi.fn())
const mockApiURL = vi.hoisted(() =>
vi.fn((path: string) => `http://localhost:8188${path}`)
)
const mockGetServerFeature = vi.hoisted(() => vi.fn(() => false))
const mockIsAssetAPIEnabled = vi.hoisted(() => vi.fn(() => false))
const mockUploadAssetFromBase64 = vi.hoisted(() => vi.fn())
const mockUpdateAsset = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: mockFetchApi,
apiURL: mockApiURL,
api_base: '',
getServerFeature: mockGetServerFeature
}
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
isAssetAPIEnabled: mockIsAssetAPIEnabled,
uploadAssetFromBase64: mockUploadAssetFromBase64,
updateAsset: mockUpdateAsset
}
}))
function mockFetchResponse(assets: Record<string, unknown>[]) {
mockFetchApi.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ assets })
})
}
function mockFetchEmpty() {
mockFetchResponse([])
}
function mockFetchError() {
mockFetchApi.mockResolvedValueOnce({ ok: false })
}
const cloudAsset = {
id: '72d169cc-7f9a-40d2-9382-35eadcba0a6a',
name: 'mesh/ComfyUI_00003_.glb',
asset_hash: 'c6cadcee57dd.glb',
preview_id: null,
preview_url: undefined
}
const cloudAssetWithPreview = {
...cloudAsset,
preview_id: 'aaaa-bbbb',
preview_url: '/api/view?type=output&filename=preview.png'
}
const localAsset = {
id: '50bf419e-7ecb-4c96-a0c7-c1eb4dff00cb',
name: 'ComfyUI_00081_.glb',
preview_id: null,
preview_url:
'/api/view?type=output&filename=ComfyUI_00081_.glb&subfolder=mesh'
}
const localAssetWithPreview = {
...localAsset,
preview_id: '3df94ee8-preview',
preview_url: '/api/view?type=output&filename=preview.png'
}
describe('isAssetPreviewSupported', () => {
beforeEach(() => vi.clearAllMocks())
it('returns true when asset API is enabled (cloud)', () => {
mockIsAssetAPIEnabled.mockReturnValue(true)
expect(isAssetPreviewSupported()).toBe(true)
})
it('returns true when server assets feature is enabled (local)', () => {
mockGetServerFeature.mockReturnValue(true)
expect(isAssetPreviewSupported()).toBe(true)
})
it('returns false when neither is enabled', () => {
mockIsAssetAPIEnabled.mockReturnValue(false)
mockGetServerFeature.mockReturnValue(false)
expect(isAssetPreviewSupported()).toBe(false)
})
})
describe('findOutputAsset', () => {
beforeEach(() => vi.clearAllMocks())
it('finds asset by hash (cloud)', async () => {
mockFetchResponse([cloudAsset])
const result = await findOutputAsset('c6cadcee57dd.glb')
expect(mockFetchApi).toHaveBeenCalledOnce()
expect(mockFetchApi.mock.calls[0][0]).toContain(
'asset_hash=c6cadcee57dd.glb'
)
expect(result).toEqual(cloudAsset)
})
it('falls back to name_contains when hash returns empty (local)', async () => {
mockFetchEmpty()
mockFetchResponse([localAsset])
const result = await findOutputAsset('ComfyUI_00081_.glb')
expect(mockFetchApi).toHaveBeenCalledTimes(2)
expect(mockFetchApi.mock.calls[0][0]).toContain('asset_hash=')
expect(mockFetchApi.mock.calls[1][0]).toContain('name_contains=')
expect(result).toEqual(localAsset)
})
it('returns undefined when no asset matches', async () => {
mockFetchEmpty()
mockFetchEmpty()
const result = await findOutputAsset('nonexistent.glb')
expect(result).toBeUndefined()
})
it('matches exact name from name_contains results', async () => {
mockFetchEmpty()
mockFetchResponse([
{ id: '1', name: 'ComfyUI_00001_.glb_preview.png' },
{ id: '2', name: 'ComfyUI_00001_.glb' }
])
const result = await findOutputAsset('ComfyUI_00001_.glb')
expect(result?.id).toBe('2')
})
it('returns empty array on fetch error', async () => {
mockFetchError()
mockFetchError()
const result = await findOutputAsset('test.glb')
expect(result).toBeUndefined()
})
})
describe('findServerPreviewUrl', () => {
beforeEach(() => vi.clearAllMocks())
it('returns null when asset has no preview_id', async () => {
mockFetchResponse([cloudAsset])
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
expect(result).toBeNull()
})
it('returns preview_url via apiURL when preview_id is set', async () => {
mockFetchResponse([cloudAssetWithPreview])
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
expect(mockApiURL).toHaveBeenCalledWith(cloudAssetWithPreview.preview_url)
expect(result).toBe(
`http://localhost:8188${cloudAssetWithPreview.preview_url}`
)
})
it('constructs URL from preview_id when preview_url is missing', async () => {
mockFetchResponse([{ ...cloudAsset, preview_id: 'aaaa-bbbb' }])
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
expect(result).toBe('http://localhost:8188/assets/aaaa-bbbb/content')
})
it('falls back to asset id when preview_id is null but set', async () => {
// Edge case: asset has preview_id explicitly null, no preview_url
mockFetchEmpty()
mockFetchEmpty()
const result = await findServerPreviewUrl('nonexistent.glb')
expect(result).toBeNull()
})
it('returns null on error', async () => {
mockFetchApi.mockRejectedValueOnce(new Error('network error'))
const result = await findServerPreviewUrl('test.glb')
expect(result).toBeNull()
})
})
describe('persistThumbnail', () => {
beforeEach(() => vi.clearAllMocks())
it('uploads thumbnail and links preview_id', async () => {
mockFetchEmpty()
mockFetchResponse([localAsset])
mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
mockUpdateAsset.mockResolvedValue({})
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('ComfyUI_00081_.glb', blob)
expect(mockUploadAssetFromBase64).toHaveBeenCalledOnce()
expect(mockUploadAssetFromBase64.mock.calls[0][0].name).toBe(
'ComfyUI_00081_.glb_preview.png'
)
expect(mockUpdateAsset).toHaveBeenCalledWith(localAsset.id, {
preview_id: 'new-preview-id'
})
})
it('skips when asset already has preview_id', async () => {
mockFetchEmpty()
mockFetchResponse([localAssetWithPreview])
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('ComfyUI_00081_.glb', blob)
expect(mockUploadAssetFromBase64).not.toHaveBeenCalled()
expect(mockUpdateAsset).not.toHaveBeenCalled()
})
it('skips when no asset found', async () => {
mockFetchEmpty()
mockFetchEmpty()
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('nonexistent.glb', blob)
expect(mockUploadAssetFromBase64).not.toHaveBeenCalled()
})
it('swallows errors silently', async () => {
mockFetchEmpty()
mockFetchResponse([localAsset])
mockUploadAssetFromBase64.mockRejectedValue(new Error('upload failed'))
const blob = new Blob(['fake-png'], { type: 'image/png' })
await expect(
persistThumbnail('ComfyUI_00081_.glb', blob)
).resolves.toBeUndefined()
})
it('works with cloud hash filename', async () => {
mockFetchResponse([cloudAsset])
mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
mockUpdateAsset.mockResolvedValue({})
const blob = new Blob(['fake-png'], { type: 'image/png' })
await persistThumbnail('c6cadcee57dd.glb', blob)
expect(mockUploadAssetFromBase64.mock.calls[0][0].name).toBe(
'mesh/ComfyUI_00003_.glb_preview.png'
)
expect(mockUpdateAsset).toHaveBeenCalledWith(cloudAsset.id, {
preview_id: 'new-preview-id'
})
})
})

View File

@@ -0,0 +1,95 @@
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
interface AssetRecord {
id: string
name: string
asset_hash?: string
preview_url?: string
preview_id?: string | null
}
export function isAssetPreviewSupported(): boolean {
return (
assetService.isAssetAPIEnabled() || api.getServerFeature('assets', false)
)
}
async function fetchAssets(
params: Record<string, string>
): Promise<AssetRecord[]> {
const query = new URLSearchParams(params)
const res = await api.fetchApi(`/assets?${query}`)
if (!res.ok) return []
const data = await res.json()
return data.assets ?? []
}
function resolvePreviewUrl(asset: AssetRecord): string {
if (asset.preview_url) return api.apiURL(asset.preview_url)
const contentId = asset.preview_id ?? asset.id
return api.apiURL(`/assets/${contentId}/content`)
}
/**
* Find an output asset record by content hash, falling back to name.
* On cloud, output filenames are content-hashed; use asset_hash to match.
* On local, filenames are not hashed; use name_contains to match.
*/
export async function findOutputAsset(
name: string
): Promise<AssetRecord | undefined> {
const byHash = await fetchAssets({ asset_hash: name })
const hashMatch = byHash.find((a) => a.asset_hash === name)
if (hashMatch) return hashMatch
const byName = await fetchAssets({ name_contains: name })
return byName.find((a) => a.name === name)
}
export async function findServerPreviewUrl(
name: string
): Promise<string | null> {
try {
const asset = await findOutputAsset(name)
if (!asset?.preview_id) return null
return resolvePreviewUrl(asset)
} catch {
return null
}
}
export async function persistThumbnail(
name: string,
blob: Blob
): Promise<void> {
try {
const asset = await findOutputAsset(name)
if (!asset || asset.preview_id) return
const previewFilename = `${asset.name}_preview.png`
const uploaded = await assetService.uploadAssetFromBase64({
data: await blobToDataUrl(blob),
name: previewFilename,
tags: ['output'],
user_metadata: { filename: previewFilename }
})
await assetService.updateAsset(asset.id, {
preview_id: uploaded.id
})
} catch {
// Non-critical — client still shows the rendered thumbnail
}
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}

View File

@@ -20,6 +20,7 @@ type OutputOverrides = Partial<{
subfolder: string
nodeId: string
url: string
display_name: string
}>
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
@@ -32,7 +33,8 @@ function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
}
return {
...merged,
previewUrl: merged.url
previewUrl: merged.url,
display_name: merged.display_name
} as ResultItemImpl
}
@@ -87,7 +89,7 @@ describe('resolveOutputAssetItems', () => {
)
})
it('loads full outputs when metadata indicates more outputs', async () => {
it('loads full outputs when metadata indicates more outputs (newest first)', async () => {
const previewOutput = createOutput({
filename: 'preview.png',
nodeId: '1',
@@ -119,12 +121,108 @@ describe('resolveOutputAssetItems', () => {
expect(mocks.getPreviewableOutputsFromJobDetail).toHaveBeenCalledWith(
jobDetail
)
// Outputs are reversed so the most recent appears first
expect(results.map((asset) => asset.name)).toEqual([
'full.png',
'preview.png'
'preview.png',
'full.png'
])
})
it('reverses outputs and excludes the correct key simultaneously', async () => {
const outputA = createOutput({
filename: 'a.png',
nodeId: '1',
url: 'https://example.com/a.png'
})
const outputB = createOutput({
filename: 'b.png',
nodeId: '2',
url: 'https://example.com/b.png'
})
const outputC = createOutput({
filename: 'c.png',
nodeId: '3',
url: 'https://example.com/c.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-combo',
nodeId: '1',
subfolder: 'sub',
outputCount: 3,
allOutputs: [outputA, outputB, outputC]
}
const results = await resolveOutputAssetItems(metadata, {
excludeOutputKey: '2-sub-b.png'
})
// outputB excluded, remaining reversed: [C, A]
expect(results.map((asset) => asset.name)).toEqual(['c.png', 'a.png'])
})
it('returns empty array when all outputs are excluded', async () => {
const output = createOutput({
filename: 'only.png',
nodeId: '1',
url: 'https://example.com/only.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-empty',
nodeId: '1',
subfolder: 'sub',
outputCount: 1,
allOutputs: [output]
}
const results = await resolveOutputAssetItems(metadata, {
excludeOutputKey: '1-sub-only.png'
})
expect(results).toHaveLength(0)
})
it('propagates display_name from output to asset item', async () => {
const output = createOutput({
filename: 'abc123hash.png',
nodeId: '1',
url: 'https://example.com/abc123hash.png',
display_name: 'ComfyUI_00001_.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-dn',
nodeId: '1',
subfolder: 'sub',
outputCount: 1,
allOutputs: [output]
}
const results = await resolveOutputAssetItems(metadata)
expect(results).toHaveLength(1)
expect(results[0].name).toBe('abc123hash.png')
expect(results[0].display_name).toBe('ComfyUI_00001_.png')
})
it('omits display_name when not present in output', async () => {
const output = createOutput({
filename: 'file.png',
nodeId: '1',
url: 'https://example.com/file.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-nodn',
nodeId: '1',
subfolder: 'sub',
outputCount: 1,
allOutputs: [output]
}
const results = await resolveOutputAssetItems(metadata)
expect(results).toHaveLength(1)
expect(results[0].display_name).toBeUndefined()
})
it('keeps root outputs with empty subfolders', async () => {
const output = createOutput({
filename: 'root.png',

View File

@@ -69,6 +69,7 @@ function mapOutputsToAssetItems({
items.push({
id: `${jobId}-${outputKey}`,
name: output.filename,
display_name: output.display_name,
size: 0,
created_at: createdAtValue,
tags: ['output'],
@@ -100,9 +101,10 @@ export async function resolveOutputAssetItems(
}
}
// Reverse so the most recent outputs appear first
return mapOutputsToAssetItems({
jobId: metadata.jobId,
outputs: outputsToDisplay,
outputs: outputsToDisplay.toReversed(),
createdAt,
executionTimeInSeconds: metadata.executionTimeInSeconds,
workflow: metadata.workflow,

View File

@@ -1,115 +0,0 @@
<template>
<div class="relative grid h-full grid-cols-5">
<Button
size="unset"
variant="muted-textonly"
class="absolute top-2.5 right-2.5 z-10 size-8 rounded-full p-0 text-white hover:bg-white/20"
:aria-label="t('g.close')"
@click="onDismiss"
>
<i class="pi pi-times" />
</Button>
<div
class="relative col-span-2 flex items-center justify-center overflow-hidden rounded-sm"
>
<video
autoplay
loop
muted
playsinline
class="-ml-[20%] h-full min-w-5/4 object-cover p-0"
>
<source
src="/assets/images/cloud-subscription.webm"
type="video/webm"
/>
</video>
</div>
<div class="col-span-3 flex flex-col justify-between p-8">
<div>
<div class="flex flex-col gap-4">
<div class="text-sm font-semibold text-text-primary">
{{ t('cloudNotification.title') }}
</div>
<p class="m-0 text-sm text-text-secondary">
{{ t('cloudNotification.message') }}
</p>
</div>
<div class="mt-6 flex flex-col items-start gap-0 self-stretch">
<div v-for="n in 4" :key="n" class="flex items-center gap-2 py-2">
<i class="pi pi-check text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ t(`cloudNotification.feature${n}Title`) }}
</span>
</div>
</div>
</div>
<div class="flex flex-col gap-2 pt-8">
<Button
variant="primary"
size="lg"
class="w-full font-bold"
@click="onExplore"
>
{{ t('cloudNotification.exploreCloud') }}
</Button>
<Button variant="textonly" size="sm" class="w-full" @click="onDismiss">
{{ t('cloudNotification.continueLocally') }}
</Button>
<p class="m-0 text-center text-xs text-text-secondary">
{{ t('cloudNotification.footer') }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
onMounted(() => {
// Impression event — uses trackUiButtonClicked as no dedicated impression tracker exists
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_modal_impression'
})
})
function onDismiss() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_continue_locally_clicked'
})
useDialogStore().closeDialog()
}
function onExplore() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_explore_cloud_clicked'
})
const params = new URLSearchParams({
utm_source: 'desktop',
utm_medium: 'onload-modal',
utm_campaign: 'local-to-cloud-conversion',
utm_id: 'desktop-onload-modal',
utm_source_platform: 'mac-desktop'
})
window.open(
`https://www.comfy.org/cloud?${params}`,
'_blank',
'noopener,noreferrer'
)
useDialogStore().closeDialog()
}
</script>

View File

@@ -182,14 +182,14 @@ const onSuccess = async () => {
const signInWithGoogle = async () => {
authError.value = ''
if (await authActions.signInWithGoogle()) {
if (await authActions.signInWithGoogle({ isNewUser: true })) {
await onSuccess()
}
}
const signInWithGithub = async () => {
authError.value = ''
if (await authActions.signInWithGithub()) {
if (await authActions.signInWithGithub({ isNewUser: true })) {
await onSuccess()
}
}

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { until } from '@vueuse/core'
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted, ref } from 'vue'
@@ -20,7 +19,7 @@ const router = useRouter()
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { isActiveSubscription, isInitialized } = useBillingContext()
const { isActiveSubscription, isInitialized, initialize } = useBillingContext()
const selectedTierKey = ref<TierKey | null>(null)
@@ -76,7 +75,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
}
if (!isInitialized.value) {
await until(isInitialized).toBe(true)
await initialize()
}
if (isActiveSubscription.value) {

View File

@@ -8,9 +8,14 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import CloudTemplate from './CloudTemplate.vue'
onMounted(() => {
document.getElementById('splash-loader')?.remove()
})
</script>

View File

@@ -31,7 +31,19 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
path: 'signup',
name: 'cloud-signup',
component: () =>
import('@/platform/cloud/onboarding/CloudSignupView.vue')
import('@/platform/cloud/onboarding/CloudSignupView.vue'),
beforeEnter: async (to, _from, next) => {
if (!to.query.switchAccount) {
const { useCurrentUser } =
await import('@/composables/auth/useCurrentUser')
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
return next({ name: 'cloud-user-check' })
}
}
next()
}
},
{
path: 'forgot-password',

View File

@@ -107,6 +107,8 @@ const i18n = createI18n({
videoEstimateHelp: 'How is this calculated?',
videoEstimateExplanation: 'Based on average usage.',
videoEstimateTryTemplate: 'Try template',
soloUseOnly: 'Solo use only',
needTeamWorkspace: 'Need team workspace?',
maxDuration: {
standard: '30 min',
creator: '30 min',
@@ -296,4 +298,20 @@ describe('PricingTable', () => {
expect(mockAccessBillingPortal).toHaveBeenCalledWith('standard-yearly')
})
})
describe('team workspace link', () => {
it('should emit chooseTeamWorkspace when clicking "Need team workspace?" link', async () => {
const wrapper = createWrapper()
await flushPromises()
const teamLink = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Need team workspace?'))
expect(teamLink).toBeDefined()
await teamLink?.trigger('click')
expect(wrapper.emitted('chooseTeamWorkspace')).toHaveLength(1)
})
})
})

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-6">
<div class="flex justify-center">
<SelectButton
v-model="currentBillingCycle"
@@ -38,7 +38,7 @@
</template>
</SelectButton>
</div>
<div class="flex flex-col items-stretch gap-6 xl:flex-row">
<div class="flex flex-col items-stretch gap-4 xl:flex-row">
<div
v-for="tier in tiers"
:key="tier.id"
@@ -49,7 +49,7 @@
)
"
>
<div class="flex flex-col gap-8 p-8 pb-0">
<div class="flex flex-col gap-4 p-6 pb-0">
<div class="flex flex-row items-center justify-between gap-2">
<span
class="font-inter text-base/normal font-bold text-base-foreground"
@@ -67,7 +67,7 @@
<div class="flex flex-col gap-2">
<div class="flex flex-row items-baseline gap-2">
<span
class="font-inter text-[32px] leading-normal font-semibold text-base-foreground"
class="font-inter text-[28px] leading-normal font-semibold text-base-foreground"
>
<span
v-show="currentBillingCycle === 'yearly'"
@@ -95,7 +95,22 @@
</div>
</div>
<div class="flex flex-1 flex-col gap-4 pb-0">
<p
role="note"
:aria-label="t('subscription.soloUseOnly')"
class="m-0 flex h-10 items-center rounded-lg bg-muted-foreground/30 px-3 text-sm text-muted-foreground"
>
{{ t('subscription.soloUseOnly') }}
<span class="mx-1 text-muted-foreground"></span>
<button
class="text-primary-foreground cursor-pointer border-none bg-transparent p-0 text-sm font-medium underline hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
@click="emit('chooseTeamWorkspace')"
>
{{ t('subscription.needTeamWorkspace') }}
</button>
</p>
<div class="flex flex-1 flex-col gap-3 pb-0">
<div class="flex flex-row items-center justify-between">
<span
class="text-foreground font-inter text-sm/normal font-normal"
@@ -179,7 +194,7 @@
</div>
</div>
</div>
<div class="flex flex-col p-8">
<div class="flex flex-col p-6">
<Button
:variant="getButtonSeverity(tier)"
:disabled="isLoading || isCurrentPlan(tier.key)"
@@ -303,6 +318,10 @@ interface PricingTierConfig {
isPopular?: boolean
}
const emit = defineEmits<{
chooseTeamWorkspace: []
}>()
const { t, n } = useI18n()
const billingCycleOptions: BillingCycleOption[] = [

View File

@@ -1,7 +1,7 @@
<template>
<div
v-if="showCustomPricingTable"
class="relative flex h-full flex-col gap-8 overflow-y-auto! p-4 pt-8 md:p-16"
class="relative flex h-full flex-col gap-6 overflow-y-auto p-4 pt-8 md:px-16 md:py-8"
>
<Button
size="icon"
@@ -10,15 +10,30 @@
:aria-label="$t('g.close')"
@click="handleClose"
>
<i class="pi pi-times text-xl" />
<i class="pi pi-times text-xl" aria-hidden="true" />
</Button>
<div class="text-center">
<h2 class="m-0 text-xl text-muted-foreground lg:text-2xl">
{{ $t('subscription.description') }}
</h2>
<div class="flex flex-col items-center gap-3">
<div
class="flex size-10 items-center justify-center rounded-xl bg-muted-foreground/30 text-lg font-semibold text-white"
aria-hidden="true"
>
<!-- Decorative initial for "Personal" workspace icon; not user-facing text -->
P
</div>
<i18n-t
keypath="subscription.plansForWorkspace"
tag="h2"
class="m-0 font-inter text-2xl font-semibold text-base-foreground"
>
<template #workspace>
<span class="text-muted-foreground">
{{ $t('subscription.personalWorkspace') }}
</span>
</template>
</i18n-t>
</div>
<PricingTable class="flex-1" />
<PricingTable class="flex-1" @choose-team-workspace="handleChooseTeam" />
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center gap-2">
@@ -55,7 +70,7 @@
:aria-label="$t('g.close')"
@click="handleClose"
>
<i class="pi pi-times" />
<i class="pi pi-times" aria-hidden="true" />
</Button>
<div
@@ -144,9 +159,10 @@ import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
const { onClose, reason } = defineProps<{
const { onClose, reason, onChooseTeam } = defineProps<{
onClose: () => void
reason?: SubscriptionDialogReason
onChooseTeam?: () => void
}>()
const emit = defineEmits<{
@@ -236,6 +252,7 @@ watch(
() => isActiveSubscription.value,
(isActive) => {
if (isActive && showCustomPricingTable.value) {
telemetry?.trackMonthlySubscriptionSucceeded()
emit('close', true)
}
}
@@ -245,6 +262,15 @@ const handleSubscribed = () => {
emit('close', true)
}
const handleChooseTeam = () => {
stopPolling()
if (onChooseTeam) {
onChooseTeam()
} else {
onClose()
}
}
const handleClose = () => {
stopPolling()
onClose()

View File

@@ -1,77 +1,196 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubscriptionDialog } from './useSubscriptionDialog'
const mockCloseDialog = vi.fn()
const mockShowLayoutDialog = vi.fn()
const mockShowTeamWorkspacesDialog = vi.fn()
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('vue', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
defineAsyncComponent: vi.fn((loader) => loader)
}
})
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: mockCloseDialog
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showLayoutDialog: mockShowLayoutDialog,
showTeamWorkspacesDialog: mockShowTeamWorkspacesDialog
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isFreeTier: mockIsFreeTier
})
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
const mockShowLayoutDialog = vi.fn()
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showLayoutDialog: mockShowLayoutDialog
}))
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
closeDialog: vi.fn()
}))
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: { teamWorkspacesEnabled: false }
}))
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isFreeTier: { value: false }
}))
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: vi.fn(() => ({
isInPersonalWorkspace: true
}))
}))
vi.mock('pinia')
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signOut: vi.fn()
useTeamWorkspaceStore: () => ({
get isInPersonalWorkspace() {
return mockIsInPersonalWorkspace.value
}
})
}))
describe('useSubscriptionDialog', () => {
it('showPricingTable does not open dialog on non-cloud', async () => {
mockIsCloud.value = false
const { useSubscriptionDialog } = await import('./useSubscriptionDialog')
const dialog = useSubscriptionDialog()
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockTeamWorkspacesEnabled.value = false
dialog.showPricingTable()
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
try {
sessionStorage.clear()
} catch {
// noop
}
})
it('showPricingTable opens dialog on cloud', async () => {
mockIsCloud.value = true
const { useSubscriptionDialog } = await import('./useSubscriptionDialog')
const dialog = useSubscriptionDialog()
describe('showPricingTable', () => {
it('does not open dialog on non-cloud', () => {
mockIsCloud.value = false
const { showPricingTable } = useSubscriptionDialog()
dialog.showPricingTable()
showPricingTable()
expect(mockShowLayoutDialog).toHaveBeenCalled()
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
})
it('opens dialog on cloud', () => {
mockIsCloud.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
expect(mockShowLayoutDialog).toHaveBeenCalled()
})
})
describe('startTeamWorkspaceUpgradeFlow', () => {
it('closes existing dialogs before opening team workspace dialog', () => {
mockShowTeamWorkspacesDialog.mockResolvedValue(undefined)
const { startTeamWorkspaceUpgradeFlow } = useSubscriptionDialog()
startTeamWorkspaceUpgradeFlow()
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'subscription-required'
})
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'free-tier-info'
})
expect(mockShowTeamWorkspacesDialog).toHaveBeenCalledWith(
expect.any(Function)
)
})
it('persists resume intent to sessionStorage via onConfirm callback', () => {
mockShowTeamWorkspacesDialog.mockResolvedValue(undefined)
const { startTeamWorkspaceUpgradeFlow } = useSubscriptionDialog()
startTeamWorkspaceUpgradeFlow()
const onConfirm = mockShowTeamWorkspacesDialog.mock.calls[0][0]
onConfirm()
expect(sessionStorage.getItem('comfy:resume-team-pricing')).toBe('1')
})
it('reopens pricing table on dialog rejection', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockShowTeamWorkspacesDialog.mockRejectedValue(new Error('dialog error'))
const { startTeamWorkspaceUpgradeFlow } = useSubscriptionDialog()
startTeamWorkspaceUpgradeFlow()
await vi.waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'[useSubscriptionDialog] Failed to open team workspaces dialog:',
expect.any(Error)
)
})
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({ key: 'subscription-required' })
)
consoleSpy.mockRestore()
})
})
describe('resumePendingPricingFlow', () => {
it('does nothing when no resume intent is stored', () => {
const { resumePendingPricingFlow } = useSubscriptionDialog()
resumePendingPricingFlow()
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
})
it('shows pricing table and clears intent when in team workspace', () => {
sessionStorage.setItem('comfy:resume-team-pricing', '1')
mockIsInPersonalWorkspace.value = false
const { resumePendingPricingFlow } = useSubscriptionDialog()
resumePendingPricingFlow()
expect(sessionStorage.getItem('comfy:resume-team-pricing')).toBeNull()
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({ key: 'subscription-required' })
)
})
it('clears intent but does not show pricing if still in personal workspace', () => {
sessionStorage.setItem('comfy:resume-team-pricing', '1')
mockIsInPersonalWorkspace.value = true
const { resumePendingPricingFlow } = useSubscriptionDialog()
resumePendingPricingFlow()
expect(sessionStorage.getItem('comfy:resume-team-pricing')).toBeNull()
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
})
it('consumes intent so second call is a no-op', () => {
sessionStorage.setItem('comfy:resume-team-pricing', '1')
mockIsInPersonalWorkspace.value = false
const { resumePendingPricingFlow } = useSubscriptionDialog()
resumePendingPricingFlow()
mockShowLayoutDialog.mockClear()
resumePendingPricingFlow()
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
})
})
})

View File

@@ -8,6 +8,7 @@ import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspace
const DIALOG_KEY = 'subscription-required'
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
export type SubscriptionDialogReason =
| 'subscription_required'
@@ -42,13 +43,20 @@ export const useSubscriptionDialog = () => {
import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue')
)
const personalProps = {
onClose: hide,
reason: options?.reason,
onChooseTeam: () => startTeamWorkspaceUpgradeFlow()
}
const workspaceProps = {
onClose: hide,
reason: options?.reason
}
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component,
props: {
onClose: hide,
reason: options?.reason
},
props: useWorkspaceVariant ? workspaceProps : personalProps,
dialogComponentProps: {
style: 'width: min(1328px, 95vw); max-height: 958px;',
pt: {
@@ -101,9 +109,58 @@ export const useSubscriptionDialog = () => {
showPricingTable(options)
}
/**
* Start the two-stage team workspace upgrade flow:
* 1. Close the current pricing dialog
* 2. Open the create workspace dialog
* 3. On successful creation, persist a resume intent so the team pricing
* dialog reopens automatically after the page reload
*
* Uses sessionStorage (not a store) because the intent must survive
* a full page reload triggered by workspace switching.
*/
function startTeamWorkspaceUpgradeFlow() {
hide()
dialogService
.showTeamWorkspacesDialog(() => {
try {
sessionStorage.setItem(RESUME_PRICING_KEY, '1')
} catch {
// sessionStorage may be unavailable
}
})
.catch((error) => {
console.error(
'[useSubscriptionDialog] Failed to open team workspaces dialog:',
error
)
showPricingTable()
})
}
/**
* Check for and consume a pending team pricing resume intent.
* Call once after workspace initialization on app boot.
*/
function resumePendingPricingFlow() {
try {
const pending = sessionStorage.getItem(RESUME_PRICING_KEY)
if (!pending) return
sessionStorage.removeItem(RESUME_PRICING_KEY)
if (!workspaceStore.isInPersonalWorkspace) {
showPricingTable()
}
} catch {
// sessionStorage may be unavailable
}
}
return {
show,
showPricingTable,
hide
hide,
startTeamWorkspaceUpgradeFlow,
resumePendingPricingFlow
}
}

View File

@@ -1,5 +1,6 @@
export const PRESERVED_QUERY_NAMESPACES = {
TEMPLATE: 'template',
INVITE: 'invite',
SHARE: 'share'
SHARE: 'share',
CREATE_WORKSPACE: 'create_workspace'
} as const

View File

@@ -1,3 +1,5 @@
import type { PostHogConfig } from 'posthog-js'
import type { TelemetryEventName } from '@/platform/telemetry/types'
/**
@@ -31,6 +33,7 @@ export type RemoteConfig = {
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Partial<PostHogConfig>
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
max_upload_size?: number

View File

@@ -299,12 +299,6 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.Desktop.CloudNotificationShown',
name: 'Cloud notification shown',
type: 'hidden',
defaultValue: false
},
{
id: 'Comfy.Graph.ZoomSpeed',
category: ['LiteGraph', 'Canvas', 'ZoomSpeed'],
@@ -576,10 +570,11 @@ export const CORE_SETTINGS: SettingParams[] = [
category: ['Appearance', 'General'],
name: 'Tab Bar Layout',
type: 'combo',
options: ['Default', 'Integrated'],
tooltip:
'Controls the layout of the tab bar. "Integrated" moves Help and User controls into the tab bar area.',
defaultValue: 'Default'
options: ['Default', 'Legacy'],
tooltip: 'Controls the elements contained in the integrated tab bar.',
defaultValue: 'Default',
migrateDeprecatedValue: (value: unknown) =>
value === 'Integrated' ? 'Default' : value
},
{
id: 'Comfy.UseNewMenu',

View File

@@ -3,7 +3,7 @@ import { isCloud, isNightly } from '@/platform/distribution/types'
/**
* Zendesk ticket form field IDs.
*/
export const ZENDESK_FIELDS = {
const ZENDESK_FIELDS = {
/** Distribution tag (cloud vs OSS) */
DISTRIBUTION: 'tf_42243568391700',
/** User email (anonymous requester) */
@@ -18,13 +18,25 @@ export const ZENDESK_FIELDS = {
* Gets the distribution identifier for Zendesk tracking.
* Helps distinguish feedback from different build types.
*/
export function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
if (isCloud) return 'ccloud'
if (isNightly) return 'oss-nightly'
return 'oss'
}
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
/**
* Builds the feedback form URL with the appropriate distribution tag.
*/
export function buildFeedbackUrl(): string {
const params = new URLSearchParams({
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
[ZENDESK_FIELDS.DISTRIBUTION]: getDistribution()
})
return `${SUPPORT_BASE_URL}?${params.toString()}`
}
/**
* Builds the support URL with optional user information for pre-filling.

View File

@@ -3,7 +3,9 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
@@ -26,7 +28,8 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
WorkflowImportMetadata,
WorkflowSavedMetadata
} from './types'
/**
@@ -156,10 +159,22 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackWorkflowOpened?.(metadata))
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.dispatch((provider) => provider.trackWorkflowSaved?.(metadata))
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.dispatch((provider) => provider.trackDefaultViewSet?.(metadata))
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.dispatch((provider) => provider.trackShareFlow?.(metadata))
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.dispatch((provider) => provider.trackPageVisibilityChanged?.(metadata))
}

View File

@@ -66,4 +66,64 @@ describe('GtmTelemetryProvider', () => {
expect(gtagScripts).toHaveLength(1)
})
it('pushes subscription_success for subscription activation', () => {
window.__CONFIG__ = {
gtm_container_id: 'GTM-TEST123'
}
const provider = new GtmTelemetryProvider()
provider.trackMonthlySubscriptionSucceeded()
const lastEntry = window.dataLayer?.[window.dataLayer.length - 1]
expect(lastEntry).toMatchObject({
event: 'subscription_success'
})
})
it('pushes normalized email as user_data before auth event', () => {
window.__CONFIG__ = {
gtm_container_id: 'GTM-TEST123'
}
const provider = new GtmTelemetryProvider()
provider.trackAuth({
method: 'email',
is_new_user: true,
user_id: 'uid-123',
email: ' Test@Example.com '
})
const dl = window.dataLayer as Record<string, unknown>[]
const userData = dl.find((entry) => 'user_data' in entry)
expect(userData).toMatchObject({
user_data: { email: 'test@example.com' }
})
// Verify user_data is pushed before the sign_up event
const userDataIndex = dl.findIndex((entry) => 'user_data' in entry)
const signUpIndex = dl.findIndex(
(entry) => (entry as Record<string, unknown>).event === 'sign_up'
)
expect(userDataIndex).toBeLessThan(signUpIndex)
})
it('does not push user_data when email is absent', () => {
window.__CONFIG__ = {
gtm_container_id: 'GTM-TEST123'
}
const provider = new GtmTelemetryProvider()
provider.trackAuth({
method: 'google',
is_new_user: false,
user_id: 'uid-456'
})
const dl = window.dataLayer as Record<string, unknown>[]
const userData = dl.find((entry) => 'user_data' in entry)
expect(userData).toBeUndefined()
})
})

View File

@@ -103,6 +103,12 @@ export class GtmTelemetryProvider implements TelemetryProvider {
...(metadata.user_id ? { user_id: metadata.user_id } : {})
}
if (metadata.email) {
window.dataLayer?.push({
user_data: { email: metadata.email.trim().toLowerCase() }
})
}
if (metadata.is_new_user) {
this.pushEvent('sign_up', basePayload)
return
@@ -114,4 +120,8 @@ export class GtmTelemetryProvider implements TelemetryProvider {
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
this.pushEvent('begin_checkout', metadata)
}
trackMonthlySubscriptionSucceeded(): void {
this.pushEvent('subscription_success')
}
}

View File

@@ -1,6 +1,7 @@
import type { OverridedMixpanel } from 'mixpanel-browser'
import { watch } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
checkForCompletedTopup as checkTopupUtil,
@@ -14,7 +15,9 @@ import { getExecutionContext } from '../../utils/getExecutionContext'
import type {
AuthMetadata,
CreditTopupMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionTriggerSource,
ExecutionErrorMetadata,
@@ -39,7 +42,8 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
@@ -275,6 +279,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
trigger_source?: ExecutionTriggerSource
}): void {
const executionContext = getExecutionContext()
const { mode, isAppMode } = useAppMode()
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
@@ -287,7 +292,9 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
api_node_names: executionContext.api_node_names,
has_toolkit_nodes: executionContext.has_toolkit_nodes,
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value
}
this.lastTriggerSource = options?.trigger_source
@@ -358,10 +365,22 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}

View File

@@ -40,12 +40,22 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))
const mockRemoteConfig = vi.hoisted(
() => ({ value: null }) as { value: Record<string, unknown> | null }
)
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: { value: null }
remoteConfig: mockRemoteConfig
}))
vi.mock('posthog-js', () => hoisted.mockPosthog)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
subscriptionTier: { value: null }
})
}))
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
function createProvider(
@@ -61,6 +71,7 @@ function createProvider(
describe('PostHogTelemetryProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRemoteConfig.value = null
window.__CONFIG__ = {
posthog_project_token: 'phc_test_token'
} as typeof window.__CONFIG__
@@ -76,30 +87,39 @@ describe('PostHogTelemetryProvider', () => {
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
it('calls posthog.init with the token and default api_host', async () => {
it('calls posthog.init with the token and default config', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith('phc_test_token', {
api_host: 'https://t.comfy.org',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
})
})
it('uses custom api_host from config when provided', async () => {
window.__CONFIG__ = {
posthog_project_token: 'phc_test_token',
posthog_api_host: 'https://custom.host.com'
} as typeof window.__CONFIG__
new PostHogTelemetryProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith(
'phc_test_token',
expect.objectContaining({ api_host: 'https://custom.host.com' })
expect.objectContaining({
api_host: 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
})
)
})
it('applies posthog_config overrides from remote config', async () => {
mockRemoteConfig.value = {
posthog_config: {
debug: true,
api_host: 'https://custom.host.com'
}
}
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith(
'phc_test_token',
expect.objectContaining({
debug: true,
api_host: 'https://custom.host.com'
})
)
})

View File

@@ -1,13 +1,17 @@
import type { PostHog } from 'posthog-js'
import { watch } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AuthMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
@@ -33,7 +37,8 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { getExecutionContext } from '../../utils/getExecutionContext'
@@ -98,13 +103,17 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
void import('posthog-js')
.then((posthogModule) => {
this.posthog = posthogModule.default
const serverConfig = remoteConfig.value?.posthog_config ?? {}
this.posthog!.init(apiKey, {
api_host:
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
...serverConfig
})
this.isInitialized = true
this.flushEventQueue()
@@ -112,6 +121,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
useCurrentUser().onUserResolved((user) => {
if (this.posthog && user.id) {
this.posthog.identify(user.id)
this.setSubscriptionProperties()
}
})
})
@@ -204,6 +214,19 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
)
}
private setSubscriptionProperties(): void {
const { subscriptionTier } = useSubscription()
watch(
subscriptionTier,
(tier) => {
if (tier && this.posthog) {
this.posthog.people.set({ subscription_tier: tier })
}
},
{ immediate: true }
)
}
trackSignupOpened(): void {
this.trackEvent(TelemetryEvents.USER_SIGN_UP_OPENED)
}
@@ -255,6 +278,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
trigger_source?: ExecutionTriggerSource
}): void {
const executionContext = getExecutionContext()
const { mode, isAppMode } = useAppMode()
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
@@ -267,7 +291,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
api_node_names: executionContext.api_node_names,
has_toolkit_nodes: executionContext.has_toolkit_nodes,
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value
}
this.lastTriggerSource = options?.trigger_source
@@ -342,10 +368,22 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}

View File

@@ -24,6 +24,7 @@ export interface AuthMetadata {
method?: 'email' | 'google' | 'github'
is_new_user?: boolean
user_id?: string
email?: string
referrer_url?: string
utm_source?: string
utm_medium?: string
@@ -63,6 +64,8 @@ export interface RunButtonProperties {
has_toolkit_nodes: boolean
toolkit_node_names: string[]
trigger_source?: ExecutionTriggerSource
view_mode?: string
is_app_mode?: boolean
}
/**
@@ -137,13 +140,38 @@ export interface WorkflowImportMetadata {
/**
* The source of the workflow open/import action
*/
open_source?: 'file_button' | 'file_drop' | 'template' | 'unknown'
open_source?:
| 'file_button'
| 'file_drop'
| 'template'
| 'shared_url'
| 'unknown'
}
export interface EnterLinearMetadata {
source?: string
}
export interface WorkflowSavedMetadata {
is_app: boolean
is_new: boolean
}
export interface DefaultViewSetMetadata {
default_view: 'app' | 'graph'
}
type ShareFlowStep =
| 'dialog_opened'
| 'save_prompted'
| 'link_created'
| 'link_copied'
export interface ShareFlowMetadata {
step: ShareFlowStep
source?: 'app_mode' | 'graph_mode'
}
/**
* Workflow open metadata
*/
@@ -361,7 +389,10 @@ export interface TelemetryProvider {
// Workflow management events
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
trackWorkflowSaved?(metadata: WorkflowSavedMetadata): void
trackDefaultViewSet?(metadata: DefaultViewSetMetadata): void
trackEnterLinear?(metadata: EnterLinearMetadata): void
trackShareFlow?(metadata: ShareFlowMetadata): void
// Page visibility events
trackPageVisibilityChanged?(metadata: PageVisibilityMetadata): void
@@ -447,7 +478,8 @@ export const TelemetryEvents = {
// Workflow Management
WORKFLOW_IMPORTED: 'app:workflow_imported',
WORKFLOW_OPENED: 'app:workflow_opened',
ENTER_LINEAR_MODE: 'app:toggle_linear_mode',
ENTER_LINEAR_MODE: 'app:app_mode_opened',
SHARE_FLOW: 'app:share_flow',
// Page Visibility
PAGE_VISIBILITY_CHANGED: 'app:page_visibility_changed',
@@ -472,6 +504,8 @@ export const TelemetryEvents = {
// Workflow Creation
WORKFLOW_CREATED: 'app:workflow_created',
WORKFLOW_SAVED: 'app:workflow_saved',
DEFAULT_VIEW_SET: 'app:default_view_set',
// Execution Lifecycle
EXECUTION_START: 'execution_start',
@@ -521,4 +555,7 @@ export type TelemetryEventProperties =
| HelpCenterClosedMetadata
| WorkflowCreatedMetadata
| EnterLinearMetadata
| ShareFlowMetadata
| WorkflowSavedMetadata
| DefaultViewSetMetadata
| SubscriptionMetadata

View File

@@ -149,6 +149,8 @@ export const useWorkflowService = () => {
await openWorkflow(tempWorkflow)
await workflowStore.saveWorkflow(tempWorkflow)
}
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })
return true
}
@@ -189,6 +191,7 @@ export const useWorkflowService = () => {
}
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
}
}

View File

@@ -26,17 +26,24 @@ import { refAutoReset } from '@vueuse/core'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useTelemetry } from '@/platform/telemetry'
const { url } = defineProps<{
url: string
}>()
const { copyToClipboard } = useCopyToClipboard()
const { isAppMode } = useAppMode()
const copied = refAutoReset(false, 2000)
async function handleCopy() {
await copyToClipboard(url)
copied.value = true
useTelemetry()?.trackShareFlow({
step: 'link_copied',
source: isAppMode.value ? 'app_mode' : 'graph_mode'
})
}
</script>

View File

@@ -133,9 +133,45 @@
role="tabpanel"
aria-labelledby="tab-publish"
data-testid="publish-tab-panel"
class="min-h-0"
class="flex min-h-0 flex-col gap-4"
>
<template v-if="dialogState === 'loading'">
<Skeleton class="h-3 w-4/5" />
<Skeleton class="h-3 w-3/5" />
<Skeleton class="h-10 w-full" />
</template>
<template v-else-if="dialogState === 'unsaved'">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('comfyHubPublish.unsavedDescription') }}
</p>
<label v-if="isTemporary" class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted-foreground">
{{ $t('shareWorkflow.workflowNameLabel') }}
</span>
<Input
ref="publishNameInputRef"
v-model="workflowName"
:disabled="isSaving"
@keydown.enter="() => handleSave()"
/>
</label>
<Button
variant="primary"
size="lg"
:loading="isSaving"
@click="() => handleSave()"
>
{{
isSaving
? $t('shareWorkflow.saving')
: $t('shareWorkflow.saveButton')
}}
</Button>
</template>
<ComfyHubPublishIntroPanel
v-else
data-testid="publish-intro"
:on-create-profile="handleOpenPublishDialog"
:on-close="onClose"
@@ -167,7 +203,9 @@ import type {
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useAppMode } from '@/composables/useAppMode'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTelemetry } from '@/platform/telemetry'
import { appendJsonExt } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -182,6 +220,11 @@ const publishDialog = useComfyHubPublishDialog()
const shareService = useWorkflowShareService()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const { isAppMode } = useAppMode()
function getShareSource() {
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
}
type DialogState = 'loading' | 'unsaved' | 'ready' | 'shared' | 'stale'
type DialogMode = 'shareLink' | 'publishToHub'
@@ -208,10 +251,15 @@ const dialogMode = ref<DialogMode>('shareLink')
const acknowledged = ref(false)
const workflowName = ref('')
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
const publishNameInputRef = ref<InstanceType<typeof Input> | null>(null)
function focusNameInput() {
nameInputRef.value?.focus()
nameInputRef.value?.select()
function focusActiveNameInput() {
const input =
dialogMode.value === 'publishToHub'
? publishNameInputRef.value
: nameInputRef.value
input?.focus()
input?.select()
}
const isTemporary = computed(
@@ -221,7 +269,7 @@ const isTemporary = computed(
watch(dialogState, async (state) => {
if (state === 'unsaved' && isTemporary.value) {
await nextTick()
focusNameInput()
focusActiveNameInput()
}
})
@@ -248,10 +296,14 @@ function tabButtonClass(mode: DialogMode) {
)
}
function handleDialogModeChange(nextMode: DialogMode) {
async function handleDialogModeChange(nextMode: DialogMode) {
if (nextMode === dialogMode.value) return
if (nextMode === 'publishToHub' && !showPublishToHubTab.value) return
dialogMode.value = nextMode
if (dialogState.value === 'unsaved' && isTemporary.value) {
await nextTick()
focusActiveNameInput()
}
}
watch(showPublishToHubTab, (isVisible) => {
@@ -298,6 +350,10 @@ async function refreshDialogState() {
if (!workflow || workflow.isTemporary || workflow.isModified) {
dialogState.value = 'unsaved'
useTelemetry()?.trackShareFlow({
step: 'save_prompted',
source: getShareSource()
})
if (workflow) {
workflowName.value = stripJsonExtension(workflow.filename)
}
@@ -379,6 +435,10 @@ const {
)
dialogState.value = 'shared'
acknowledged.value = false
useTelemetry()?.trackShareFlow({
step: 'link_created',
source: getShareSource()
})
return result
},

View File

@@ -75,8 +75,23 @@
>
@
</span>
<Input id="profile-username" v-model="username" class="pl-7" />
<Input
id="profile-username"
v-model="username"
class="pl-7"
:aria-invalid="showUsernameError ? 'true' : 'false'"
:aria-describedby="
showUsernameError ? 'profile-username-error' : undefined
"
/>
</div>
<p
v-if="showUsernameError"
id="profile-username-error"
class="text-xs text-destructive-background"
>
{{ $t('comfyHubProfile.usernameError') }}
</p>
</div>
<div class="flex flex-col gap-2">
@@ -105,7 +120,7 @@
<Button
variant="primary"
size="lg"
:disabled="!username.trim() || isCreating"
:disabled="!isUsernameValid || isCreating"
@click="handleCreate"
>
{{
@@ -156,6 +171,16 @@ const profilePictureFile = ref<File | null>(null)
const profilePreviewUrl = useObjectUrl(profilePictureFile)
const isCreating = ref(false)
const VALID_USERNAME_PATTERN = /^[a-z0-9][a-z0-9-]{1,40}[a-z0-9]$/
const isUsernameValid = computed(() =>
VALID_USERNAME_PATTERN.test(username.value)
)
const showUsernameError = computed(
() => username.value.length > 0 && !isUsernameValid.value
)
const profileInitial = computed(() => {
const source = name.value.trim() || username.value.trim()
return source ? source[0].toUpperCase() : 'C'

View File

@@ -0,0 +1,125 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
function mountStep(
props: Partial<InstanceType<typeof ComfyHubDescribeStep>['$props']> = {}
) {
return mount(ComfyHubDescribeStep, {
props: {
name: 'Workflow Name',
description: 'Workflow description',
tags: [],
...props
},
global: {
mocks: {
$t: (key: string) => key
},
stubs: {
Input: {
template:
'<input data-testid="name-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue']
},
Textarea: {
template:
'<textarea data-testid="description-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue']
},
TagsInput: {
template:
'<div data-testid="tags-input" :data-disabled="disabled ? \'true\' : \'false\'"><slot :is-empty="!modelValue || modelValue.length === 0" /></div>',
props: {
modelValue: {
type: Array,
default: () => []
},
disabled: Boolean
}
},
TagsInputItem: {
template:
'<button data-testid="tag-item" :data-value="value" type="button"><slot /></button>',
props: ['value']
},
TagsInputItemText: {
template: '<span data-testid="tag-item-text" />'
},
TagsInputItemDelete: {
template: '<button data-testid="tag-item-delete" type="button" />'
},
TagsInputInput: {
template: '<input data-testid="tags-input-input" />'
},
Button: {
template:
'<button data-testid="toggle-suggestions" type="button"><slot /></button>'
}
}
}
})
}
describe('ComfyHubDescribeStep', () => {
it('emits name and description updates', async () => {
const wrapper = mountStep()
await wrapper.find('[data-testid="name-input"]').setValue('New workflow')
await wrapper
.find('[data-testid="description-input"]')
.setValue('New description')
expect(wrapper.emitted('update:name')).toEqual([['New workflow']])
expect(wrapper.emitted('update:description')).toEqual([['New description']])
})
it('adds a suggested tag when clicked', async () => {
const wrapper = mountStep()
const suggestionButtons = wrapper.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
expect(suggestionButtons.length).toBeGreaterThan(0)
const firstSuggestion = suggestionButtons[0].attributes('data-value')
await suggestionButtons[0].trigger('click')
const tagUpdates = wrapper.emitted('update:tags')
expect(tagUpdates?.at(-1)).toEqual([[firstSuggestion]])
})
it('hides already-selected tags from suggestions', () => {
const selectedTag = COMFY_HUB_TAG_OPTIONS[0]
const wrapper = mountStep({ tags: [selectedTag] })
const suggestionValues = wrapper
.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
.map((button) => button.attributes('data-value'))
expect(suggestionValues).not.toContain(selectedTag)
})
it('toggles between default and full suggestion lists', async () => {
const wrapper = mountStep()
const defaultSuggestions = wrapper.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
expect(defaultSuggestions).toHaveLength(10)
expect(wrapper.text()).toContain('comfyHubPublish.showMoreTags')
await wrapper.find('[data-testid="toggle-suggestions"]').trigger('click')
await wrapper.vm.$nextTick()
const allSuggestions = wrapper.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
expect(allSuggestions).toHaveLength(COMFY_HUB_TAG_OPTIONS.length)
expect(wrapper.text()).toContain('comfyHubPublish.showLessTags')
})
})

View File

@@ -25,35 +25,8 @@
<label class="flex flex-col gap-2">
<span class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.workflowType') }}
</span>
<Select
:model-value="workflowType"
@update:model-value="
emit('update:workflowType', $event as ComfyHubWorkflowType)
"
>
<SelectTrigger>
<SelectValue
:placeholder="$t('comfyHubPublish.workflowTypePlaceholder')"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in workflowTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</label>
<fieldset class="flex flex-col gap-2">
<legend class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.tagsDescription') }}
</legend>
</span>
<TagsInput
v-slot="{ isEmpty }"
always-editing
@@ -67,54 +40,48 @@
</TagsInputItem>
<TagsInputInput :is-empty />
</TagsInput>
<TagsInput
disabled
class="hover-within:bg-transparent bg-transparent p-0 hover:bg-transparent"
</label>
<TagsInput
disabled
class="hover-within:bg-transparent bg-transparent p-0 hover:bg-transparent"
>
<div
v-if="displayedSuggestions.length > 0"
class="flex basis-full flex-wrap gap-2"
>
<div
v-if="displayedSuggestions.length > 0"
class="flex basis-full flex-wrap gap-2"
<TagsInputItem
v-for="tag in displayedSuggestions"
:key="tag"
v-auto-animate
:value="tag"
class="cursor-pointer bg-secondary-background px-2 text-muted-foreground transition-colors select-none hover:bg-secondary-background-selected"
@click="addTag(tag)"
>
<TagsInputItem
v-for="tag in displayedSuggestions"
:key="tag"
v-auto-animate
:value="tag"
class="cursor-pointer bg-secondary-background px-2 text-muted-foreground transition-colors select-none hover:bg-secondary-background-selected"
@click="addTag(tag)"
>
<TagsInputItemText />
</TagsInputItem>
</div>
<Button
v-if="shouldShowSuggestionToggle"
variant="muted-textonly"
size="unset"
class="hover:bg-unset px-0 text-xs"
@click="showAllSuggestions = !showAllSuggestions"
>
{{
$t(
showAllSuggestions
? 'comfyHubPublish.showLessTags'
: 'comfyHubPublish.showMoreTags'
)
}}
</Button>
</TagsInput>
</fieldset>
<TagsInputItemText />
</TagsInputItem>
</div>
<Button
v-if="shouldShowSuggestionToggle"
variant="muted-textonly"
size="unset"
class="hover:bg-unset px-0 text-xs"
@click="showAllSuggestions = !showAllSuggestions"
>
{{
$t(
showAllSuggestions
? 'comfyHubPublish.showLessTags'
: 'comfyHubPublish.showMoreTags'
)
}}
</Button>
</TagsInput>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
@@ -122,46 +89,21 @@ import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
import type { ComfyHubWorkflowType } from '@/platform/workflow/sharing/types/comfyHubTypes'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
const { tags, workflowType } = defineProps<{
const { tags } = defineProps<{
name: string
description: string
workflowType: ComfyHubWorkflowType | ''
tags: string[]
}>()
const emit = defineEmits<{
'update:name': [value: string]
'update:description': [value: string]
'update:workflowType': [value: ComfyHubWorkflowType | '']
'update:tags': [value: string[]]
}>()
const { t } = useI18n()
const workflowTypeOptions = computed(() => [
{
value: 'imageGeneration',
label: t('comfyHubPublish.workflowTypeImageGeneration')
},
{
value: 'videoGeneration',
label: t('comfyHubPublish.workflowTypeVideoGeneration')
},
{
value: 'upscaling',
label: t('comfyHubPublish.workflowTypeUpscaling')
},
{
value: 'editing',
label: t('comfyHubPublish.workflowTypeEditing')
}
])
const INITIAL_TAG_SUGGESTION_COUNT = 10
const showAllSuggestions = ref(false)

View File

@@ -0,0 +1,95 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
vi.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({
draggable: vi.fn(() => vi.fn()),
dropTargetForElements: vi.fn(() => vi.fn()),
monitorForElements: vi.fn(() => vi.fn())
}))
function createImages(count: number): ExampleImage[] {
return Array.from({ length: count }, (_, i) => ({
id: `img-${i}`,
url: `blob:http://localhost/img-${i}`
}))
}
function mountStep(images: ExampleImage[]) {
return mount(ComfyHubExamplesStep, {
props: { exampleImages: images },
global: {
mocks: { $t: (key: string) => key }
}
})
}
describe('ComfyHubExamplesStep', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders all example images', () => {
const wrapper = mountStep(createImages(3))
expect(wrapper.findAll('[role="listitem"]')).toHaveLength(3)
})
it('emits reordered array when moving image left via keyboard', async () => {
const wrapper = mountStep(createImages(3))
const tiles = wrapper.findAll('[role="listitem"]')
await tiles[1].trigger('keydown', { key: 'ArrowLeft', shiftKey: true })
const emitted = wrapper.emitted('update:exampleImages')
expect(emitted).toBeTruthy()
const reordered = emitted![0][0] as ExampleImage[]
expect(reordered.map((img) => img.id)).toEqual(['img-1', 'img-0', 'img-2'])
})
it('emits reordered array when moving image right via keyboard', async () => {
const wrapper = mountStep(createImages(3))
const tiles = wrapper.findAll('[role="listitem"]')
await tiles[1].trigger('keydown', { key: 'ArrowRight', shiftKey: true })
const emitted = wrapper.emitted('update:exampleImages')
expect(emitted).toBeTruthy()
const reordered = emitted![0][0] as ExampleImage[]
expect(reordered.map((img) => img.id)).toEqual(['img-0', 'img-2', 'img-1'])
})
it('does not emit when moving first image left (boundary)', async () => {
const wrapper = mountStep(createImages(3))
const tiles = wrapper.findAll('[role="listitem"]')
await tiles[0].trigger('keydown', { key: 'ArrowLeft', shiftKey: true })
expect(wrapper.emitted('update:exampleImages')).toBeFalsy()
})
it('does not emit when moving last image right (boundary)', async () => {
const wrapper = mountStep(createImages(3))
const tiles = wrapper.findAll('[role="listitem"]')
await tiles[2].trigger('keydown', { key: 'ArrowRight', shiftKey: true })
expect(wrapper.emitted('update:exampleImages')).toBeFalsy()
})
it('emits filtered array when removing an image', async () => {
const wrapper = mountStep(createImages(2))
const removeBtn = wrapper.find(
'button[aria-label="comfyHubPublish.removeExampleImage"]'
)
expect(removeBtn.exists()).toBe(true)
await removeBtn.trigger('click')
const emitted = wrapper.emitted('update:exampleImages')
expect(emitted).toBeTruthy()
expect(emitted![0][0]).toHaveLength(1)
})
})

View File

@@ -1,21 +1,25 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-6">
<p class="text-sm">
<div class="flex min-h-0 flex-1 flex-col">
<p class="text-sm select-none">
{{
$t('comfyHubPublish.examplesDescription', {
selected: selectedExampleIds.length,
total: MAX_EXAMPLES
})
}}
</p>
<div class="grid grid-cols-4 gap-2.5 overflow-y-auto">
<!-- Upload tile -->
<div
role="list"
class="group/grid grid gap-2"
style="grid-template-columns: repeat(auto-fill, 8rem)"
>
<!-- Upload tile (hidden when max images reached) -->
<label
v-if="showUploadTile"
tabindex="0"
role="button"
:aria-label="$t('comfyHubPublish.uploadExampleImage')"
class="focus-visible:outline-ring flex aspect-square h-25 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default text-center transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2"
class="focus-visible:outline-ring flex aspect-square cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default text-center transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2"
@dragenter.stop
@dragleave.stop
@dragover.prevent.stop
@@ -40,83 +44,100 @@
}}</span>
</label>
<!-- Example images -->
<Button
<!-- Example images (drag to reorder) -->
<ReorderableExampleImage
v-for="(image, index) in exampleImages"
:key="image.id"
variant="textonly"
size="unset"
:class="
cn(
'relative h-25 cursor-pointer overflow-hidden rounded-sm p-0',
isSelected(image.id) ? 'ring-ring ring-2' : 'ring-0'
)
"
@click="toggleSelection(image.id)"
>
<img
:src="image.url"
:alt="$t('comfyHubPublish.exampleImage', { index: index + 1 })"
class="size-full object-cover"
/>
<div
v-if="isSelected(image.id)"
class="absolute bottom-1.5 left-1.5 flex size-7 items-center justify-center rounded-full bg-primary-background text-sm font-bold text-base-foreground"
>
{{ selectionIndex(image.id) }}
</div>
</Button>
:image="image"
:index="index"
:total="exampleImages.length"
:instance-id="instanceId"
@remove="removeImage"
@move="moveImage"
@insert-files="insertImagesAt"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { v4 as uuidv4 } from 'uuid'
import { ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
import {
isFileTooLarge,
MAX_IMAGE_SIZE_MB
} from '@/platform/workflow/sharing/utils/validateFileSize'
import { cn } from '@/utils/tailwindUtil'
import ReorderableExampleImage from './ReorderableExampleImage.vue'
const fileInputRef = ref<HTMLInputElement | null>(null)
const MAX_EXAMPLES = 8
const { exampleImages, selectedExampleIds } = defineProps<{
exampleImages: ExampleImage[]
selectedExampleIds: string[]
}>()
const exampleImages = defineModel<ExampleImage[]>('exampleImages', {
required: true
})
const emit = defineEmits<{
'update:exampleImages': [value: ExampleImage[]]
'update:selectedExampleIds': [value: string[]]
}>()
const showUploadTile = computed(() => exampleImages.value.length < MAX_EXAMPLES)
function isSelected(id: string): boolean {
return selectedExampleIds.includes(id)
const instanceId = Symbol('example-images')
let cleanupMonitor = () => {}
onMounted(() => {
cleanupMonitor = monitorForElements({
canMonitor: ({ source }) => source.data.instanceId === instanceId,
onDrop: ({ source, location }) => {
const destination = location.current.dropTargets[0]
if (!destination) return
const fromId = source.data.imageId
const toId = destination.data.imageId
if (typeof fromId !== 'string' || typeof toId !== 'string') return
reorderImages(fromId, toId)
}
})
})
onBeforeUnmount(() => {
cleanupMonitor()
})
function moveByIndex(fromIndex: number, toIndex: number) {
if (fromIndex < 0 || toIndex < 0) return
if (toIndex >= exampleImages.value.length || fromIndex === toIndex) return
const updated = [...exampleImages.value]
const [moved] = updated.splice(fromIndex, 1)
updated.splice(toIndex, 0, moved)
exampleImages.value = updated
}
function selectionIndex(id: string): number {
return selectedExampleIds.indexOf(id) + 1
function reorderImages(fromId: string, toId: string) {
moveByIndex(
exampleImages.value.findIndex((img) => img.id === fromId),
exampleImages.value.findIndex((img) => img.id === toId)
)
}
function toggleSelection(id: string) {
if (isSelected(id)) {
emit(
'update:selectedExampleIds',
selectedExampleIds.filter((sid) => sid !== id)
)
} else if (selectedExampleIds.length < MAX_EXAMPLES) {
emit('update:selectedExampleIds', [...selectedExampleIds, id])
function moveImage(id: string, direction: number) {
const currentIndex = exampleImages.value.findIndex((img) => img.id === id)
moveByIndex(currentIndex, currentIndex + direction)
}
function removeImage(id: string) {
const image = exampleImages.value.find((img) => img.id === id)
if (image?.file) {
URL.revokeObjectURL(image.url)
}
exampleImages.value = exampleImages.value.filter((img) => img.id !== id)
}
function addImages(files: FileList) {
const newImages: ExampleImage[] = Array.from(files)
function createExampleImages(files: FileList): ExampleImage[] {
return Array.from(files)
.filter((f) => f.type.startsWith('image/'))
.filter((f) => !isFileTooLarge(f, MAX_IMAGE_SIZE_MB))
.map((file) => ({
@@ -124,10 +145,51 @@ function addImages(files: FileList) {
url: URL.createObjectURL(file),
file
}))
}
if (newImages.length > 0) {
emit('update:exampleImages', [...exampleImages, ...newImages])
function addImages(files: FileList) {
const remaining = MAX_EXAMPLES - exampleImages.value.length
if (remaining <= 0) return
const created = createExampleImages(files)
const newImages = created.slice(0, remaining)
for (const img of created.slice(remaining)) {
URL.revokeObjectURL(img.url)
}
if (newImages.length > 0) {
exampleImages.value = [...newImages, ...exampleImages.value]
}
}
function insertImagesAt(index: number, files: FileList) {
const created = createExampleImages(files)
if (created.length === 0) return
const updated = [...exampleImages.value]
const safeIndex = Math.min(Math.max(index, 0), updated.length)
const remaining = MAX_EXAMPLES - exampleImages.value.length
const maxInsert =
remaining <= 0 ? Math.max(updated.length - safeIndex, 0) : remaining
const newImages = created.slice(0, maxInsert)
for (const img of created.slice(maxInsert)) {
URL.revokeObjectURL(img.url)
}
if (newImages.length === 0) return
if (remaining <= 0) {
const replacedImages = updated.splice(
safeIndex,
newImages.length,
...newImages
)
for (const img of replacedImages) {
if (img.file) URL.revokeObjectURL(img.url)
}
} else {
updated.splice(safeIndex, 0, ...newImages)
}
exampleImages.value = updated
}
function handleFileSelect(event: Event) {

View File

@@ -0,0 +1,96 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-8 px-6 py-4">
<section class="flex flex-col gap-4">
<span class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.shareAs') }}
</span>
<div
class="flex items-center gap-4 rounded-2xl bg-secondary-background px-6 py-4"
>
<div
class="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full bg-linear-to-b from-green-600/50 to-green-900"
>
<img
v-if="profile.profilePictureUrl"
:src="profile.profilePictureUrl"
:alt="profile.username"
class="size-full rounded-full object-cover"
/>
<span v-else class="text-base text-white">
{{ (profile.name ?? profile.username).charAt(0).toUpperCase() }}
</span>
</div>
<div class="flex flex-1 flex-col gap-2">
<span class="text-sm text-base-foreground">
{{ profile.name ?? profile.username }}
</span>
<span class="text-sm text-muted-foreground">
@{{ profile.username }}
</span>
</div>
</div>
</section>
<section
v-if="isLoadingAssets || hasPrivateAssets"
class="flex flex-col gap-4"
>
<span class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.additionalInfo') }}
</span>
<p
v-if="isLoadingAssets"
class="m-0 text-sm text-muted-foreground italic"
>
{{ $t('shareWorkflow.checkingAssets') }}
</p>
<ShareAssetWarningBox
v-else
v-model:acknowledged="acknowledged"
:items="privateAssets"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { computed, watch } from 'vue'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
import ShareAssetWarningBox from '@/platform/workflow/sharing/components/ShareAssetWarningBox.vue'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
const { profile } = defineProps<{
profile: ComfyHubProfile
}>()
const acknowledged = defineModel<boolean>('acknowledged', { default: false })
const ready = defineModel<boolean>('ready', { default: false })
const shareService = useWorkflowShareService()
const {
state: privateAssets,
isLoading: isLoadingAssets,
error: privateAssetsError
} = useAsyncState(() => shareService.getShareableAssets(), [])
const hasPrivateAssets = computed(() => privateAssets.value.length > 0)
const isReady = computed(
() =>
!isLoadingAssets.value &&
!privateAssetsError.value &&
(!hasPrivateAssets.value || acknowledged.value)
)
watch(
isReady,
(val) => {
ready.value = val
},
{ immediate: true }
)
</script>

View File

@@ -2,6 +2,18 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
useI18n: () => ({ t: (key: string) => key })
}
})
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: vi.fn() })
}))
import ComfyHubPublishDialog from '@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue'
const mockFetchProfile = vi.hoisted(() => vi.fn())
@@ -10,6 +22,11 @@ const mockGoNext = vi.hoisted(() => vi.fn())
const mockGoBack = vi.hoisted(() => vi.fn())
const mockOpenProfileCreationStep = vi.hoisted(() => vi.fn())
const mockCloseProfileCreationStep = vi.hoisted(() => vi.fn())
const mockApplyPrefill = vi.hoisted(() => vi.fn())
const mockCachePublishPrefill = vi.hoisted(() => vi.fn())
const mockGetCachedPrefill = vi.hoisted(() => vi.fn())
const mockSubmitToComfyHub = vi.hoisted(() => vi.fn())
const mockGetPublishStatus = vi.hoisted(() => vi.fn())
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
@@ -28,14 +45,16 @@ vi.mock(
formData: ref({
name: '',
description: '',
workflowType: '',
tags: [],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
selectedExampleIds: []
tutorialUrl: '',
metadata: {}
}),
isFirstStep: ref(false),
isLastStep: ref(true),
@@ -43,17 +62,64 @@ vi.mock(
goNext: mockGoNext,
goBack: mockGoBack,
openProfileCreationStep: mockOpenProfileCreationStep,
closeProfileCreationStep: mockCloseProfileCreationStep
closeProfileCreationStep: mockCloseProfileCreationStep,
applyPrefill: mockApplyPrefill
}),
cachePublishPrefill: mockCachePublishPrefill,
getCachedPrefill: mockGetCachedPrefill
})
)
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubPublishSubmission',
() => ({
useComfyHubPublishSubmission: () => ({
submitToComfyHub: mockSubmitToComfyHub
})
})
)
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
useWorkflowShareService: () => ({
getPublishStatus: mockGetPublishStatus
})
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
renameWorkflow: vi.fn(),
saveWorkflow: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: {
path: 'workflows/test.json',
filename: 'test.json',
directory: 'workflows',
isTemporary: false,
isModified: false
},
saveWorkflow: vi.fn()
})
}))
describe('ComfyHubPublishDialog', () => {
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockFetchProfile.mockResolvedValue(null)
mockSubmitToComfyHub.mockResolvedValue(undefined)
mockGetCachedPrefill.mockReturnValue(null)
mockGetPublishStatus.mockResolvedValue({
isPublished: false,
shareId: null,
shareUrl: null,
publishedAt: null,
prefill: null
})
})
function createWrapper() {
@@ -78,14 +144,16 @@ describe('ComfyHubPublishDialog', () => {
},
ComfyHubPublishWizardContent: {
template:
'<div><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /></div>',
'<div :data-is-publishing="$props.isPublishing"><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /><button data-testid="publish" @click="$props.onPublish()" /></div>',
props: [
'currentStep',
'formData',
'isFirstStep',
'isLastStep',
'isPublishing',
'onGoNext',
'onGoBack',
'onPublish',
'onRequireProfile',
'onGateComplete',
'onGateClose'
@@ -136,4 +204,72 @@ describe('ComfyHubPublishDialog', () => {
expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
expect(onClose).not.toHaveBeenCalled()
})
it('closes dialog after successful publish', async () => {
const wrapper = createWrapper()
await flushPromises()
await wrapper.find('[data-testid="publish"]').trigger('click')
await flushPromises()
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
it('applies prefill when workflow is already published with metadata', async () => {
mockGetPublishStatus.mockResolvedValue({
isPublished: true,
shareId: 'abc123',
shareUrl: 'http://localhost/?share=abc123',
publishedAt: new Date(),
prefill: {
description: 'Existing description',
tags: ['art', 'upscale'],
thumbnailType: 'video',
sampleImageUrls: ['https://example.com/img1.png']
}
})
createWrapper()
await flushPromises()
expect(mockApplyPrefill).toHaveBeenCalledWith({
description: 'Existing description',
tags: ['art', 'upscale'],
thumbnailType: 'video',
sampleImageUrls: ['https://example.com/img1.png']
})
})
it('does not apply prefill when workflow is not published', async () => {
createWrapper()
await flushPromises()
expect(mockApplyPrefill).not.toHaveBeenCalled()
})
it('does not apply prefill when status has no prefill data', async () => {
mockGetPublishStatus.mockResolvedValue({
isPublished: true,
shareId: 'abc123',
shareUrl: 'http://localhost/?share=abc123',
publishedAt: new Date(),
prefill: null
})
createWrapper()
await flushPromises()
expect(mockApplyPrefill).not.toHaveBeenCalled()
})
it('silently ignores prefill fetch errors', async () => {
mockGetPublishStatus.mockRejectedValue(new Error('Network error'))
createWrapper()
await flushPromises()
expect(mockApplyPrefill).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
})

View File

@@ -12,44 +12,106 @@
</template>
<template #leftPanel>
<ComfyHubPublishNav :current-step @step-click="goToStep" />
<ComfyHubPublishNav
v-if="!needsSave"
:current-step
@step-click="goToStep"
/>
</template>
<template #header />
<template #content>
<div v-if="needsSave" class="flex flex-col gap-4 p-6">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('comfyHubPublish.unsavedDescription') }}
</p>
<label v-if="isTemporary" class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted-foreground">
{{ $t('shareWorkflow.workflowNameLabel') }}
</span>
<Input
ref="nameInputRef"
v-model="workflowName"
:disabled="isSaving"
@keydown.enter="() => handleSave()"
/>
</label>
<Button
variant="primary"
size="lg"
:loading="isSaving"
@click="() => handleSave()"
>
{{
isSaving
? $t('shareWorkflow.saving')
: $t('shareWorkflow.saveButton')
}}
</Button>
</div>
<ComfyHubPublishWizardContent
v-else
:current-step
:form-data
:is-first-step
:is-last-step
:is-publishing
:on-update-form-data="updateFormData"
:on-go-next="goNext"
:on-go-back="goBack"
:on-require-profile="handleRequireProfile"
:on-gate-complete="handlePublishGateComplete"
:on-gate-close="handlePublishGateClose"
:on-publish="onClose"
:on-publish="handlePublish"
/>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, provide } from 'vue'
import { useAsyncState } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
provide,
ref,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import ComfyHubPublishNav from '@/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue'
import ComfyHubPublishWizardContent from '@/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.vue'
import { useComfyHubPublishWizard } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
import { useComfyHubPublishSubmission } from '@/platform/workflow/sharing/composables/useComfyHubPublishSubmission'
import {
cachePublishPrefill,
getCachedPrefill,
useComfyHubPublishWizard
} from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
import { appendJsonExt } from '@/utils/formatUtil'
import { OnCloseKey } from '@/types/widgetTypes'
const { onClose } = defineProps<{
onClose: () => void
}>()
const { t } = useI18n()
const toast = useToast()
const { fetchProfile } = useComfyHubProfileGate()
const { submitToComfyHub } = useComfyHubPublishSubmission()
const shareService = useWorkflowShareService()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const {
currentStep,
formData,
@@ -59,8 +121,72 @@ const {
goNext,
goBack,
openProfileCreationStep,
closeProfileCreationStep
closeProfileCreationStep,
applyPrefill
} = useComfyHubPublishWizard()
const isPublishing = ref(false)
const needsSave = ref(false)
const workflowName = ref('')
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
const isTemporary = computed(
() => workflowStore.activeWorkflow?.isTemporary ?? false
)
function checkNeedsSave() {
const workflow = workflowStore.activeWorkflow
needsSave.value = !workflow || workflow.isTemporary || workflow.isModified
if (workflow) {
workflowName.value = workflow.filename.replace(/\.json$/i, '')
}
}
watch(needsSave, async (needs) => {
if (needs && isTemporary.value) {
await nextTick()
nameInputRef.value?.focus()
nameInputRef.value?.select()
}
})
function buildWorkflowPath(directory: string, filename: string): string {
const normalizedDirectory = directory.replace(/\/+$/, '')
const normalizedFilename = appendJsonExt(filename.replace(/\.json$/i, ''))
return normalizedDirectory
? `${normalizedDirectory}/${normalizedFilename}`
: normalizedFilename
}
const { isLoading: isSaving, execute: handleSave } = useAsyncState(
async () => {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
if (workflow.isTemporary) {
const name = workflowName.value.trim()
if (!name) return
const newPath = buildWorkflowPath(workflow.directory, name)
await workflowService.renameWorkflow(workflow, newPath)
await workflowStore.saveWorkflow(workflow)
} else {
await workflowService.saveWorkflow(workflow)
}
checkNeedsSave()
},
undefined,
{
immediate: false,
onError: (error) => {
console.error('Failed to save workflow:', error)
toast.add({
severity: 'error',
summary: t('shareWorkflow.saveFailedTitle'),
detail: t('shareWorkflow.saveFailedDescription')
})
}
}
)
function handlePublishGateComplete() {
closeProfileCreationStep()
@@ -75,18 +201,67 @@ function handleRequireProfile() {
openProfileCreationStep()
}
async function handlePublish(): Promise<void> {
if (isPublishing.value) {
return
}
isPublishing.value = true
try {
await submitToComfyHub(formData.value)
const path = workflowStore.activeWorkflow?.path
if (path) {
cachePublishPrefill(path, formData.value)
}
onClose()
} catch (error) {
console.error('Failed to publish workflow:', error)
toast.add({
severity: 'error',
summary: t('comfyHubPublish.publishFailedTitle'),
detail: t('comfyHubPublish.publishFailedDescription')
})
} finally {
isPublishing.value = false
}
}
function updateFormData(patch: Partial<ComfyHubPublishFormData>) {
formData.value = { ...formData.value, ...patch }
}
async function fetchPublishPrefill() {
const path = workflowStore.activeWorkflow?.path
if (!path) return
try {
const status = await shareService.getPublishStatus(path)
const prefill = status.isPublished
? (status.prefill ?? getCachedPrefill(path))
: getCachedPrefill(path)
if (prefill) {
applyPrefill(prefill)
}
} catch (error) {
console.warn('Failed to fetch publish prefill:', error)
const cached = getCachedPrefill(path)
if (cached) {
applyPrefill(cached)
}
}
}
onMounted(() => {
// Prefetch profile data in the background so finish-step profile context is ready.
checkNeedsSave()
void fetchProfile()
void fetchPublishPrefill()
})
onBeforeUnmount(() => {
for (const image of formData.value.exampleImages) {
URL.revokeObjectURL(image.url)
if (image.file) {
URL.revokeObjectURL(image.url)
}
}
})

View File

@@ -1,26 +1,30 @@
<template>
<footer class="flex shrink items-center justify-between py-2">
<div>
<Button v-if="!isFirstStep" size="lg" @click="$emit('back')">
{{ $t('comfyHubPublish.back') }}
</Button>
</div>
<div class="flex gap-4">
<Button v-if="!isLastStep" size="lg" @click="$emit('next')">
{{ $t('comfyHubPublish.next') }}
<i class="icon-[lucide--chevron-right] size-4" />
</Button>
<Button
v-else
variant="primary"
size="lg"
:disabled="isPublishDisabled"
@click="$emit('publish')"
>
<i class="icon-[lucide--upload] size-4" />
{{ $t('comfyHubPublish.publishButton') }}
</Button>
</div>
<footer
class="flex shrink items-center justify-end gap-4 border-t border-border-default px-6 py-4"
>
<Button v-if="!isFirstStep" size="lg" @click="$emit('back')">
{{ $t('comfyHubPublish.back') }}
</Button>
<Button
v-if="!isLastStep"
variant="primary"
size="lg"
@click="$emit('next')"
>
{{ $t('comfyHubPublish.next') }}
<i class="icon-[lucide--chevron-right] size-4" />
</Button>
<Button
v-else
variant="primary"
size="lg"
:disabled="isPublishDisabled || isPublishing"
:loading="isPublishing"
@click="$emit('publish')"
>
<i class="icon-[lucide--upload] size-4" />
{{ $t('comfyHubPublish.publishButton') }}
</Button>
</footer>
</template>
@@ -31,6 +35,7 @@ defineProps<{
isFirstStep: boolean
isLastStep: boolean
isPublishDisabled?: boolean
isPublishing?: boolean
}>()
defineEmits<{

View File

@@ -1,6 +1,6 @@
<template>
<nav class="flex flex-col gap-6 px-3 py-4">
<ol class="flex flex-col">
<ol class="flex list-none flex-col p-0">
<li
v-for="step in steps"
:key="step.name"

View File

@@ -8,13 +8,20 @@ import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/
const mockCheckProfile = vi.hoisted(() => vi.fn())
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockHasProfile = ref<boolean | null>(true)
const mockIsFetchingProfile = ref(false)
const mockProfile = ref<{ username: string; name?: string } | null>({
username: 'testuser',
name: 'Test User'
})
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
() => ({
useComfyHubProfileGate: () => ({
checkProfile: mockCheckProfile,
hasProfile: mockHasProfile
hasProfile: mockHasProfile,
isFetchingProfile: mockIsFetchingProfile,
profile: mockProfile
})
})
)
@@ -39,14 +46,16 @@ function createDefaultFormData(): ComfyHubPublishFormData {
return {
name: 'Test Workflow',
description: '',
workflowType: '',
tags: [],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
selectedExampleIds: []
tutorialUrl: '',
metadata: {}
}
}
@@ -61,8 +70,11 @@ describe('ComfyHubPublishWizardContent', () => {
beforeEach(() => {
vi.clearAllMocks()
onPublish.mockResolvedValue(undefined)
mockCheckProfile.mockResolvedValue(true)
mockHasProfile.value = true
mockIsFetchingProfile.value = false
mockProfile.value = { username: 'testuser', name: 'Test User' }
mockFlags.comfyHubProfileGateEnabled = true
})
@@ -99,9 +111,23 @@ describe('ComfyHubPublishWizardContent', () => {
template: '<div data-testid="publish-gate-flow" />',
props: ['onProfileCreated', 'onClose', 'showCloseButton']
},
Skeleton: {
template: '<div class="skeleton" />'
},
ComfyHubDescribeStep: {
template: '<div data-testid="describe-step" />'
},
ComfyHubFinishStep: {
template: '<div data-testid="finish-step" />',
props: ['profile', 'acknowledged', 'ready'],
emits: ['update:ready', 'update:acknowledged'],
setup(
_: unknown,
{ emit }: { emit: (e: string, v: boolean) => void }
) {
emit('update:ready', true)
}
},
ComfyHubExamplesStep: {
template: '<div data-testid="examples-step" />'
},
@@ -115,8 +141,13 @@ describe('ComfyHubPublishWizardContent', () => {
},
ComfyHubPublishFooter: {
template:
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
props: ['isFirstStep', 'isLastStep', 'isPublishDisabled'],
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled" :data-is-publishing="isPublishing"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
props: [
'isFirstStep',
'isLastStep',
'isPublishDisabled',
'isPublishing'
],
emits: ['publish', 'next', 'back']
}
}
@@ -124,43 +155,19 @@ describe('ComfyHubPublishWizardContent', () => {
})
}
describe('handlePublish — double-click guard', () => {
it('prevents concurrent publish calls', async () => {
let resolveCheck!: (v: boolean) => void
mockCheckProfile.mockReturnValue(
new Promise<boolean>((resolve) => {
resolveCheck = resolve
})
)
function createDeferred<T>() {
let resolve: (value: T) => void = () => {}
let reject: (error: unknown) => void = () => {}
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await publishBtn.trigger('click')
resolveCheck(true)
await flushPromises()
expect(mockCheckProfile).toHaveBeenCalledTimes(1)
expect(onPublish).toHaveBeenCalledTimes(1)
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
})
describe('handlePublish — feature flag bypass', () => {
it('calls onPublish directly when profile gate is disabled', async () => {
mockFlags.comfyHubProfileGateEnabled = false
return { promise, resolve, reject }
}
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(mockCheckProfile).not.toHaveBeenCalled()
expect(onPublish).toHaveBeenCalledOnce()
})
})
describe('handlePublish — profile check routing', () => {
describe('handlePublish - profile check routing', () => {
it('calls onPublish when profile exists', async () => {
mockCheckProfile.mockResolvedValue(true)
@@ -197,20 +204,83 @@ describe('ComfyHubPublishWizardContent', () => {
expect(onRequireProfile).not.toHaveBeenCalled()
})
it('resets guard after checkProfile error so retry is possible', async () => {
mockCheckProfile.mockRejectedValueOnce(new Error('Network error'))
it('calls onPublish directly when profile gate is disabled', async () => {
mockFlags.comfyHubProfileGateEnabled = false
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(mockCheckProfile).not.toHaveBeenCalled()
expect(onPublish).toHaveBeenCalledOnce()
})
})
describe('handlePublish - async submission', () => {
it('prevents duplicate publish submissions while in-flight', async () => {
const publishDeferred = createDeferred<void>()
onPublish.mockReturnValue(publishDeferred.promise)
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledTimes(1)
publishDeferred.resolve(undefined)
await flushPromises()
})
it('calls onPublish and does not close when publish request fails', async () => {
const publishError = new Error('Publish failed')
onPublish.mockRejectedValueOnce(publishError)
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledOnce()
expect(mockToastErrorHandler).toHaveBeenCalledWith(publishError)
expect(onGateClose).not.toHaveBeenCalled()
})
it('shows publish disabled while submitting', async () => {
const publishDeferred = createDeferred<void>()
onPublish.mockReturnValue(publishDeferred.promise)
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).not.toHaveBeenCalled()
mockCheckProfile.mockResolvedValue(true)
const footer = wrapper.find('[data-testid="publish-footer"]')
expect(footer.attributes('data-publish-disabled')).toBe('true')
expect(footer.attributes('data-is-publishing')).toBe('true')
publishDeferred.resolve(undefined)
await flushPromises()
expect(footer.attributes('data-is-publishing')).toBe('false')
})
it('resets guard after publish error so retry is possible', async () => {
onPublish.mockRejectedValueOnce(new Error('Publish failed'))
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledOnce()
onPublish.mockResolvedValueOnce(undefined)
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledTimes(2)
})
})
@@ -223,9 +293,10 @@ describe('ComfyHubPublishWizardContent', () => {
expect(footer.attributes('data-publish-disabled')).toBe('true')
})
it('enables publish when gate enabled and hasProfile is true', () => {
it('enables publish when gate enabled and hasProfile is true', async () => {
mockHasProfile.value = true
const wrapper = createWrapper()
await flushPromises()
const footer = wrapper.find('[data-testid="publish-footer"]')
expect(footer.attributes('data-publish-disabled')).toBe('false')

View File

@@ -7,17 +7,15 @@
:on-close="onGateClose"
:show-close-button="false"
/>
<div v-else class="flex min-h-0 flex-1 flex-col px-6 pt-4 pb-2">
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<ComfyHubDescribeStep
v-if="currentStep === 'describe'"
:name="formData.name"
:description="formData.description"
:workflow-type="formData.workflowType"
:tags="formData.tags"
@update:name="onUpdateFormData({ name: $event })"
@update:description="onUpdateFormData({ description: $event })"
@update:workflow-type="onUpdateFormData({ workflowType: $event })"
@update:tags="onUpdateFormData({ tags: $event })"
/>
<div
@@ -37,13 +35,22 @@
/>
<ComfyHubExamplesStep
:example-images="formData.exampleImages"
:selected-example-ids="formData.selectedExampleIds"
@update:example-images="onUpdateFormData({ exampleImages: $event })"
@update:selected-example-ids="
onUpdateFormData({ selectedExampleIds: $event })
"
/>
</div>
<div
v-else-if="currentStep === 'finish' && isProfileLoading"
class="flex min-h-0 flex-1 flex-col gap-4 px-6 py-4"
>
<Skeleton class="h-4 w-1/4" />
<Skeleton class="h-20 w-full rounded-2xl" />
</div>
<ComfyHubFinishStep
v-else-if="currentStep === 'finish' && hasProfile && profile"
v-model:ready="finishStepReady"
v-model:acknowledged="assetsAcknowledged"
:profile
/>
<ComfyHubProfilePromptPanel
v-else-if="currentStep === 'finish'"
@request-profile="onRequireProfile"
@@ -53,6 +60,7 @@
:is-first-step
:is-last-step
:is-publish-disabled
:is-publishing="isPublishInFlight"
@back="onGoBack"
@next="onGoNext"
@publish="handlePublish"
@@ -70,8 +78,10 @@ import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/
import ComfyHubCreateProfileForm from '@/platform/workflow/sharing/components/profile/ComfyHubCreateProfileForm.vue'
import type { ComfyHubPublishStep } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
import ComfyHubFinishStep from './ComfyHubFinishStep.vue'
import ComfyHubProfilePromptPanel from './ComfyHubProfilePromptPanel.vue'
import ComfyHubThumbnailStep from './ComfyHubThumbnailStep.vue'
import ComfyHubPublishFooter from './ComfyHubPublishFooter.vue'
@@ -81,6 +91,7 @@ const {
formData,
isFirstStep,
isLastStep,
isPublishing = false,
onGoNext,
onGoBack,
onUpdateFormData,
@@ -93,10 +104,11 @@ const {
formData: ComfyHubPublishFormData
isFirstStep: boolean
isLastStep: boolean
isPublishing?: boolean
onGoNext: () => void
onGoBack: () => void
onUpdateFormData: (patch: Partial<ComfyHubPublishFormData>) => void
onPublish: () => void
onPublish: () => Promise<void>
onRequireProfile: () => void
onGateComplete?: () => void
onGateClose?: () => void
@@ -104,24 +116,42 @@ const {
const { toastErrorHandler } = useErrorHandling()
const { flags } = useFeatureFlags()
const { checkProfile, hasProfile } = useComfyHubProfileGate()
const { checkProfile, hasProfile, isFetchingProfile, profile } =
useComfyHubProfileGate()
const isProfileLoading = computed(
() => hasProfile.value === null || isFetchingProfile.value
)
const finishStepReady = ref(false)
const assetsAcknowledged = ref(false)
const isResolvingPublishAccess = ref(false)
const isPublishInFlight = computed(
() => isPublishing || isResolvingPublishAccess.value
)
const isFinishStepVisible = computed(
() =>
currentStep === 'finish' &&
hasProfile.value === true &&
profile.value !== null
)
const isPublishDisabled = computed(
() => flags.comfyHubProfileGateEnabled && hasProfile.value !== true
() =>
isPublishInFlight.value ||
(flags.comfyHubProfileGateEnabled && hasProfile.value !== true) ||
(isFinishStepVisible.value && !finishStepReady.value)
)
async function handlePublish() {
if (isResolvingPublishAccess.value) {
return
}
if (!flags.comfyHubProfileGateEnabled) {
onPublish()
if (isResolvingPublishAccess.value || isPublishing) {
return
}
isResolvingPublishAccess.value = true
try {
if (!flags.comfyHubProfileGateEnabled) {
await onPublish()
return
}
let profileExists: boolean
try {
profileExists = await checkProfile()
@@ -131,11 +161,13 @@ async function handlePublish() {
}
if (profileExists) {
onPublish()
await onPublish()
return
}
onRequireProfile()
} catch (error) {
toastErrorHandler(error)
} finally {
isResolvingPublishAccess.value = false
}

View File

@@ -1,9 +1,9 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-6">
<fieldset class="flex flex-col gap-2">
<legend class="text-sm text-base-foreground">
<div class="flex flex-col gap-2">
<span class="text-sm text-base-foreground select-none">
{{ $t('comfyHubPublish.selectAThumbnail') }}
</legend>
</span>
<ToggleGroup
type="single"
:model-value="thumbnailType"
@@ -14,18 +14,19 @@
v-for="option in thumbnailOptions"
:key="option.value"
:value="option.value"
class="h-auto w-full rounded-sm bg-node-component-surface p-2 data-[state=on]:bg-muted-background"
class="flex h-auto w-full gap-2 rounded-sm bg-node-component-surface p-2 font-inter text-base-foreground data-[state=on]:bg-muted-background"
>
<span class="text-center text-sm font-bold text-base-foreground">
<i :class="cn('size-4', option.icon)" />
<span class="text-center text-sm font-bold">
{{ option.label }}
</span>
</ToggleGroupItem>
</ToggleGroup>
</fieldset>
</div>
<div class="flex min-h-0 flex-1 flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
<span class="text-sm text-base-foreground select-none">
{{ uploadSectionLabel }}
</span>
<Button
@@ -40,7 +41,7 @@
<template v-if="thumbnailType === 'imageComparison'">
<div
class="grid flex-1 grid-cols-1 grid-rows-1 place-content-center-safe"
class="grid flex-1 grid-cols-1 grid-rows-1 place-content-center-safe overflow-hidden"
>
<div
v-if="hasBothComparisonImages"
@@ -69,7 +70,7 @@
<div
:class="
cn(
'col-span-full row-span-full flex gap-2',
'col-span-full row-span-full flex items-center-safe justify-center-safe gap-2',
hasBothComparisonImages && 'invisible'
)
"
@@ -80,8 +81,10 @@
:ref="(el) => (comparisonDropRefs[slot.key] = el as HTMLElement)"
:class="
cn(
'flex max-w-1/2 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
comparisonPreviewUrls[slot.key] ? 'self-start' : 'flex-1',
'flex aspect-square h-full min-h-0 flex-[0_1_auto] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed text-center transition-colors',
comparisonPreviewUrls[slot.key]
? 'self-start'
: 'flex-[0_1_1]',
comparisonOverStates[slot.key]
? 'border-muted-foreground'
: 'border-border-default hover:border-muted-foreground'
@@ -123,7 +126,7 @@
ref="singleDropRef"
:class="
cn(
'flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
'm-auto flex aspect-square min-h-0 w-full max-w-48 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed text-center transition-colors',
thumbnailPreviewUrl ? 'self-center p-1' : 'flex-1',
isOverSingleDrop
? 'border-muted-foreground'
@@ -239,15 +242,18 @@ const uploadDropText = computed(() =>
const thumbnailOptions = [
{
value: 'image' as const,
label: t('comfyHubPublish.thumbnailImage')
label: t('comfyHubPublish.thumbnailImage'),
icon: 'icon-[lucide--image]'
},
{
value: 'video' as const,
label: t('comfyHubPublish.thumbnailVideo')
label: t('comfyHubPublish.thumbnailVideo'),
icon: 'icon-[lucide--video]'
},
{
value: 'imageComparison' as const,
label: t('comfyHubPublish.thumbnailImageComparison')
label: t('comfyHubPublish.thumbnailImageComparison'),
icon: 'icon-[lucide--diff]'
}
]

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