Compare commits

..

52 Commits

Author SHA1 Message Date
Comfy Org PR Bot
1b7565559a [backport cloud/1.38] fix: prevent XSS vulnerability in context menu labels (#8923)
Backport of #8887 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8923-backport-cloud-1-38-fix-prevent-XSS-vulnerability-in-context-menu-labels-3096d73d365081baa2dce3b6c8027736)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-17 02:25:07 -08:00
Comfy Org PR Bot
4d1645931c [backport cloud/1.38] fix: clear draft on workflow close to prevent stale state on reopen (#8869)
Backport of #8854 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8869-backport-cloud-1-38-fix-clear-draft-on-workflow-close-to-prevent-stale-state-on-reopen-3076d73d365081a7b0f5f602c2a609ea)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-14 02:58:44 -08:00
Comfy Org PR Bot
2279bc0276 [backport cloud/1.38] fix: default onboardingSurveyEnabled flag to false (#8830)
Backport of #8829 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8830-backport-cloud-1-38-fix-default-onboardingSurveyEnabled-flag-to-false-3056d73d3650817fa52bd33647f04e90)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-12 00:09:30 -08:00
Comfy Org PR Bot
f9e526e4a8 [backport cloud/1.38] feat: invite member upsell for single-seat plans (#8822)
Backport of #8801 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8822-backport-cloud-1-38-feat-invite-member-upsell-for-single-seat-plans-3056d73d3650815586bdfdaba8a7ae2e)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-11 21:51:49 -08:00
Comfy Org PR Bot
7afe45fdf8 [backport cloud/1.38] fix: credit display and top up and other UI display if personal membe… (#8811)
Backport of #8784 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8811-backport-cloud-1-38-fix-credit-display-and-top-up-and-other-UI-display-if-personal-mem-3046d73d365081feb075e6c712aced3e)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
2026-02-11 14:34:42 -08:00
Comfy Org PR Bot
450e75ff83 [backport cloud/1.38] Fix hit detection on vue node slots (#8799)
Backport of #8609 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8799-backport-cloud-1-38-Fix-hit-detection-on-vue-node-slots-3046d73d365081379ed5dcdf359e8610)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-02-10 19:38:54 -08:00
Comfy Org PR Bot
e3391c7895 [backport cloud/1.38] fix(download): Use content-disposition filename (#8796)
Backport of #8785 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8796-backport-cloud-1-38-fix-download-Use-content-disposition-filename-3046d73d365081389899ebc4e6712415)
by [Unito](https://www.unito.io)

Co-authored-by: guill <jacob.e.segal@gmail.com>
2026-02-10 18:58:43 -08:00
Comfy Org PR Bot
6d21aad1f9 [backport cloud/1.38] fix: handle RIFF padding for odd-sized WEBP chunks (#8795)
Backport of #8527 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8795-backport-cloud-1-38-fix-handle-RIFF-padding-for-odd-sized-WEBP-chunks-3046d73d365081cc85bae4ada8541965)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-02-10 18:51:59 -08:00
Comfy Org PR Bot
6b4334759f [backport cloud/1.38] Austin/fix move subgraph input (#8793)
Backport of #8777 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8793-backport-cloud-1-38-Austin-fix-move-subgraph-input-3046d73d365081c1a6c0d1983c433b50)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-02-10 18:51:18 -08:00
Comfy Org PR Bot
9c8f815b05 [backport cloud/1.38] Remove comfy logo splash screen. (#8789)
Backport of #8786 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8789-backport-cloud-1-38-Remove-comfy-logo-splash-screen-3046d73d3650810c8c72d40bf86d900c)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-10 16:54:01 -08:00
Comfy Org PR Bot
e2744868a4 [backport cloud/1.38] feat: sort workspaces (#8776)
Backport of #8770 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8776-backport-cloud-1-38-feat-sort-workspaces-3036d73d36508173bb59f3b4ac8e463f)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
2026-02-10 10:31:40 -08:00
Comfy Org PR Bot
c12aa37599 [backport cloud/1.38] feat: wire renewal_date from cloud billing status (#8755)
Backport of #8754 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8755-backport-cloud-1-38-feat-wire-renewal_date-from-cloud-billing-status-3026d73d3650818e8281cf0778dc0137)
by [Unito](https://www.unito.io)

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-02-09 13:18:27 -08:00
Comfy Org PR Bot
9d89a405cf [backport cloud/1.38] fix: show credit balance for unsubscribed personal workspaces (#8738)
Backport of #8719 to `cloud/1.38`

Automatically created by backport workflow.

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-02-07 20:30:25 -08:00
Comfy Org PR Bot
47d5001833 [backport cloud/1.38] fix(vue-nodes): hide slot labels for reroute nodes with empty names (#8728)
Backport of #8574 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8728-backport-cloud-1-38-fix-vue-nodes-hide-slot-labels-for-reroute-nodes-with-empty-names-3006d73d3650816a9d83d96fe00389b2)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-07 20:03:42 -08:00
Simula_r
9691f5fd00 [backport cloud/1.38] Feat/workspaces 6 billing (#8713)
Backport of #8508 to `cloud/1.38`

Automatically created by manual backport (cherry-pick of
c5431de123).

Conflicts resolved:
-
`src/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts`
— accepted PR version (uses `useBillingContext` mock instead of
pinia/firebase auth)
- `src/services/dialogService.ts` — merged: kept cloud/1.38 eager
imports for dialog components, replaced `TopUpCreditsDialogContent` with
Legacy/Workspace variants, replaced `useSubscription` with
`useBillingContext`, added workspace/legacy component selection in
`showTopUpCreditsDialog`; skipped lazy loader refactor (separate PR, not
part of #8508)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 21:16:21 -08:00
Comfy Org PR Bot
5c43c933d7 [backport cloud/1.38] feat: add model-to-node mappings for cloud asset categories (#8706)
Backport of #8468 to `cloud/1.38`

Automatically created by backport workflow.

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-02-06 16:13:36 -08:00
Comfy Org PR Bot
155548c1b8 [backport cloud/1.38] fix: include subfolder in asset download URL for audio/video files (#8685)
Backport of #8684 to `cloud/1.38`

Automatically created by backport workflow.

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2026-02-06 05:27:09 +01:00
Comfy Org PR Bot
a51ba16e5a [backport cloud/1.38] fix: prevent duplicate context menu items by using content-based comparison (#8617) 2026-02-05 14:58:40 +01:00
Comfy Org PR Bot
1b3127bb0d [backport cloud/1.38] Update manifest.json to remove color properties (#8613)
Backport of #8612 to `cloud/1.38`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-04 20:31:54 +00:00
Comfy Org PR Bot
45a0bf1e89 [backport cloud/1.38] fix: Safari compatibility issues in Secrets panel dialog (#8611)
Backport of #8610 to `cloud/1.38`

Automatically created by backport workflow.

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-02-04 20:09:28 +00:00
AustinMroz
ab96fec96e [backport cloud/1.38] fix: localize node definition filter names and descriptions (#8563)
Backport of #8540 to cloud/1.38

Does not include dev-node changes since that has not been backported.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8563-backport-cloud-1-38-fix-localize-node-definition-filter-names-and-descriptions-2fc6d73d36508171b11cfa01d522438b)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-03 11:02:49 -08:00
Comfy Org PR Bot
aca98890b2 [backport cloud/1.38] feat: remove obsolete model asset feature flags (#8568)
Backport of #8566 to `cloud/1.38`

Automatically created by backport workflow.

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-02-03 03:12:25 +00:00
Comfy Org PR Bot
cb86a1c94e [backport cloud/1.38] fix: dedupe queueStore.update() to prevent race conditions (#8558)
Backport of #8523 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8558-backport-cloud-1-38-fix-dedupe-queueStore-update-to-prevent-race-conditions-2fc6d73d3650812494dec497cc6b01fa)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 17:39:41 -08:00
Luke Mino-Altherr
6eb8aa4820 [backport cloud/1.38] feat: add user secrets management panel (#8560)
Backport of #8473 to cloud/1.38

Cherry-picked merge commit bc19bb60fb

## Conflicts resolved
- `src/locales/en/main.json`: Added missing closing brace for `nightly`
section and accepted PR's new `nodeFilters` and `secrets` sections

Co-authored-by: Amp <amp@ampcode.com>
2026-02-03 01:39:33 +00:00
Comfy Org PR Bot
020ad868ce [backport cloud/1.38] fix: node header on preview has a gap on the right (not flush) (#8556)
Backport of #8487 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8556-backport-cloud-1-38-fix-node-header-on-preview-has-a-gap-on-the-right-not-flush-2fc6d73d3650812e8502edb5aaf64bd2)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-02 17:38:51 -08:00
Comfy Org PR Bot
02b3bb840e [backport cloud/1.38] fix: add Frame Nodes to core menu items for multi-selection context menu (#8554)
Backport of #8524 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8554-backport-cloud-1-38-fix-add-Frame-Nodes-to-core-menu-items-for-multi-selection-context-2fc6d73d365081c9b5c7cd6f6c4d5335)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 17:37:51 -08:00
Comfy Org PR Bot
942ffbe896 [backport cloud/1.38] fix: update reactive ref after merge in imagePreviewStore (#8503)
Backport of #8479 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8503-backport-cloud-1-38-fix-update-reactive-ref-after-merge-in-imagePreviewStore-2f96d73d3650812088c8edfb5b919a27)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Austin Mroz <austin@comfy.org>
2026-01-31 23:40:10 -08:00
Comfy Org PR Bot
d30e1a882e [backport cloud/1.38] fix(cloud): disable legacy node templates feature on cloud (#8504)
Backport of #8462 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8504-backport-cloud-1-38-fix-cloud-disable-legacy-node-templates-feature-on-cloud-2f96d73d3650815993ebc15eb076744e)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-31 23:22:31 -08:00
Comfy Org PR Bot
7fc0b55bb8 [backport cloud/1.38] fix: remove delete account button and direct users to support (#8522)
Backport of #8515 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8522-backport-cloud-1-38-fix-remove-delete-account-button-and-direct-users-to-support-2fa6d73d3650813da43eeb404f9918cd)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-31 20:01:54 -08:00
Comfy Org PR Bot
7d114577b5 [backport cloud/1.38] Update control_after_generate schema (#8507)
Backport of #8505 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8507-backport-cloud-1-38-Update-control_after_generate-schema-2f96d73d365081d399f3f459a843709e)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-30 21:43:00 -08:00
Comfy Org PR Bot
26bf6d324e [backport cloud/1.38] fix: prevent image/video preview reset on dynamic widget addition (#8493)
Backport of #8366 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8493-backport-cloud-1-38-fix-prevent-image-video-preview-reset-on-dynamic-widget-addition-2f86d73d36508192b27fc55a29d6d02e)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 08:37:41 -08:00
Comfy Org PR Bot
77f94a3357 [backport cloud/1.38] fix: properties panel obscures menus in legacy layout (#8491)
Backport of #8474 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8491-backport-cloud-1-38-fix-properties-panel-obscures-menus-in-legacy-layout-2f86d73d365081aea778f7573e1202f8)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 08:23:47 -08:00
Comfy Org PR Bot
29a0071b4e [backport cloud/1.38] Fix: Hide Jobs in Assets Panel when Queue V2 is disabled. (#8486)
Backport of #8450 to `cloud/1.38`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-30 08:03:35 +00:00
AustinMroz
99f85d9b04 Revert matchtype slot reactivity on cloud/1.38 (#8477)
Fixes a bug where canvas functionality is lost if a multitype input
(like the native switch) is added to the graph in litegraph mode.

This issue is properly fixed by #8440, but this single revision is
easier and safer to backport

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8477-Revert-matchtype-slot-reactivity-on-cloud-1-38-2f86d73d365081279285e6d86ded9e43)
by [Unito](https://www.unito.io)
2026-01-29 21:47:12 -08:00
Comfy Org PR Bot
1631f22efb [backport cloud/1.38] feat: add category support for blueprints and protect global blueprints (#8466)
Backport of #8378 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8466-backport-cloud-1-38-feat-add-category-support-for-blueprints-and-protect-global-bluepr-2f86d73d3650814da4b4d8d773d2ffe5)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 19:15:47 -08:00
Comfy Org PR Bot
a5577a7f45 [backport cloud/1.38] Improve template search input performance issue (#8472)
Backport of #8343 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8472-backport-cloud-1-38-Improve-template-search-input-performance-issue-2f86d73d3650815ba8dfef1ea135c4a3)
by [Unito](https://www.unito.io)

Co-authored-by: Kelly Yang <124ykl@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-29 19:12:28 -08:00
Comfy Org PR Bot
519bd2f166 [backport cloud/1.38] fix: default image input for the template is displayed as empty on dropdown selection (#8456)
Backport of #8276 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8456-backport-cloud-1-38-fix-default-image-input-for-the-template-is-displayed-as-empty-on--2f86d73d365081098415f75295ce33eb)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-29 16:59:25 -08:00
Comfy Org PR Bot
679fa1b354 [backport cloud/1.38] feat: add Chatterbox model support for Cloud asset browser (#8453)
Backport of #8418 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8453-backport-cloud-1-38-feat-add-Chatterbox-model-support-for-Cloud-asset-browser-2f86d73d36508158a791f6063485fd3c)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-01-29 16:45:10 -08:00
Comfy Org PR Bot
fa2879b523 [backport cloud/1.38] Fix invalid keybind flash (#8452)
Backport of #8435 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8452-backport-cloud-1-38-Fix-invalid-keybind-flash-2f86d73d365081efbc2ec6ea01e37b80)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 16:34:53 -08:00
Comfy Org PR Bot
7175663efd [backport cloud/1.38] Fix Help Center display in linear mode (#8449)
Backport of #8438 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8449-backport-cloud-1-38-Fix-Help-Center-display-in-linear-mode-2f86d73d365081179c5be03a8d1a522d)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 16:23:03 -08:00
Comfy Org PR Bot
0367e33a76 [backport cloud/1.38] make new queue panel disabled by default (#8446)
Backport of #8444 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8446-backport-cloud-1-38-make-new-queue-panel-disabled-by-default-2f76d73d365081f3a0b3dd32ed39e03c)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-01-29 16:16:11 -08:00
Comfy Org PR Bot
b057f69501 [backport cloud/1.38] fix: dragging (e.g., when selecting text) in Markdown note causes node to drag (#8428)
Backport of #8413 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8428-backport-cloud-1-38-fix-dragging-e-g-when-selecting-text-in-Markdown-note-causes-n-2f76d73d36508132b067e6cee1122dc8)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 10:05:04 -08:00
Comfy Org PR Bot
6e5b72a685 [backport cloud/1.38] fix: use getAuthHeader in createCustomer to support API key auth (#8426)
Backport of #8408 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8426-backport-cloud-1-38-fix-use-getAuthHeader-in-createCustomer-to-support-API-key-auth-2f76d73d365081ad948ffed429f3714b)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 10:04:22 -08:00
Comfy Org PR Bot
ecb778dfe1 [backport cloud/1.38] Disable logs button in sidebar on cloud (#8430)
Backport of #8429 to `cloud/1.38`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 18:04:15 +00:00
Comfy Org PR Bot
e9dc3c0e41 [backport cloud/1.38] fix: add ResizeObserver to fix Preview3D initial render stretch (#8424)
Backport of #8351 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8424-backport-cloud-1-38-fix-add-ResizeObserver-to-fix-Preview3D-initial-render-stretch-2f76d73d365081a9974fe055b2a5894d)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-01-29 10:03:32 -08:00
Comfy Org PR Bot
fc9b78fce3 [backport cloud/1.38] Fix flake hidream test (#8421)
Backport of #8406 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8421-backport-cloud-1-38-Fix-flake-hidream-test-2f76d73d365081ceaff8cf1fd2d3f102)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 08:39:49 -08:00
Comfy Org PR Bot
0466f16b89 [backport cloud/1.38] [bugfix] Fix manager missing node tab with shared composable (#8411)
Backport of #8409 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8411-backport-cloud-1-38-bugfix-Fix-manager-missing-node-tab-with-shared-composable-2f76d73d365081b584b8f6134e624d38)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-01-29 00:05:20 -08:00
Comfy Org PR Bot
3060423e27 [backport cloud/1.38] fix: add null check in getCanvasCenter to prevent crash on asset insert (#8404)
Backport of #8399 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8404-backport-cloud-1-38-fix-add-null-check-in-getCanvasCenter-to-prevent-crash-on-asset-in-2f76d73d365081de80dac9abcc3c53b5)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-01-28 20:45:03 -08:00
Comfy Org PR Bot
dd0b2dc9cb [backport cloud/1.38] fix: increase Vue node resize handle size for better usability (#8395)
Backport of #8391 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8395-backport-cloud-1-38-fix-increase-Vue-node-resize-handle-size-for-better-usability-2f76d73d3650813eb26af9adf00abe2e)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-28 19:50:09 -08:00
Comfy Org PR Bot
0c02573ff9 [backport cloud/1.38] fix: move WorkspaceAuthGate to LayoutDefault for proper re-login hand… (#8389)
Backport of #8381 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8389-backport-cloud-1-38-fix-move-WorkspaceAuthGate-to-LayoutDefault-for-proper-re-login-ha-2f76d73d365081d4ae00fc2cbbf1c362)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 18:41:33 -08:00
Comfy Org PR Bot
0f8925e95a [backport cloud/1.38] feat: Change the card description to the filename (#8380)
Backport of #8348 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8380-backport-cloud-1-38-feat-Change-the-card-description-to-the-filename-2f66d73d365081a989a3f793bb72605d)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-28 15:23:08 -08:00
Comfy Org PR Bot
c8aac69f6c [backport cloud/1.38] CI: Add formatting after generating locales. (#8362)
Backport of #8360 to `cloud/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8362-backport-cloud-1-38-CI-Add-formatting-after-generating-locales-2f66d73d365081c4837bc748aa0abcd5)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-27 22:42:16 -08:00
225 changed files with 9519 additions and 3155 deletions

2
.gitignore vendored
View File

@@ -96,3 +96,5 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -11,7 +11,6 @@ This guide covers patterns and examples for unit testing utilities, composables,
5. [Mocking Utility Functions](#mocking-utility-functions)
6. [Testing with Debounce and Throttle](#testing-with-debounce-and-throttle)
7. [Mocking Node Definitions](#mocking-node-definitions)
8. [Mocking Composables with Reactive State](#mocking-composables-with-reactive-state)
## Testing Vue Composables with Reactivity
@@ -254,79 +253,3 @@ it('should validate node definition', () => {
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
})
```
## Mocking Composables with Reactive State
When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations.
### Rules
1. **Define mocks in the factory function** — Create `vi.fn()` and `ref()` instances directly inside `vi.mock()`, not in `beforeEach`
2. **Use singleton pattern** — The factory runs once; all calls to the composable return the same mock object
3. **Access mocks per-test** — Call the composable directly in each test to get the singleton instance rather than storing in a shared variable
4. **Wrap in `vi.mocked()` for type safety** — Use `vi.mocked(service.method).mockResolvedValue(...)` when configuring
5. **Rely on `vi.resetAllMocks()`** — Resets call counts without recreating instances; ref values may need manual reset if mutated
### Pattern
```typescript
// Example from: src/platform/updates/common/releaseStore.test.ts
import { ref } from 'vue'
vi.mock('@/path/to/composable', () => {
const doSomething = vi.fn()
const isLoading = ref(false)
const error = ref<string | null>(null)
return {
useMyComposable: () => ({
doSomething,
isLoading,
error
})
}
})
describe('MyStore', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should call the composable method', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue({ data: 'test' })
await store.initialize()
expect(service.doSomething).toHaveBeenCalledWith(expectedArgs)
})
it('should handle errors from the composable', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue(null)
service.error.value = 'Something went wrong'
await store.initialize()
expect(store.error).toBe('Something went wrong')
})
})
```
### Anti-patterns
```typescript
// ❌ Don't configure mock return values in beforeEach with shared variable
let mockService: { doSomething: Mock }
beforeEach(() => {
mockService = { doSomething: vi.fn() }
vi.mocked(useMyComposable).mockReturnValue(mockService)
})
// ❌ Don't auto-mock then override — reactive refs won't work correctly
vi.mock('@/path/to/composable')
vi.mocked(useMyComposable).mockReturnValue({ isLoading: ref(false) })
```
```
```

View File

@@ -35,18 +35,6 @@
background-size: cover;
background-repeat: no-repeat;
}
#vue-app:has(#loading-logo) {
display: contents;
color: var(--fg-color);
& #loading-logo {
place-self: center;
font-size: clamp(2px, 1vw, 6px);
line-height: 1;
overflow: hidden;
max-width: 100vw;
border-radius: 20ch;
}
}
.visually-hidden {
position: absolute;
width: 1px;
@@ -65,36 +53,6 @@
<body class="litegraph grid">
<div id="vue-app">
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
<svg
width="520"
height="520"
viewBox="0 0 520 520"
fill="none"
xmlns="http://www.w3.org/2000/svg"
id="loading-logo"
>
<mask
id="mask0_227_285"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="520"
height="520"
>
<path
d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z"
fill="#EEFF30"
/>
</mask>
<g mask="url(#mask0_227_285)">
<rect y="0.751831" width="520" height="520" fill="#172DD7" />
<path
d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z"
fill="#F0FF41"
/>
</g>
</svg>
</div>
<script type="module" src="src/main.ts"></script>
</body>

View File

@@ -10,7 +10,5 @@
"type": "image/svg+xml"
}
],
"display": "standalone",
"background_color": "#172dd7",
"theme_color": "#f0ff41"
"display": "standalone"
}

View File

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

View File

@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { downloadFile } from '@/base/common/downloadUtil'
import {
downloadFile,
extractFilenameFromContentDisposition
} from '@/base/common/downloadUtil'
let mockIsCloud = false
@@ -155,10 +158,14 @@ describe('downloadUtil', () => {
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
@@ -195,5 +202,147 @@ describe('downloadUtil', () => {
expect(createObjectURLSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('uses filename from Content-Disposition header in cloud mode', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(headersMock.get).toHaveBeenCalledWith('Content-Disposition')
expect(mockLink.download).toBe('user-friendly.png')
})
it('uses RFC 5987 filename from Content-Disposition header', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi
.fn()
.mockReturnValue(
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(mockLink.download).toBe('中文.png')
})
it('falls back to provided filename when Content-Disposition is missing', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl, 'my-fallback.png')
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(mockLink.download).toBe('my-fallback.png')
})
})
describe('extractFilenameFromContentDisposition', () => {
it('returns null for null header', () => {
expect(extractFilenameFromContentDisposition(null)).toBeNull()
})
it('returns null for empty header', () => {
expect(extractFilenameFromContentDisposition('')).toBeNull()
})
it('extracts filename from simple quoted format', () => {
const header = 'attachment; filename="test-file.png"'
expect(extractFilenameFromContentDisposition(header)).toBe(
'test-file.png'
)
})
it('extracts filename from unquoted format', () => {
const header = 'attachment; filename=test-file.png'
expect(extractFilenameFromContentDisposition(header)).toBe(
'test-file.png'
)
})
it('extracts filename from RFC 5987 format', () => {
const header = "attachment; filename*=UTF-8''test%20file.png"
expect(extractFilenameFromContentDisposition(header)).toBe(
'test file.png'
)
})
it('prefers RFC 5987 format over simple format', () => {
const header =
'attachment; filename="fallback.png"; filename*=UTF-8\'\'preferred.png'
expect(extractFilenameFromContentDisposition(header)).toBe(
'preferred.png'
)
})
it('handles unicode characters in RFC 5987 format', () => {
const header =
"attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.png"
expect(extractFilenameFromContentDisposition(header)).toBe('中文文件.png')
})
it('falls back to simple format when RFC 5987 decoding fails', () => {
const header =
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%invalid'
expect(extractFilenameFromContentDisposition(header)).toBe('fallback.png')
})
it('handles header with only attachment disposition', () => {
const header = 'attachment'
expect(extractFilenameFromContentDisposition(header)).toBeNull()
})
it('handles case-insensitive filename parameter', () => {
const header = 'attachment; FILENAME="test.png"'
expect(extractFilenameFromContentDisposition(header)).toBe('test.png')
})
})
})

View File

@@ -75,14 +75,57 @@ const extractFilenameFromUrl = (url: string): string | null => {
}
}
/**
* Extract filename from Content-Disposition header
* Handles both simple format: attachment; filename="name.png"
* And RFC 5987 format: attachment; filename="fallback.png"; filename*=UTF-8''encoded%20name.png
* @param header - The Content-Disposition header value
* @returns The extracted filename or null if not found
*/
export function extractFilenameFromContentDisposition(
header: string | null
): string | null {
if (!header) return null
// Try RFC 5987 extended format first (filename*=UTF-8''...)
const extendedMatch = header.match(/filename\*=UTF-8''([^;]+)/i)
if (extendedMatch?.[1]) {
try {
return decodeURIComponent(extendedMatch[1])
} catch {
// Fall through to simple format
}
}
// Try simple quoted format: filename="..."
const quotedMatch = header.match(/filename="([^"]+)"/i)
if (quotedMatch?.[1]) {
return quotedMatch[1]
}
// Try unquoted format: filename=...
const unquotedMatch = header.match(/filename=([^;\s]+)/i)
if (unquotedMatch?.[1]) {
return unquotedMatch[1]
}
return null
}
const downloadViaBlobFetch = async (
href: string,
filename: string
fallbackFilename: string
): Promise<void> => {
const response = await fetch(href)
if (!response.ok) {
throw new Error(`Failed to fetch ${href}: ${response.status}`)
}
// Try to get filename from Content-Disposition header (set by backend)
const contentDisposition = response.headers.get('Content-Disposition')
const headerFilename =
extractFilenameFromContentDisposition(contentDisposition)
const blob = await response.blob()
downloadBlob(filename, blob)
downloadBlob(headerFilename ?? fallbackFilename, blob)
}

View File

@@ -2,8 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
import type { Component } from 'vue'
import { computed, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
@@ -15,7 +14,6 @@ import type {
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isElectron } from '@/utils/envUtil'
@@ -38,17 +36,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
attachTo?: HTMLElement
}
function createWrapper({
pinia = createTestingPinia({ createSpy: vi.fn }),
stubs = {},
attachTo
}: WrapperOptions = {}) {
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -67,21 +55,18 @@ function createWrapper({
})
return mount(TopMenuSection, {
attachTo,
global: {
plugins: [pinia, i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
CurrentUserButton: true,
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
},
...stubs
}
},
directives: {
tooltip: () => {}
@@ -106,7 +91,6 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
})
describe('authentication state', () => {
@@ -167,7 +151,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
await nextTick()
@@ -185,7 +169,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
const commandStore = useCommandStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -201,7 +185,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -215,7 +199,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
@@ -226,84 +210,6 @@ describe('TopMenuSection', () => {
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
describe('inline progress summary', () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
}
it('renders inline progress summary when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(true)
})
it('does not render inline progress summary when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
})
it('teleports inline progress summary when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')
document.body.appendChild(actionbarTarget)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activePromptId = 'prompt-1'
const ComfyActionbarStub = defineComponent({
name: 'ComfyActionbar',
setup(_, { emit }) {
onMounted(() => {
emit('update:progressTarget', actionbarTarget)
})
return () => h('div')
}
})
const wrapper = createWrapper({
pinia,
attachTo: document.body,
stubs: {
ComfyActionbar: ComfyActionbarStub,
QueueInlineProgressSummary: false
}
})
try {
await nextTick()
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
} finally {
wrapper.unmount()
actionbarTarget.remove()
}
})
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })

View File

@@ -1,130 +1,101 @@
<template>
<div
v-if="!workspaceStore.focusMode"
class="ml-1 flex flex-col gap-1 pt-1"
class="ml-1 flex gap-x-0.5 pt-1"
@mouseenter="isTopMenuHovered = true"
@mouseleave="isTopMenuHovered = false"
>
<div class="flex gap-x-0.5">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
<div
ref="actionbarContainerRef"
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu
ref="queueContextMenu"
:model="queueContextMenuItems"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
</div>
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div>
<Teleport
v-if="inlineProgressSummaryTarget"
:to="inlineProgressSummaryTarget"
>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
</Teleport>
<QueueInlineProgressSummary
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
class="pr-1"
:hidden="isQueueOverlayExpanded"
<div
class="actionbar-container pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
@@ -133,7 +104,6 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
@@ -177,15 +147,6 @@ const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const actionbarContainerRef = ref<HTMLElement>()
const isActionbarDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const actionbarPosition = computed(() => settingStore.get('Comfy.UseNewMenu'))
const isActionbarEnabled = computed(
() => actionbarPosition.value !== 'Disabled'
)
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
@@ -203,19 +164,6 @@ const isQueuePanelV2Enabled = computed(() =>
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const shouldShowInlineProgressSummary = computed(
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
)
const progressTarget = ref<HTMLElement | null>(null)
function updateProgressTarget(target: HTMLElement | null) {
progressTarget.value = target
}
const inlineProgressSummaryTarget = computed(() => {
if (!shouldShowInlineProgressSummary.value || !isActionbarFloating.value) {
return null
}
return progressTarget.value
})
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)

View File

@@ -10,7 +10,6 @@
</div>
<Panel
ref="panelRef"
class="pointer-events-auto"
:style="style"
:class="panelClass"
@@ -19,7 +18,7 @@
content: { class: isDocked ? 'p-0' : 'p-1' }
}"
>
<div class="relative flex items-center select-none gap-2">
<div ref="panelRef" class="flex items-center select-none gap-2">
<span
ref="dragHandleRef"
:class="
@@ -44,14 +43,6 @@
</Button>
</div>
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="queueOverlayExpanded"
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
data-testid="queue-inline-progress"
/>
</Teleport>
</div>
</template>
@@ -60,17 +51,14 @@ import {
useDraggable,
useEventListener,
useLocalStorage,
unrefElement,
watchDebounced
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -81,15 +69,6 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
}>()
const emit = defineEmits<{
(event: 'update:progressTarget', target: HTMLElement | null): void
}>()
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const { t } = useI18n()
@@ -97,22 +76,15 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const isQueuePanelV2Enabled = computed(() =>
settingsStore.get('Comfy.Queue.QPOV2')
)
const panelRef = ref<ComponentPublicInstance | null>(null)
const panelElement = computed<HTMLElement | null>(() => {
const element = unrefElement(panelRef)
return element instanceof HTMLElement ? element : null
})
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
const { x, y, style, isDragging } = useDraggable(panelElement, {
const { x, y, style, isDragging } = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
@@ -129,12 +101,11 @@ watchDebounced(
// Set initial position to bottom center
const setInitialPosition = () => {
const panel = panelElement.value
if (panel) {
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
if (menuWidth === 0 || menuHeight === 0) {
return
@@ -210,12 +181,11 @@ watch(
)
const adjustMenuPosition = () => {
const panel = panelElement.value
if (panel) {
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
// Calculate distances to all edges
const distanceLeft = lastDragState.value.x
@@ -282,19 +252,6 @@ const onMouseLeaveDropZone = () => {
}
}
const inlineProgressTarget = computed(() => {
if (!visible.value || !isQueuePanelV2Enabled.value) return null
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
watch(
panelElement,
(target) => {
emit('update:progressTarget', target)
},
{ immediate: true }
)
// Handle drag state changes
watch(isDragging, (dragging) => {
if (dragging) {

View File

@@ -8,10 +8,10 @@
import { computed } from 'vue'
import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()
const currentButton = computed(() =>
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton

View File

@@ -14,12 +14,7 @@
</template>
<template #header>
<SearchBox
v-model="searchQuery"
size="lg"
class="max-w-[384px]"
autofocus
/>
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
</template>
<template #header-right-area>
@@ -772,7 +767,7 @@ useIntersectionObserver(loadTrigger, () => {
// Reset pagination when filters change
watch(
[
searchQuery,
filteredTemplates,
selectedNavItem,
sortBy,
selectedModels,

View File

@@ -158,6 +158,7 @@ import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
@@ -176,8 +177,9 @@ const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { isSubscriptionEnabled } = useSubscription()
const { flags } = useFeatureFlags()
const { isSubscriptionEnabled } = useSubscription()
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
@@ -256,9 +258,15 @@ async function handleBuy() {
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
handleClose(false)
dialogService.showSettingsDialog(
isSubscriptionEnabled() ? 'subscription' : 'credits'
)
// In workspace mode (personal workspace), show workspace settings panel
// Otherwise, show legacy subscription/credits panel
const settingsPanel = flags.teamWorkspacesEnabled
? 'workspace'
: isSubscriptionEnabled()
? 'subscription'
: 'credits'
dialogService.showSettingsDialog(settingsPanel)
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -0,0 +1,295 @@
<template>
<div
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- Header -->
<div class="flex py-8 items-center justify-between px-8">
<h2 class="text-lg font-bold text-base-foreground m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
@click="() => handleClose()"
>
<i class="icon-[lucide--x] size-6" />
</button>
</div>
<p
v-if="isInsufficientCredits"
class="text-sm text-muted-foreground m-0 px-8"
>
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
<!-- Preset amount buttons -->
<div class="px-8">
<h3 class="m-0 text-sm font-normal text-muted-foreground">
{{ $t('credits.topUp.selectAmount') }}
</h3>
<div class="flex gap-2 pt-3">
<Button
v-for="amount in PRESET_AMOUNTS"
:key="amount"
:autofocus="amount === 50"
variant="secondary"
size="lg"
:class="
cn(
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
selectedPreset === amount && 'bg-secondary-background-selected'
)
"
@click="handlePresetClick(amount)"
>
${{ amount }}
</Button>
</div>
</div>
<!-- Amount (USD) / Credits -->
<div class="flex gap-2 px-8 pt-8">
<!-- You Pay -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youPay') }}
</div>
<FormattedNumberStepper
:model-value="payAmount"
:min="0"
:max="MAX_AMOUNT"
:step="getStepAmount"
@update:model-value="handlePayAmountChange"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<span class="shrink-0 text-base font-semibold text-base-foreground"
>$</span
>
</template>
</FormattedNumberStepper>
</div>
<!-- You Get -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youGet') }}
</div>
<FormattedNumberStepper
v-model="creditsModel"
:min="0"
:max="usdToCredits(MAX_AMOUNT)"
:step="getCreditsStepAmount"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
</div>
<!-- Warnings -->
<p
v-if="isBelowMin"
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
})
}}
</p>
<p
v-if="showCeilingWarning"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))
})
}}
<span>{{ $t('credits.topUp.needMore') }}</span>
<a
href="https://www.comfy.org/cloud/enterprise"
target="_blank"
class="ml-1 text-inherit"
>{{ $t('credits.topUp.contactUs') }}</a
>
</p>
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
<Button
:disabled="!isValidAmount || loading || isPolling"
:loading="loading || isPolling"
variant="primary"
size="lg"
class="h-10 justify-center"
@click="handleBuy"
>
{{ $t('subscription.addCredits') }}
</Button>
<div class="flex items-center justify-center gap-1">
<a
:href="pricingUrl"
target="_blank"
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
{{ $t('credits.topUp.viewPricing') }}
<i class="icon-[lucide--external-link] size-4" />
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
const { isInsufficientCredits = false } = defineProps<{
isInsufficientCredits?: boolean
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { fetchBalance } = useBillingContext()
const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
const MAX_AMOUNT = 10000
// State
const selectedPreset = ref<number | null>(50)
const payAmount = ref(50)
const showCeilingWarning = ref(false)
const loading = ref(false)
// Computed
const pricingUrl = computed(() =>
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
)
const creditsModel = computed({
get: () => usdToCredits(payAmount.value),
set: (newCredits: number) => {
payAmount.value = Math.round(creditsToUsd(newCredits))
selectedPreset.value = null
}
})
const isValidAmount = computed(
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
)
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
// Utility functions
function formatNumber(num: number): string {
return num.toLocaleString('en-US')
}
// Step amount functions
function getStepAmount(currentAmount: number): number {
if (currentAmount < 100) return 5
if (currentAmount < 1000) return 50
return 100
}
function getCreditsStepAmount(currentCredits: number): number {
const usdAmount = creditsToUsd(currentCredits)
return usdToCredits(getStepAmount(usdAmount))
}
// Event handlers
function handlePayAmountChange(value: number) {
payAmount.value = value
selectedPreset.value = null
showCeilingWarning.value = false
}
function handlePresetClick(amount: number) {
showCeilingWarning.value = false
payAmount.value = amount
selectedPreset.value = amount
}
function handleClose(clearTracking = true) {
if (clearTracking) {
clearTopupTracking()
}
dialogStore.closeDialog({ key: 'top-up-credits' })
}
async function handleBuy() {
if (loading.value || !isValidAmount.value) return
loading.value = true
try {
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
const amountCents = payAmount.value * 100
const response = await workspaceApi.createTopup(amountCents)
if (response.status === 'completed') {
toast.add({
severity: 'success',
summary: t('credits.topUp.purchaseSuccess'),
life: 5000
})
await fetchBalance()
handleClose(false)
dialogService.showSettingsDialog('workspace')
} else if (response.status === 'pending') {
billingOperationStore.startOperation(response.billing_op_id, 'topup')
} else {
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.unknownError'),
life: 5000
})
}
} catch (error) {
console.error('Purchase failed:', error)
const errorMessage =
error instanceof Error ? error.message : t('credits.topUp.unknownError')
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -265,18 +265,15 @@ function cancelEdit() {
}
async function saveKeybinding() {
if (currentEditingCommand.value && newBindingKeyCombo.value) {
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({
commandId: currentEditingCommand.value.id,
combo: newBindingKeyCombo.value
})
)
if (updated) {
await keybindingService.persistUserKeybindings()
}
}
const commandId = currentEditingCommand.value?.id
const combo = newBindingKeyCombo.value
cancelEdit()
if (!combo || commandId == undefined) return
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
if (updated) await keybindingService.persistUserKeybindings()
}
async function resetKeybinding(commandData: ICommandData) {

View File

@@ -116,9 +116,9 @@ import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
@@ -138,7 +138,7 @@ const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)

View File

@@ -6,14 +6,15 @@
<!-- Section Header -->
<div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span
v-if="uiConfig.showMembersList"
class="text-base font-semibold text-base-foreground"
>
<span class="text-base font-semibold text-base-foreground">
<template v-if="activeView === 'active'">
{{
$t('workspacePanel.members.membersCount', {
count: members.length
count:
isSingleSeatPlan || isPersonalWorkspace
? 1
: members.length,
maxSeats: maxSeats
})
}}
</template>
@@ -27,7 +28,10 @@
</template>
</span>
</div>
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
<div
v-if="uiConfig.showSearch && !isSingleSeatPlan"
class="flex items-start gap-2"
>
<SearchBox
v-model="searchQuery"
:placeholder="$t('g.search')"
@@ -45,14 +49,16 @@
:class="
cn(
'grid w-full items-center py-2',
activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
isSingleSeatPlan
? 'grid-cols-1 py-0'
: activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
)
"
>
<!-- Tab buttons in first column -->
<div class="flex items-center gap-2">
<div v-if="!isSingleSeatPlan" class="flex items-center gap-2">
<Button
:variant="
activeView === 'active' ? 'secondary' : 'muted-textonly'
@@ -101,17 +107,19 @@
<div />
</template>
<template v-else>
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
<template v-if="!isSingleSeatPlan">
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
</template>
</template>
</div>
@@ -166,7 +174,7 @@
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols,
isSingleSeatPlan ? 'grid-cols-1' : uiConfig.membersGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
@@ -206,14 +214,14 @@
</div>
<!-- Join date -->
<span
v-if="uiConfig.showDateColumn"
v-if="uiConfig.showDateColumn && !isSingleSeatPlan"
class="text-sm text-muted-foreground text-right"
>
{{ formatDate(member.joinDate) }}
</span>
<!-- Remove member action (OWNER only, can't remove yourself) -->
<div
v-if="permissions.canRemoveMembers"
v-if="permissions.canRemoveMembers && !isSingleSeatPlan"
class="flex items-center justify-end"
>
<Button
@@ -237,8 +245,29 @@
</template>
</template>
<!-- Upsell Banner -->
<div
v-if="isSingleSeatPlan"
class="flex items-center gap-2 rounded-xl border bg-secondary-background border-border-default px-4 py-3 mt-4 justify-center"
>
<p class="m-0 text-sm text-foreground">
{{
isActiveSubscription
? $t('workspacePanel.members.upsellBannerUpgrade')
: $t('workspacePanel.members.upsellBannerSubscribe')
}}
</p>
<Button
variant="muted-textonly"
class="cursor-pointer underline text-sm"
@click="showSubscriptionDialog()"
>
{{ $t('workspacePanel.members.viewPlans') }}
</Button>
</div>
<!-- Pending Invites -->
<template v-else>
<template v-if="activeView === 'pending'">
<div
v-for="(invite, index) in filteredPendingInvites"
:key="invite.id"
@@ -342,6 +371,8 @@ import SearchBox from '@/components/common/SearchBox.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
PendingInvite,
@@ -367,6 +398,27 @@ const {
} = storeToRefs(workspaceStore)
const { copyInviteLink } = workspaceStore
const { permissions, uiConfig } = useWorkspaceUI()
const {
isActiveSubscription,
subscription,
showSubscriptionDialog,
getMaxSeats
} = useBillingContext()
const maxSeats = computed(() => {
if (isPersonalWorkspace.value) return 1
const tier = subscription.value?.tier
if (!tier) return 1
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return 1
return getMaxSeats(tierKey)
})
const isSingleSeatPlan = computed(() => {
if (isPersonalWorkspace.value) return false
if (!isActiveSubscription.value) return true
return maxSeats.value <= 1
})
const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active')

View File

@@ -63,14 +63,18 @@
<i class="pi pi-sign-out" />
{{ $t('auth.signOut.signOut') }}
</Button>
<Button
<i18n-t
v-if="!isApiKeyLogin"
class="w-fit"
variant="destructive-textonly"
@click="handleDeleteAccount"
keypath="auth.deleteAccount.contactSupport"
tag="p"
class="text-muted text-sm"
>
{{ $t('auth.deleteAccount.deleteAccount') }}
</Button>
<template #email>
<a href="mailto:support@comfy.org" class="underline"
>support@comfy.org</a
>
</template>
</i18n-t>
</div>
</div>
@@ -116,7 +120,6 @@ const {
providerName,
providerIcon,
handleSignOut,
handleSignIn,
handleDeleteAccount
handleSignIn
} = useCurrentUser()
</script>

View File

@@ -55,8 +55,12 @@
"
variant="secondary"
size="lg"
:disabled="isInviteLimitReached"
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
:disabled="!isSingleSeatPlan && isInviteLimitReached"
:class="
!isSingleSeatPlan &&
isInviteLimitReached &&
'opacity-50 cursor-not-allowed'
"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
@@ -129,6 +133,8 @@ import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue'
import { buttonVariants } from '@/components/ui/button/button.variants'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { cn } from '@/utils/tailwindUtil'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
@@ -144,8 +150,19 @@ const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showEditWorkspaceDialog
} = useDialogService()
const { isActiveSubscription, subscription, getMaxSeats } = useBillingContext()
const isSingleSeatPlan = computed(() => {
if (!isActiveSubscription.value) return true
const tier = subscription.value?.tier
if (!tier) return true
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return true
return getMaxSeats(tierKey) <= 1
})
const workspaceStore = useTeamWorkspaceStore()
const {
workspaceName,
@@ -187,11 +204,16 @@ const deleteTooltip = computed(() => {
})
const inviteTooltip = computed(() => {
if (isSingleSeatPlan.value) return null
if (!isInviteLimitReached.value) return null
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
if (isSingleSeatPlan.value) {
showInviteMemberUpsellDialog()
return
}
if (isInviteLimitReached.value) return
showInviteMemberDialog()
}

View File

@@ -0,0 +1,108 @@
<template>
<div
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('subscription.cancelDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
:disabled="isLoading"
@click="onClose"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ description }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" :disabled="isLoading" @click="onClose">
{{ $t('subscription.cancelDialog.keepSubscription') }}
</Button>
<Button
variant="destructive"
size="lg"
:loading="isLoading"
@click="onConfirmCancel"
>
{{ $t('subscription.cancelDialog.confirmCancel') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{
cancelAt?: string
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
const isLoading = ref(false)
const formattedEndDate = computed(() => {
const dateStr = props.cancelAt ?? subscription.value?.endDate
if (!dateStr) return t('subscription.cancelDialog.endOfBillingPeriod')
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
})
const description = computed(() =>
t('subscription.cancelDialog.description', { date: formattedEndDate.value })
)
function onClose() {
if (isLoading.value) return
dialogStore.closeDialog({ key: 'cancel-subscription' })
}
async function onConfirmCancel() {
isLoading.value = true
try {
await cancelSubscription()
await fetchStatus()
dialogStore.closeDialog({ key: 'cancel-subscription' })
toast.add({
severity: 'success',
summary: t('subscription.cancelSuccess'),
life: 5000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -70,31 +70,17 @@
@click="onSelectLink"
/>
<div
class="absolute right-4 top-2 cursor-pointer"
class="absolute right-3 top-2.5 cursor-pointer"
@click="onCopyLink"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<g clip-path="url(#clip0_2127_14348)">
<path
d="M2.66634 10.6666C1.93301 10.6666 1.33301 10.0666 1.33301 9.33325V2.66659C1.33301 1.93325 1.93301 1.33325 2.66634 1.33325H9.33301C10.0663 1.33325 10.6663 1.93325 10.6663 2.66659M6.66634 5.33325H13.333C14.0694 5.33325 14.6663 5.93021 14.6663 6.66658V13.3333C14.6663 14.0696 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0696 5.33301 13.3333V6.66658C5.33301 5.93021 5.92996 5.33325 6.66634 5.33325Z"
stroke="white"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_2127_14348">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
<i
:class="
cn(
'pi size-4',
justCopied ? 'pi-check text-green-500' : 'pi-copy'
)
"
/>
</div>
</div>
</div>
@@ -118,6 +104,7 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -130,6 +117,7 @@ const loading = ref(false)
const email = ref('')
const step = ref<'email' | 'link'>('email')
const generatedLink = ref('')
const justCopied = ref(false)
const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@@ -161,6 +149,10 @@ async function onCreateLink() {
async function onCopyLink() {
try {
await navigator.clipboard.writeText(generatedLink.value)
justCopied.value = true
setTimeout(() => {
justCopied.value = false
}, 759)
toast.add({
severity: 'success',
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),

View File

@@ -0,0 +1,68 @@
<template>
<div
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.titleSingleSeat')
: $t('workspacePanel.inviteUpsellDialog.titleNotSubscribed')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onDismiss"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.messageSingleSeat')
: $t('workspacePanel.inviteUpsellDialog.messageNotSubscribed')
}}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onDismiss">
{{ $t('g.cancel') }}
</Button>
<Button variant="primary" size="lg" @click="onUpgrade">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.upgradeToCreator')
: $t('workspacePanel.inviteUpsellDialog.viewPlans')
}}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
function onDismiss() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
}
function onUpgrade() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
showSubscriptionDialog()
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div>
<h2 class="px-4">
<h2 :class="cn(flags.teamWorkspacesEnabled ? 'px-6' : 'px-4')">
<i class="pi pi-cog" />
<span>{{ $t('g.settings') }}</span>
<Tag
@@ -16,6 +16,9 @@
import Tag from 'primevue/tag'
import { isStaging } from '@/config/staging'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { cn } from '@comfyorg/tailwind-utils'
const { flags } = useFeatureFlags()
</script>
<style scoped>

View File

@@ -149,7 +149,7 @@ import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useNewUserService } from '@/services/useNewUserService'
import { newUserService } from '@/services/newUserService'
import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
@@ -457,9 +457,11 @@ onMounted(async () => {
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
// Wait for both i18n and newUserService in parallel
// (newUserService only needs settings, not i18n)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
useNewUserService().initializeIfNewUser()
newUserService().initializeIfNewUser(settingStore)
])
if (i18nError.value) {
console.warn(

View File

@@ -13,8 +13,6 @@ import {
createMockCanvas,
createMockPositionable
} from '@/utils/__tests__/litegraphTestUtils'
import * as litegraphUtil from '@/utils/litegraphUtil'
import * as nodeFilterUtil from '@/utils/nodeFilterUtil'
function createMockExtensionService(): ReturnType<typeof useExtensionService> {
return {
@@ -291,8 +289,9 @@ describe('SelectionToolbox', () => {
)
})
it('should show mask editor only for single image nodes', () => {
const isImageNodeSpy = vi.spyOn(litegraphUtil, 'isImageNode')
it('should show mask editor only for single image nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isImageNodeSpy = vi.spyOn(mockUtils, 'isImageNode')
// Single image node
isImageNodeSpy.mockReturnValue(true)
@@ -308,8 +307,9 @@ describe('SelectionToolbox', () => {
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
})
it('should show Color picker button only for single Load3D nodes', () => {
const isLoad3dNodeSpy = vi.spyOn(litegraphUtil, 'isLoad3dNode')
it('should show Color picker button only for single Load3D nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isLoad3dNodeSpy = vi.spyOn(mockUtils, 'isLoad3dNode')
// Single Load3D node
isLoad3dNodeSpy.mockReturnValue(true)
@@ -325,9 +325,13 @@ describe('SelectionToolbox', () => {
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
})
it('should show ExecuteButton only when output nodes are selected', () => {
const isOutputNodeSpy = vi.spyOn(nodeFilterUtil, 'isOutputNode')
const filterOutputNodesSpy = vi.spyOn(nodeFilterUtil, 'filterOutputNodes')
it('should show ExecuteButton only when output nodes are selected', async () => {
const mockNodeFilterUtil = await import('@/utils/nodeFilterUtil')
const isOutputNodeSpy = vi.spyOn(mockNodeFilterUtil, 'isOutputNode')
const filterOutputNodesSpy = vi.spyOn(
mockNodeFilterUtil,
'filterOutputNodes'
)
// With output node selected
isOutputNodeSpy.mockReturnValue(true)

View File

@@ -7,7 +7,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -48,7 +47,7 @@ describe('ExecuteButton', () => {
}
})
beforeEach(() => {
beforeEach(async () => {
// Set up Pinia with testing utilities
setActivePinia(
createTestingPinia({
@@ -72,7 +71,10 @@ describe('ExecuteButton', () => {
vi.spyOn(commandStore, 'execute').mockResolvedValue()
// Update the useSelectionState mock
vi.mocked(useSelectionState).mockReturnValue({
const { useSelectionState } = vi.mocked(
await import('@/composables/graph/useSelectionState')
)
useSelectionState.mockReturnValue({
selectedNodes: {
value: mockSelectedNodes
}

View File

@@ -1,6 +1,6 @@
<template>
<!-- Help Center Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<Teleport to="body">
<div
v-if="isHelpCenterVisible"
class="help-center-popup"

View File

@@ -76,14 +76,6 @@ describe('NodePreview', () => {
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
})
it('applies text-ellipsis class to node header for text truncation', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader.classes()).toContain('text-ellipsis')
expect(nodeHeader.classes()).toContain('mr-4')
})
it('sets title attribute on node header with full display name', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')

View File

@@ -10,7 +10,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<div v-else class="_sb_node_preview bg-component-node-background">
<div class="_sb_table">
<div
class="node_header mr-4 text-ellipsis"
class="node_header text-ellipsis"
:title="nodeDef.display_name"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,

View File

@@ -1,75 +0,0 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
const mockProgress = vi.hoisted(() => ({
totalPercent: null as unknown as Ref<number>,
currentNodePercent: null as unknown as Ref<number>
}))
vi.mock('@/composables/queue/useQueueProgress', () => ({
useQueueProgress: () => ({
totalPercent: mockProgress.totalPercent,
currentNodePercent: mockProgress.currentNodePercent
})
}))
const createWrapper = (props: { hidden?: boolean } = {}) =>
mount(QueueInlineProgress, { props })
describe('QueueInlineProgress', () => {
beforeEach(() => {
mockProgress.totalPercent = ref(0)
mockProgress.currentNodePercent = ref(0)
})
it('renders when total progress is non-zero', () => {
mockProgress.totalPercent.value = 12
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('renders when current node progress is non-zero', () => {
mockProgress.currentNodePercent.value = 33
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('does not render when hidden', () => {
mockProgress.totalPercent.value = 45
const wrapper = createWrapper({ hidden: true })
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
it('shows when progress becomes non-zero', async () => {
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
mockProgress.totalPercent.value = 10
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('hides when progress returns to zero', async () => {
mockProgress.totalPercent.value = 10
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
mockProgress.totalPercent.value = 0
mockProgress.currentNodePercent.value = 0
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
})

View File

@@ -1,36 +0,0 @@
<template>
<div
v-if="shouldShow"
aria-hidden="true"
:class="
cn('pointer-events-none absolute inset-0 overflow-hidden', radiusClass)
"
>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${totalPercent}%` }"
/>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${currentNodePercent}%` }"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { cn } from '@/utils/tailwindUtil'
const { hidden = false, radiusClass = 'rounded-[7px]' } = defineProps<{
hidden?: boolean
radiusClass?: string
}>()
const { totalPercent, currentNodePercent } = useQueueProgress()
const shouldShow = computed(
() => !hidden && (totalPercent.value > 0 || currentNodePercent.value > 0)
)
</script>

View File

@@ -1,70 +0,0 @@
<template>
<div v-if="shouldShow" class="flex justify-end">
<div
class="flex items-center whitespace-nowrap text-[0.75rem] leading-[normal] drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
role="status"
aria-live="polite"
aria-atomic="true"
>
<div class="flex items-center text-base-foreground">
<span class="font-normal">
{{ t('sideToolbar.queueProgressOverlay.inlineTotalLabel') }}:
</span>
<span class="w-[5ch] shrink-0 text-right font-bold tabular-nums">
{{ totalPercentFormatted }}
</span>
</div>
<div class="flex items-center text-muted-foreground">
<span
class="w-[16ch] shrink-0 truncate text-right"
:title="currentNodeName"
>
{{ currentNodeName }}:
</span>
<span class="w-[5ch] shrink-0 text-right tabular-nums">
{{ currentNodePercentFormatted }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { st } from '@/i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useExecutionStore } from '@/stores/executionStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const props = defineProps<{
hidden?: boolean
}>()
const { t } = useI18n()
const executionStore = useExecutionStore()
const {
totalPercent,
totalPercentFormatted,
currentNodePercent,
currentNodePercentFormatted
} = useQueueProgress()
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
})
const shouldShow = computed(
() =>
!props.hidden &&
(!executionStore.isIdle ||
totalPercent.value > 0 ||
currentNodePercent.value > 0)
)
</script>

View File

@@ -8,14 +8,12 @@ import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import TabInfo from './info/TabInfo.vue'
@@ -148,12 +146,9 @@ function resolveTitle() {
return groups[0].title || t('rightSidePanel.fallbackGroupTitle')
}
if (nodes.length === 1) {
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(nodes[0], {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
return (
nodes[0].title || nodes[0].type || t('rightSidePanel.fallbackNodeTitle')
)
}
}
return t('rightSidePanel.title', { count: items.length })

View File

@@ -14,8 +14,6 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { GetNodeParentGroupKey } from '../shared'
import WidgetItem from './WidgetItem.vue'
@@ -54,7 +52,7 @@ const rootElement = ref<HTMLElement>()
const widgets = shallowRef(widgetsProp)
watchEffect(() => (widgets.value = widgetsProp))
provide(HideLayoutFieldKey, true)
provide('hideLayoutField', true)
const canvasStore = useCanvasStore()
const { t } = useI18n()

View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -17,7 +15,6 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { renameWidget } from '@/utils/widgetUtil'
@@ -41,7 +38,6 @@ const {
isShownOnParents?: boolean
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isEditing = ref(false)
@@ -63,13 +59,7 @@ const sourceNodeName = computed((): string | null => {
const { graph, nodeId } = widget._overlay
sourceNode = getNodeByExecutionId(graph, nodeId)
}
if (!sourceNode) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(sourceNode, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
return sourceNode ? sourceNode.title || sourceNode.type : null
})
const hasParents = computed(() => parents?.length > 0)

View File

@@ -41,7 +41,7 @@
:is-small="isSmall"
/>
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
<ModeToggle
@@ -65,6 +65,7 @@ import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPa
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'

View File

@@ -2,7 +2,7 @@
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="activeJobItems.length"
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
@@ -65,6 +65,7 @@ import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isActiveJobState } from '@/utils/queueUtil'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
@@ -90,6 +91,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
type AssetGridItem = { key: string; asset: AssetItem }

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex h-full flex-col">
<div
v-if="activeJobItems.length"
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
@@ -133,6 +133,7 @@ import {
} from '@/utils/formatUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
@@ -154,6 +155,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)

View File

@@ -1,11 +1,7 @@
<template>
<div
:class="
cn(
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
props.class
)
"
class="comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col"
:class="props.class"
>
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<Toolbar
@@ -39,8 +35,6 @@
import ScrollPanel from 'primevue/scrollpanel'
import Toolbar from 'primevue/toolbar'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
title: string
class?: string

View File

@@ -13,10 +13,7 @@
severity="danger"
/>
</template>
<template
v-if="nodeDef.name.startsWith(useSubgraphStore().typePrefix)"
#actions
>
<template v-if="isUserBlueprint" #actions>
<Button
variant="destructive"
size="icon-sm"
@@ -128,8 +125,18 @@ const editBlueprint = async () => {
await useSubgraphStore().editBlueprint(props.node.data.name)
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const subgraphStore = useSubgraphStore()
const isUserBlueprint = computed(() => {
const name = nodeDef.value.name
if (!name.startsWith(subgraphStore.typePrefix)) return false
return !subgraphStore.isGlobalBlueprint(
name.slice(subgraphStore.typePrefix.length)
)
})
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
if (!isUserBlueprint.value) return []
return [
{
label: t('g.delete'),
icon: 'pi pi-trash',
@@ -137,15 +144,14 @@ const menuItems = computed<MenuItem[]>(() => {
command: deleteBlueprint
}
]
return items
})
function handleContextMenu(event: Event) {
if (!nodeDef.value.name.startsWith(useSubgraphStore().typePrefix)) return
if (!isUserBlueprint.value) return
menu.value?.show(event)
}
function deleteBlueprint() {
if (!props.node.data) return
void useSubgraphStore().deleteBlueprint(props.node.data.name)
void subgraphStore.deleteBlueprint(props.node.data.name)
}
const nodePreviewStyle = ref<CSSProperties>({

View File

@@ -1,5 +1,13 @@
<template>
<Toast />
<Toast group="billing-operation" position="top-right">
<template #message="slotProps">
<div class="flex items-center gap-2">
<i class="pi pi-spin pi-spinner text-primary" />
<span>{{ slotProps.message.summary }}</span>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">

View File

@@ -0,0 +1,42 @@
<template>
<Toast group="invite-accepted" position="top-right">
<template #message="slotProps">
<div class="flex items-center gap-2 justify-between w-full">
<div class="flex flex-col justify-start">
<div class="text-base">
{{ slotProps.message.summary }}
</div>
<div class="mt-1 text-sm text-foreground">
{{ slotProps.message.detail.text }} <br />
{{ slotProps.message.detail.workspaceName }}
</div>
</div>
<Button
size="md"
variant="inverted"
@click="viewWorkspace(slotProps.message.detail.workspaceId)"
>
{{ t('workspace.viewWorkspace') }}
</Button>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Toast from 'primevue/toast'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
const { t } = useI18n()
const toast = useToast()
const { switchWithConfirmation } = useWorkspaceSwitch()
function viewWorkspace(workspaceId: string) {
void switchWithConfirmation(workspaceId)
toast.removeGroup('invite-accepted')
}
</script>

View File

@@ -64,10 +64,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
// Mock the CurrentUserPopover component
vi.mock('./CurrentUserPopover.vue', () => ({
// Mock the CurrentUserPopoverLegacy component
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
default: {
name: 'CurrentUserPopoverMock',
name: 'CurrentUserPopoverLegacyMock',
render() {
return h('div', 'Popover Content')
},

View File

@@ -45,14 +45,16 @@
class: 'rounded-lg w-80'
}
}"
@show="onPopoverShow"
>
<!-- Workspace mode: workspace-aware popover (only when ready) -->
<CurrentUserPopoverWorkspace
v-if="teamWorkspacesEnabled && initState === 'ready'"
ref="workspacePopoverContent"
@close="closePopover"
/>
<!-- Legacy mode: original popover -->
<CurrentUserPopover
<CurrentUserPopoverLegacy
v-else-if="!teamWorkspacesEnabled"
@close="closePopover"
/>
@@ -75,7 +77,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
import CurrentUserPopover from './CurrentUserPopover.vue'
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
const CurrentUserPopoverWorkspace = defineAsyncComponent(
() => import('./CurrentUserPopoverWorkspace.vue')
@@ -112,8 +114,15 @@ const workspaceName = computed(() => {
})
const popover = ref<InstanceType<typeof Popover> | null>(null)
const workspacePopoverContent = ref<{
refreshBalance: () => void
} | null>(null)
const closePopover = () => {
popover.value?.hide()
}
const onPopoverShow = () => {
workspacePopoverContent.value?.refreshBalance()
}
</script>

View File

@@ -7,7 +7,7 @@ import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopover from './CurrentUserPopover.vue'
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
@@ -172,7 +172,7 @@ vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
}
}))
describe('CurrentUserPopover', () => {
describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuthStoreState.balance = {
@@ -190,7 +190,7 @@ describe('CurrentUserPopover', () => {
messages: { en: enMessages }
})
return mount(CurrentUserPopover, {
return mount(CurrentUserPopoverLegacy, {
global: {
plugins: [i18n],
stubs: {

View File

@@ -55,55 +55,61 @@
/>
</Popover>
<!-- Credits Section (PERSONAL and OWNER only) -->
<template v-if="showCreditsSection">
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
displayedCredits
}}</span>
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
/>
<!-- Subscribed: Show Add Credits button -->
<Button
v-if="isActiveSubscription && isWorkspaceSubscribed"
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Unsubscribed: Show Subscribe button -->
<SubscribeButton
v-else-if="isPersonalWorkspace"
:fluid="false"
:label="$t('workspaceSwitcher.subscribe')"
size="sm"
variant="gradient"
/>
<!-- Non-personal workspace: Navigate to workspace settings -->
<Button
v-else
variant="primary"
size="sm"
@click="handleOpenPlanAndCreditsSettings"
>
{{ $t('workspaceSwitcher.subscribe') }}
</Button>
</div>
<!-- Credits Section -->
<Divider class="mx-0 my-2" />
</template>
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
displayedCredits
}}</span>
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
/>
<!-- Add Credits (subscribed + personal or workspace owner only) -->
<Button
v-if="isActiveSubscription && permissions.canTopUp"
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Subscribe/Resubscribe (only when not subscribed or cancelled) -->
<SubscribeButton
v-if="showSubscribeAction && isPersonalWorkspace"
:fluid="false"
:label="
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
"
size="sm"
variant="gradient"
/>
<Button
v-if="showSubscribeAction && !isPersonalWorkspace"
variant="primary"
size="sm"
@click="handleOpenPlansAndPricing"
>
{{
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
}}
</Button>
</div>
<Divider class="mx-0 my-2" />
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
<div
@@ -196,18 +202,19 @@ import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -219,10 +226,9 @@ const workspaceStore = useTeamWorkspaceStore()
const {
initState,
workspaceName,
isInPersonalWorkspace: isPersonalWorkspace,
isWorkspaceSubscribed
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { workspaceRole } = useWorkspaceUI()
const { permissions } = useWorkspaceUI()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
const emit = defineEmits<{
@@ -233,22 +239,30 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { isActiveSubscription, subscriptionStatus } = useSubscription()
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
useBillingContext()
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
const subscriptionDialog = useSubscriptionDialog()
const { locale } = useI18n()
const isLoadingBalance = isLoading
const displayedCredits = computed(() => {
if (initState.value !== 'ready') return ''
// Only personal workspaces have subscription status from useSubscription()
// Team workspaces don't have backend subscription data yet
if (isPersonalWorkspace.value) {
// Wait for subscription status to load
if (subscriptionStatus.value === null) return ''
return isActiveSubscription.value ? totalCredits.value : '0'
}
return '0'
// API field is named _micros but contains cents (naming inconsistency)
const cents =
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
return formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
})
const canUpgrade = computed(() => {
@@ -258,13 +272,15 @@ const canUpgrade = computed(() => {
})
const showPlansAndPricing = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
() => permissions.value.canManageSubscription
)
const showManagePlan = computed(
() => showPlansAndPricing.value && isActiveSubscription.value
() => permissions.value.canManageSubscription && isActiveSubscription.value
)
const showCreditsSection = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
const showSubscribeAction = computed(
() =>
permissions.value.canManageSubscription &&
(!isActiveSubscription.value || isCancelled.value)
)
const handleOpenUserSettings = () => {
@@ -322,7 +338,9 @@ const toggleWorkspaceSwitcher = (event: MouseEvent) => {
workspaceSwitcherPopover.value?.toggle(event)
}
onMounted(() => {
void authActions.fetchBalance()
})
const refreshBalance = () => {
void fetchBalance()
}
defineExpose({ refreshBalance })
</script>

View File

@@ -113,24 +113,24 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
SubscriptionTier,
WorkspaceRole,
WorkspaceType
} from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
interface AvailableWorkspace {
id: string
name: string
type: WorkspaceType
role: WorkspaceRole
isSubscribed: boolean
subscriptionPlan: SubscriptionPlan
subscriptionPlan: string | null
subscriptionTier: SubscriptionTier | null
}
const emit = defineEmits<{
@@ -140,7 +140,34 @@ const emit = defineEmits<{
const { t } = useI18n()
const { switchWithConfirmation } = useWorkspaceSwitch()
const { subscriptionTierName: userSubscriptionTierName } = useSubscription()
const { subscription } = useBillingContext()
const tierKeyMap: Record<string, string> = {
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDER: 'founder',
FOUNDERS_EDITION: 'founder'
}
function formatTierName(
tier: string | null | undefined,
isYearly: boolean
): string {
if (!tier) return ''
const key = tierKeyMap[tier] ?? 'standard'
const baseName = t(`subscription.tiers.${key}.name`)
return isYearly
? t('subscription.tierNameYearly', { name: baseName })
: baseName
}
const currentSubscriptionTierName = computed(() => {
const tier = subscription.value?.tier
if (!tier) return ''
const isYearly = subscription.value?.duration === 'ANNUAL'
return formatTierName(tier, isYearly)
})
const workspaceStore = useTeamWorkspaceStore()
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
@@ -153,7 +180,8 @@ const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
type: w.type,
role: w.role,
isSubscribed: w.isSubscribed,
subscriptionPlan: w.subscriptionPlan
subscriptionPlan: w.subscriptionPlan,
subscriptionTier: w.subscriptionTier
}))
)
@@ -168,19 +196,32 @@ function getRoleLabel(role: AvailableWorkspace['role']): string {
}
function getTierLabel(workspace: AvailableWorkspace): string | null {
// Personal workspace: use user's subscription tier
if (workspace.type === 'personal') {
return userSubscriptionTierName.value || null
// For the current/active workspace, use billing context directly
// This ensures we always have the most up-to-date subscription info
if (isCurrentWorkspace(workspace)) {
return currentSubscriptionTierName.value || null
}
// Team workspace: use workspace subscription plan
if (!workspace.isSubscribed || !workspace.subscriptionPlan) return null
if (workspace.subscriptionPlan === 'PRO_MONTHLY')
return t('subscription.tiers.pro.name')
if (workspace.subscriptionPlan === 'PRO_YEARLY')
return t('subscription.tierNameYearly', {
name: t('subscription.tiers.pro.name')
})
return null
// For non-active workspaces, use cached store data
if (!workspace.isSubscribed) return null
if (workspace.subscriptionTier) {
return formatTierName(workspace.subscriptionTier, false)
}
if (!workspace.subscriptionPlan) return null
// Parse plan slug (format: TIER_DURATION, e.g. "CREATOR_MONTHLY", "PRO_YEARLY")
const planSlug = workspace.subscriptionPlan
// Extract tier from plan slug (e.g., "CREATOR_MONTHLY" -> "CREATOR")
const tierMatch = Object.keys(tierKeyMap).find((tier) =>
planSlug.startsWith(tier)
)
if (!tierMatch) return null
const isYearly = planSlug.includes('YEARLY') || planSlug.includes('ANNUAL')
return formatTierName(tierMatch, isYearly)
}
async function handleSelectWorkspace(workspace: AvailableWorkspace) {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
import { whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
@@ -10,7 +10,6 @@ defineProps<{
}>()
const feedbackRef = useTemplateRef('feedbackRef')
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
whenever(feedbackRef, () => {
const scriptEl = document.createElement('script')
@@ -19,20 +18,9 @@ whenever(feedbackRef, () => {
})
</script>
<template>
<Button
v-if="isMobile"
as="a"
:href="`https://form.typeform.com/to/${dataTfWidget}`"
target="_blank"
variant="inverted"
class="rounded-full size-12"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
<Popover v-else>
<Popover>
<template #button>
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs">
<Button variant="inverted" class="rounded-full size-12">
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
</template>

View File

@@ -20,9 +20,18 @@ defineOptions({
const {
position = 'popper',
// Safari has issues with click events on portaled content inside dialogs.
// Set disablePortal to true when using Select inside a Dialog on Safari.
// See: https://github.com/chakra-ui/ark/issues/1782
disablePortal = false,
class: className,
...restProps
} = defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>()
} = defineProps<
SelectContentProps & {
class?: HTMLAttributes['class']
disablePortal?: boolean
}
>()
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = computed(() => ({
@@ -34,7 +43,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectPortal :disabled="disablePortal">
<SelectContent
v-bind="{ ...forwarded, ...$attrs }"
:class="

View File

@@ -1,9 +1,6 @@
import { whenever } from '@vueuse/core'
import { computed, watch } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -13,8 +10,6 @@ export const useCurrentUser = () => {
const authStore = useFirebaseAuthStore()
const commandStore = useCommandStore()
const apiKeyStore = useApiKeyAuthStore()
const dialogService = useDialogService()
const { deleteAccount } = useFirebaseAuthActions()
const firebaseUser = computed(() => authStore.currentUser)
const isApiKeyLogin = computed(() => apiKeyStore.isAuthenticated)
@@ -116,18 +111,6 @@ export const useCurrentUser = () => {
await commandStore.execute('Comfy.User.OpenSignInDialog')
}
const handleDeleteAccount = async () => {
const confirmed = await dialogService.confirm({
title: t('auth.deleteAccount.confirmTitle'),
message: t('auth.deleteAccount.confirmMessage'),
type: 'delete'
})
if (confirmed) {
await deleteAccount()
}
}
return {
loading: authStore.loading,
isLoggedIn,
@@ -141,7 +124,6 @@ export const useCurrentUser = () => {
resolvedUserInfo,
handleSignOut,
handleSignIn,
handleDeleteAccount,
onUserResolved,
onTokenRefreshed,
onUserLogout

View File

@@ -2,11 +2,11 @@ import { FirebaseError } from 'firebase/app'
import { AuthErrorCodes } from 'firebase/auth'
import { ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
@@ -83,7 +83,7 @@ export const useFirebaseAuthActions = () => {
)
const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => {
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()
if (!isActiveSubscription.value) return
const response = await authStore.initiateCreditPurchase({
@@ -206,21 +206,6 @@ export const useFirebaseAuthActions = () => {
[createReauthenticationRecovery<[string], void>()]
)
const deleteAccount = wrapWithErrorHandlingAsync(
async () => {
await authStore.deleteAccount()
toastStore.add({
severity: 'success',
summary: t('auth.deleteAccount.success'),
detail: t('auth.deleteAccount.successDetail'),
life: 5000
})
},
reportError,
undefined,
[createReauthenticationRecovery<[], void>()]
)
return {
logout,
sendPasswordReset,
@@ -232,7 +217,6 @@ export const useFirebaseAuthActions = () => {
signInWithEmail,
signUpWithEmail,
updatePassword,
deleteAccount,
accessError,
reportError
}

View File

@@ -0,0 +1,78 @@
import type { ComputedRef, Ref } from 'vue'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
Plan,
PreviewSubscribeResponse,
SubscribeResponse,
SubscriptionDuration,
SubscriptionTier
} from '@/platform/workspace/api/workspaceApi'
export type BillingType = 'legacy' | 'workspace'
export interface SubscriptionInfo {
isActive: boolean
tier: SubscriptionTier | null
duration: SubscriptionDuration | null
planSlug: string | null
renewalDate: string | null
endDate: string | null
isCancelled: boolean
hasFunds: boolean
}
export interface BalanceInfo {
amountMicros: number
currency: string
effectiveBalanceMicros?: number
prepaidBalanceMicros?: number
cloudCreditBalanceMicros?: number
}
export interface BillingActions {
initialize: () => Promise<void>
fetchStatus: () => Promise<void>
fetchBalance: () => Promise<void>
subscribe: (
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) => Promise<SubscribeResponse | void>
previewSubscribe: (
planSlug: string
) => Promise<PreviewSubscribeResponse | null>
manageSubscription: () => Promise<void>
cancelSubscription: () => Promise<void>
fetchPlans: () => Promise<void>
/**
* Ensures billing is initialized and subscription is active.
* Shows subscription dialog if not subscribed.
* Use this in extensions/entry points that require active subscription.
*/
requireActiveSubscription: () => Promise<void>
/**
* Shows the subscription dialog.
*/
showSubscriptionDialog: () => void
}
export interface BillingState {
isInitialized: Ref<boolean>
subscription: ComputedRef<SubscriptionInfo | null>
balance: ComputedRef<BalanceInfo | null>
plans: ComputedRef<Plan[]>
currentPlanSlug: ComputedRef<string | null>
isLoading: Ref<boolean>
error: Ref<string | null>
/**
* Convenience computed for checking if subscription is active.
* Equivalent to `subscription.value?.isActive ?? false`
*/
isActiveSubscription: ComputedRef<boolean>
}
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
getMaxSeats: (tierKey: TierKey) => number
}

View File

@@ -0,0 +1,237 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext'
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] }
})
)
vi.mock('@vueuse/core', async (importOriginal) => {
const original = await importOriginal()
return {
...(original as Record<string, unknown>),
createSharedComposable: (fn: (...args: unknown[]) => unknown) => fn
}
})
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get isInPersonalWorkspace() {
return mockIsPersonal.value
},
get activeWorkspace() {
return mockIsPersonal.value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
},
updateActiveWorkspace: vi.fn()
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: { value: true },
subscriptionTier: { value: 'PRO' },
subscriptionDuration: { value: 'MONTHLY' },
formattedRenewalDate: { value: 'Jan 1, 2025' },
formattedEndDate: { value: '' },
isCancelled: { value: false },
fetchStatus: vi.fn().mockResolvedValue(undefined),
manageSubscription: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn().mockResolvedValue(undefined),
showSubscriptionDialog: vi.fn()
})
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({
show: vi.fn(),
hide: vi.fn()
})
})
)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
balance: { amount_micros: 5000000 },
fetchBalance: vi.fn().mockResolvedValue({ amount_micros: 5000000 })
})
}))
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
useBillingPlans: () => ({
get plans() {
return mockPlans
},
currentPlanSlug: { value: null },
isLoading: { value: false },
error: { value: null },
fetchPlans: vi.fn().mockResolvedValue(undefined),
getPlanBySlug: vi.fn().mockReturnValue(null)
})
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getBillingStatus: vi.fn().mockResolvedValue({
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
}),
getBillingBalance: vi.fn().mockResolvedValue({
amount_micros: 10000000,
currency: 'usd'
}),
subscribe: vi.fn().mockResolvedValue({ status: 'subscribed' }),
previewSubscribe: vi.fn().mockResolvedValue({ allowed: true })
}
}))
describe('useBillingContext', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
})
it('returns legacy type for personal workspace', () => {
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('provides subscription info from legacy billing', () => {
const { subscription } = useBillingContext()
expect(subscription.value).toEqual({
isActive: true,
tier: 'PRO',
duration: 'MONTHLY',
planSlug: null,
renewalDate: 'Jan 1, 2025',
endDate: null,
isCancelled: false,
hasFunds: true
})
})
it('provides balance info from legacy billing', () => {
const { balance } = useBillingContext()
expect(balance.value).toEqual({
amountMicros: 5000000,
currency: 'usd',
effectiveBalanceMicros: 5000000,
prepaidBalanceMicros: 0,
cloudCreditBalanceMicros: 0
})
})
it('exposes initialize action', async () => {
const { initialize } = useBillingContext()
await expect(initialize()).resolves.toBeUndefined()
})
it('exposes fetchStatus action', async () => {
const { fetchStatus } = useBillingContext()
await expect(fetchStatus()).resolves.toBeUndefined()
})
it('exposes fetchBalance action', async () => {
const { fetchBalance } = useBillingContext()
await expect(fetchBalance()).resolves.toBeUndefined()
})
it('exposes subscribe action', async () => {
const { subscribe } = useBillingContext()
await expect(subscribe('pro-monthly')).resolves.toBeUndefined()
})
it('exposes manageSubscription action', async () => {
const { manageSubscription } = useBillingContext()
await expect(manageSubscription()).resolves.toBeUndefined()
})
it('provides isActiveSubscription convenience computed', () => {
const { isActiveSubscription } = useBillingContext()
expect(isActiveSubscription.value).toBe(true)
})
it('exposes requireActiveSubscription action', async () => {
const { requireActiveSubscription } = useBillingContext()
await expect(requireActiveSubscription()).resolves.toBeUndefined()
})
it('exposes showSubscriptionDialog action', () => {
const { showSubscriptionDialog } = useBillingContext()
expect(() => showSubscriptionDialog()).not.toThrow()
})
describe('getMaxSeats', () => {
it('returns 1 for personal workspaces regardless of tier', () => {
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('standard')).toBe(1)
expect(getMaxSeats('creator')).toBe(1)
expect(getMaxSeats('pro')).toBe(1)
expect(getMaxSeats('founder')).toBe(1)
})
it('falls back to hardcoded values when no API plans available', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('standard')).toBe(1)
expect(getMaxSeats('creator')).toBe(5)
expect(getMaxSeats('pro')).toBe(20)
expect(getMaxSeats('founder')).toBe(1)
})
it('prefers API max_seats when plans are loaded', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockPlans.value = [
{
slug: 'pro-monthly',
tier: 'PRO',
duration: 'MONTHLY',
price_cents: 10000,
credits_cents: 2110000,
max_seats: 50,
availability: { available: true },
seat_summary: {
seat_count: 1,
total_cost_cents: 10000,
total_credits_cents: 2110000
}
}
]
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('pro')).toBe(50)
// Tiers without API plans still fall back to hardcoded values
expect(getMaxSeats('creator')).toBe(5)
})
})
})

View File

@@ -0,0 +1,258 @@
import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
KEY_TO_TIER,
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
BalanceInfo,
BillingActions,
BillingContext,
BillingType,
BillingState,
SubscriptionInfo
} from './types'
import { useLegacyBilling } from './useLegacyBilling'
import { useWorkspaceBilling } from './useWorkspaceBilling'
/**
* Unified billing context that automatically switches between legacy (user-scoped)
* and workspace billing based on the active workspace type.
*
* - Personal workspaces use legacy billing via /customers/* endpoints
* - Team workspaces use workspace billing via /billing/* endpoints
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
*
* @example
* ```typescript
* const {
* type,
* subscription,
* balance,
* isInitialized,
* initialize,
* subscribe
* } = useBillingContext()
*
* // Wait for initialization
* await initialize()
*
* // Check subscription status
* if (subscription.value?.isActive) {
* console.log(`Tier: ${subscription.value.tier}`)
* }
*
* // Check balance
* if (balance.value) {
* const dollars = balance.value.amountMicros / 1_000_000
* console.log(`Balance: $${dollars.toFixed(2)}`)
* }
* ```
*/
function useBillingContextInternal(): BillingContext {
const store = useTeamWorkspaceStore()
const { flags } = useFeatureFlags()
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
null
)
const workspaceBillingRef = shallowRef<
(BillingState & BillingActions) | null
>(null)
const getLegacyBilling = () => {
if (!legacyBillingRef.value) {
legacyBillingRef.value = useLegacyBilling()
}
return legacyBillingRef.value
}
const getWorkspaceBilling = () => {
if (!workspaceBillingRef.value) {
workspaceBillingRef.value = useWorkspaceBilling()
}
return workspaceBillingRef.value
}
const isInitialized = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
/**
* Determines which billing type to use:
* - If team workspaces feature is disabled: always use legacy (/customers)
* - If team workspaces feature is enabled:
* - Personal workspace: use legacy (/customers)
* - Team workspace: use workspace (/billing)
*/
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
})
const activeContext = computed(() =>
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
)
// Proxy state from active context
const subscription = computed<SubscriptionInfo | null>(() =>
toValue(activeContext.value.subscription)
)
const balance = computed<BalanceInfo | null>(() =>
toValue(activeContext.value.balance)
)
const plans = computed(() => toValue(activeContext.value.plans))
const currentPlanSlug = computed(() =>
toValue(activeContext.value.currentPlanSlug)
)
const isActiveSubscription = computed(() =>
toValue(activeContext.value.isActiveSubscription)
)
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
const apiTier = KEY_TO_TIER[tierKey]
const plan = plans.value.find(
(p) => p.tier === apiTier && p.duration === 'MONTHLY'
)
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
}
// Sync subscription info to workspace store for display in workspace switcher
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
// This ensures the delete button is enabled after cancellation, even before the period ends
watch(
subscription,
(sub) => {
if (!sub || store.isInPersonalWorkspace) return
store.updateActiveWorkspace({
isSubscribed: sub.isActive && !sub.isCancelled,
subscriptionPlan: sub.planSlug
})
},
{ immediate: true }
)
// Initialize billing when workspace changes
watch(
() => store.activeWorkspace?.id,
async (newWorkspaceId, oldWorkspaceId) => {
if (!newWorkspaceId) {
// No workspace selected - reset state
isInitialized.value = false
error.value = null
return
}
if (newWorkspaceId !== oldWorkspaceId) {
// Workspace changed - reinitialize
isInitialized.value = false
try {
await initialize()
} catch (err) {
// Error is already captured in error ref
console.error('Failed to initialize billing context:', err)
}
}
},
{ immediate: true }
)
async function initialize(): Promise<void> {
if (isInitialized.value) return
isLoading.value = true
error.value = null
try {
await activeContext.value.initialize()
isInitialized.value = true
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
}
}
async function fetchStatus(): Promise<void> {
return activeContext.value.fetchStatus()
}
async function fetchBalance(): Promise<void> {
return activeContext.value.fetchBalance()
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) {
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
}
async function previewSubscribe(planSlug: string) {
return activeContext.value.previewSubscribe(planSlug)
}
async function manageSubscription() {
return activeContext.value.manageSubscription()
}
async function cancelSubscription() {
return activeContext.value.cancelSubscription()
}
async function fetchPlans() {
return activeContext.value.fetchPlans()
}
async function requireActiveSubscription() {
return activeContext.value.requireActiveSubscription()
}
function showSubscriptionDialog() {
return activeContext.value.showSubscriptionDialog()
}
return {
type,
isInitialized,
subscription,
balance,
plans,
currentPlanSlug,
isLoading,
error,
isActiveSubscription,
getMaxSeats,
initialize,
fetchStatus,
fetchBalance,
subscribe,
previewSubscribe,
manageSubscription,
cancelSubscription,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog
}
}
export const useBillingContext = createSharedComposable(
useBillingContextInternal
)

View File

@@ -0,0 +1,189 @@
import { computed, ref } from 'vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type {
BalanceInfo,
BillingActions,
BillingState,
SubscriptionInfo
} from './types'
/**
* Adapter for legacy user-scoped billing via /customers/* endpoints.
* Used for personal workspaces.
* @internal - Use useBillingContext() instead of importing directly.
*/
export function useLegacyBilling(): BillingState & BillingActions {
const {
isActiveSubscription: legacyIsActiveSubscription,
subscriptionTier,
subscriptionDuration,
formattedRenewalDate,
formattedEndDate,
isCancelled,
fetchStatus: legacyFetchStatus,
manageSubscription: legacyManageSubscription,
subscribe: legacySubscribe,
showSubscriptionDialog: legacyShowSubscriptionDialog
} = useSubscription()
const firebaseAuthStore = useFirebaseAuthStore()
const isInitialized = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isActiveSubscription = computed(() => legacyIsActiveSubscription.value)
const subscription = computed<SubscriptionInfo | null>(() => {
if (!legacyIsActiveSubscription.value && !subscriptionTier.value) {
return null
}
return {
isActive: legacyIsActiveSubscription.value,
tier: subscriptionTier.value,
duration: subscriptionDuration.value,
planSlug: null, // Legacy doesn't use plan slugs
renewalDate: formattedRenewalDate.value || null,
endDate: formattedEndDate.value || null,
isCancelled: isCancelled.value,
hasFunds: (firebaseAuthStore.balance?.amount_micros ?? 0) > 0
}
})
const balance = computed<BalanceInfo | null>(() => {
const legacyBalance = firebaseAuthStore.balance
if (!legacyBalance) return null
return {
amountMicros: legacyBalance.amount_micros ?? 0,
currency: legacyBalance.currency ?? 'usd',
effectiveBalanceMicros:
legacyBalance.effective_balance_micros ??
legacyBalance.amount_micros ??
0,
prepaidBalanceMicros: legacyBalance.prepaid_balance_micros ?? 0,
cloudCreditBalanceMicros: legacyBalance.cloud_credit_balance_micros ?? 0
}
})
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
const currentPlanSlug = computed(() => null)
async function initialize(): Promise<void> {
if (isInitialized.value) return
isLoading.value = true
error.value = null
try {
await Promise.all([fetchStatus(), fetchBalance()])
isInitialized.value = true
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
}
}
async function fetchStatus(): Promise<void> {
isLoading.value = true
error.value = null
try {
await legacyFetchStatus()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch subscription'
throw err
} finally {
isLoading.value = false
}
}
async function fetchBalance(): Promise<void> {
isLoading.value = true
error.value = null
try {
await firebaseAuthStore.fetchBalance()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch balance'
throw err
} finally {
isLoading.value = false
}
}
async function subscribe(
_planSlug: string,
_returnUrl?: string,
_cancelUrl?: string
): Promise<SubscribeResponse | void> {
// Legacy billing uses Stripe checkout flow via useSubscription
await legacySubscribe()
}
async function previewSubscribe(
_planSlug: string
): Promise<PreviewSubscribeResponse | null> {
// Legacy billing doesn't support preview - returns null
return null
}
async function manageSubscription(): Promise<void> {
await legacyManageSubscription()
}
async function cancelSubscription(): Promise<void> {
await legacyManageSubscription()
}
async function fetchPlans(): Promise<void> {
// Legacy billing doesn't have workspace-style plans
// Plans are hardcoded in the UI for legacy subscriptions
}
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
legacyShowSubscriptionDialog()
}
}
function showSubscriptionDialog(): void {
legacyShowSubscriptionDialog()
}
return {
// State
isInitialized,
subscription,
balance,
plans,
currentPlanSlug,
isLoading,
error,
isActiveSubscription,
// Actions
initialize,
fetchStatus,
fetchBalance,
subscribe,
previewSubscribe,
manageSubscription,
cancelSubscription,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog
}
}

View File

@@ -0,0 +1,311 @@
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingBalanceResponse,
BillingStatusResponse,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
BalanceInfo,
BillingActions,
BillingState,
SubscriptionInfo
} from './types'
/**
* Adapter for workspace-scoped billing via /billing/* endpoints.
* Used for team workspaces.
* @internal - Use useBillingContext() instead of importing directly.
*/
export function useWorkspaceBilling(): BillingState & BillingActions {
const billingPlans = useBillingPlans()
const workspaceStore = useTeamWorkspaceStore()
const isInitialized = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
const statusData = shallowRef<BillingStatusResponse | null>(null)
const balanceData = shallowRef<BillingBalanceResponse | null>(null)
const isActiveSubscription = computed(
() => statusData.value?.is_active ?? false
)
const subscription = computed<SubscriptionInfo | null>(() => {
const status = statusData.value
if (!status) return null
return {
isActive: status.is_active,
tier: status.subscription_tier ?? null,
duration: status.subscription_duration ?? null,
planSlug: status.plan_slug ?? null,
renewalDate: status.renewal_date ?? null,
endDate: status.cancel_at ?? null,
isCancelled: status.subscription_status === 'canceled',
hasFunds: status.has_funds
}
})
const balance = computed<BalanceInfo | null>(() => {
const data = balanceData.value
if (!data) return null
return {
amountMicros: data.amount_micros,
currency: data.currency,
effectiveBalanceMicros: data.effective_balance_micros,
prepaidBalanceMicros: data.prepaid_balance_micros,
cloudCreditBalanceMicros: data.cloud_credit_balance_micros
}
})
const plans = computed(() => billingPlans.plans.value)
const currentPlanSlug = computed(
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
)
const pendingCancelOpId = ref<string | null>(null)
let cancelPollTimeout: number | null = null
const stopCancelPolling = () => {
if (cancelPollTimeout !== null) {
window.clearTimeout(cancelPollTimeout)
cancelPollTimeout = null
}
}
async function pollCancelStatus(opId: string): Promise<void> {
stopCancelPolling()
const maxAttempts = 30
let attempt = 0
const poll = async () => {
if (pendingCancelOpId.value !== opId) return
try {
const response = await workspaceApi.getBillingOpStatus(opId)
if (response.status === 'succeeded') {
pendingCancelOpId.value = null
stopCancelPolling()
await fetchStatus()
workspaceStore.updateActiveWorkspace({
isSubscribed: false
})
return
}
if (response.status === 'failed') {
pendingCancelOpId.value = null
stopCancelPolling()
throw new Error(
response.error_message ?? 'Failed to cancel subscription'
)
}
attempt += 1
if (attempt >= maxAttempts) {
pendingCancelOpId.value = null
stopCancelPolling()
await fetchStatus()
return
}
} catch (err) {
pendingCancelOpId.value = null
stopCancelPolling()
throw err
}
cancelPollTimeout = window.setTimeout(
() => {
void poll()
},
Math.min(1000 * 2 ** attempt, 5000)
)
}
await poll()
}
async function initialize(): Promise<void> {
if (isInitialized.value) return
isLoading.value = true
error.value = null
try {
await Promise.all([fetchStatus(), fetchBalance(), fetchPlans()])
isInitialized.value = true
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to initialize billing'
throw err
} finally {
isLoading.value = false
}
}
async function fetchStatus(): Promise<void> {
isLoading.value = true
error.value = null
try {
statusData.value = await workspaceApi.getBillingStatus()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch billing status'
throw err
} finally {
isLoading.value = false
}
}
async function fetchBalance(): Promise<void> {
isLoading.value = true
error.value = null
try {
balanceData.value = await workspaceApi.getBillingBalance()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch balance'
throw err
} finally {
isLoading.value = false
}
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
): Promise<SubscribeResponse> {
isLoading.value = true
error.value = null
try {
const response = await workspaceApi.subscribe(
planSlug,
returnUrl,
cancelUrl
)
// Refresh status and balance after subscription
await Promise.all([fetchStatus(), fetchBalance()])
return response
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to subscribe'
throw err
} finally {
isLoading.value = false
}
}
async function previewSubscribe(
planSlug: string
): Promise<PreviewSubscribeResponse | null> {
isLoading.value = true
error.value = null
try {
return await workspaceApi.previewSubscribe(planSlug)
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to preview subscription'
throw err
} finally {
isLoading.value = false
}
}
async function manageSubscription(): Promise<void> {
isLoading.value = true
error.value = null
try {
const returnUrl = window.location.href
const response = await workspaceApi.getPaymentPortalUrl(returnUrl)
if (response.url) {
window.open(response.url, '_blank')
}
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to open billing portal'
throw err
} finally {
isLoading.value = false
}
}
async function cancelSubscription(): Promise<void> {
isLoading.value = true
error.value = null
try {
const response = await workspaceApi.cancelSubscription()
pendingCancelOpId.value = response.billing_op_id
await pollCancelStatus(response.billing_op_id)
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to cancel subscription'
throw err
} finally {
isLoading.value = false
}
}
async function fetchPlans(): Promise<void> {
isLoading.value = true
error.value = null
try {
await billingPlans.fetchPlans()
if (billingPlans.error.value) {
error.value = billingPlans.error.value
}
} finally {
isLoading.value = false
}
}
const subscriptionDialog = useSubscriptionDialog()
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
subscriptionDialog.show()
}
}
function showSubscriptionDialog(): void {
subscriptionDialog.show()
}
onBeforeUnmount(() => {
stopCancelPolling()
})
return {
// State
isInitialized,
subscription,
balance,
plans,
currentPlanSlug,
isLoading,
error,
isActiveSubscription,
// Actions
initialize,
fetchStatus,
fetchBalance,
subscribe,
previewSubscribe,
manageSubscription,
cancelSubscription,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog
}
}

View File

@@ -134,6 +134,27 @@ describe('contextMenuConverter', () => {
// Node Info (section 4) should come before or with Color (section 4)
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
})
it('should recognize Frame Nodes as a core menu item', () => {
const options: MenuOption[] = [
{ label: 'Rename', source: 'vue' },
{ label: 'Frame Nodes', source: 'vue' },
{ label: 'Custom Extension', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Frame Nodes should appear in the core items section (before Extensions)
const frameNodesIndex = result.findIndex(
(opt) => opt.label === 'Frame Nodes'
)
const extensionsCategoryIndex = result.findIndex(
(opt) => opt.label === 'Extensions' && opt.type === 'category'
)
// Frame Nodes should come before Extensions category
expect(frameNodesIndex).toBeLessThan(extensionsCategoryIndex)
})
})
describe('convertContextMenuToOptions', () => {

View File

@@ -44,6 +44,7 @@ const CORE_MENU_ITEMS = new Set([
// Structure operations
'Convert to Subgraph',
'Frame selection',
'Frame Nodes',
'Minimize Node',
'Expand',
'Collapse',
@@ -103,7 +104,8 @@ function isDuplicateItem(label: string, existingItems: MenuOption[]): boolean {
shape: ['shape', 'shapes'],
pin: ['pin', 'unpin'],
delete: ['remove', 'delete'],
duplicate: ['clone', 'duplicate']
duplicate: ['clone', 'duplicate'],
frame: ['frame selection', 'frame nodes']
}
return existingItems.some((item) => {
@@ -226,6 +228,7 @@ const MENU_ORDER: string[] = [
// Section 3: Structure operations
'Convert to Subgraph',
'Frame selection',
'Frame Nodes',
'Minimize Node',
'Expand',
'Collapse',

View File

@@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectionMenuOptions } from '@/composables/graph/useSelectionMenuOptions'
const subgraphMocks = vi.hoisted(() => ({
const mocks = vi.hoisted(() => ({
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn(),
addSubgraphToLibrary: vi.fn(),
frameNodes: vi.fn(),
createI18nMock: vi.fn(() => ({
global: {
t: vi.fn(),
@@ -19,7 +20,7 @@ vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
}),
createI18n: subgraphMocks.createI18nMock
createI18n: mocks.createI18nMock
}))
vi.mock('@/composables/graph/useSelectionOperations', () => ({
@@ -42,18 +43,46 @@ vi.mock('@/composables/graph/useNodeArrangement', () => ({
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
useSubgraphOperations: () => ({
convertToSubgraph: subgraphMocks.convertToSubgraph,
unpackSubgraph: subgraphMocks.unpackSubgraph,
addSubgraphToLibrary: subgraphMocks.addSubgraphToLibrary
convertToSubgraph: mocks.convertToSubgraph,
unpackSubgraph: mocks.unpackSubgraph,
addSubgraphToLibrary: mocks.addSubgraphToLibrary
})
}))
vi.mock('@/composables/graph/useFrameNodes', () => ({
useFrameNodes: () => ({
frameNodes: vi.fn()
frameNodes: mocks.frameNodes
})
}))
describe('useSelectionMenuOptions - multiple nodes options', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns Frame Nodes option that invokes frameNodes when called', () => {
const { getMultipleNodesOptions } = useSelectionMenuOptions()
const options = getMultipleNodesOptions()
const frameOption = options.find((opt) => opt.label === 'g.frameNodes')
expect(frameOption).toBeDefined()
expect(frameOption?.action).toBeDefined()
frameOption?.action?.()
expect(mocks.frameNodes).toHaveBeenCalledOnce()
})
it('returns Convert to Group Node option from getMultipleNodesOptions', () => {
const { getMultipleNodesOptions } = useSelectionMenuOptions()
const options = getMultipleNodesOptions()
const groupNodeOption = options.find(
(opt) => opt.label === 'contextMenu.Convert to Group Node'
)
expect(groupNodeOption).toBeDefined()
})
})
describe('useSelectionMenuOptions - subgraph options', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -68,7 +97,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
expect(options).toHaveLength(1)
expect(options[0]?.label).toBe('contextMenu.Convert to Subgraph')
expect(options[0]?.action).toBe(subgraphMocks.convertToSubgraph)
expect(options[0]?.action).toBe(mocks.convertToSubgraph)
})
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
@@ -86,7 +115,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
const convertOption = options.find(
(option) => option.label === 'contextMenu.Convert to Subgraph'
)
expect(convertOption?.action).toBe(subgraphMocks.convertToSubgraph)
expect(convertOption?.action).toBe(mocks.convertToSubgraph)
})
it('hides convert option when only a single subgraph is selected', () => {

View File

@@ -87,6 +87,25 @@ describe('useSelectionState', () => {
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(true)
})
test('hasMultipleSelection should be true when 2+ items selected', () => {
const canvasStore = useCanvasStore()
const node1 = createMockLGraphNode({ id: 1 })
const node2 = createMockLGraphNode({ id: 2 })
canvasStore.$state.selectedItems = [node1, node2]
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(true)
})
test('hasMultipleSelection should be false when only 1 item selected', () => {
const canvasStore = useCanvasStore()
const node1 = createMockLGraphNode({ id: 1 })
canvasStore.$state.selectedItems = [node1]
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(false)
})
})
describe('Node Type Filtering', () => {

View File

@@ -17,7 +17,7 @@ import {
isToday,
isYesterday
} from '@/utils/dateTimeUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildJobDisplay } from '@/utils/queueDisplay'
import { jobStateFromTask } from '@/utils/queueUtil'
@@ -185,11 +185,13 @@ export function useJobList() {
executionStore.isPromptInitializing(promptId)
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
const node = executionStore.executingNode
if (!node) return t('g.emDash')
const title = (node.title ?? '').toString().trim()
if (title) return title
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
})
const selectedJobTab = ref<JobTab>('All')

View File

@@ -107,6 +107,13 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
}))
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: { value: true },
showSubscriptionDialog: vi.fn()
}))
}))
describe('useCoreCommands', () => {
const createMockNode = (id: number, comfyClass: string): LGraphNode => {
const baseNode = createMockLGraphNode({ id })

View File

@@ -17,9 +17,9 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type { Point } from '@/lib/litegraph/src/litegraph'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
@@ -69,7 +69,7 @@ import { useDialogStore } from '@/stores/dialogStore'
const moveSelectedNodesVersionAdded = '1.22.2'
export function useCoreCommands(): ComfyCommand[] {
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()

View File

@@ -15,14 +15,12 @@ export enum ServerFeatureFlag {
MAX_UPLOAD_SIZE = 'max_upload_size',
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_DELETION_ENABLED = 'asset_deletion_enabled',
ASSET_RENAME_ENABLED = 'asset_rename_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled'
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled'
}
/**
@@ -40,7 +38,6 @@ export function useFeatureFlags() {
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
},
get modelUploadButtonEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.model_upload_button_enabled ??
api.getServerFeature(
@@ -49,12 +46,6 @@ export function useFeatureFlags() {
)
)
},
get assetDeletionEnabled() {
return (
remoteConfig.value.asset_deletion_enabled ??
api.getServerFeature(ServerFeatureFlag.ASSET_DELETION_ENABLED, false)
)
},
get assetRenameEnabled() {
return (
remoteConfig.value.asset_rename_enabled ??
@@ -62,7 +53,6 @@ export function useFeatureFlags() {
)
},
get privateModelsEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.private_models_enabled ??
api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false)
@@ -71,16 +61,7 @@ export function useFeatureFlags() {
get onboardingSurveyEnabled() {
return (
remoteConfig.value.onboarding_survey_enabled ??
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true)
)
},
get huggingfaceModelImportEnabled() {
return (
remoteConfig.value.huggingface_model_import_enabled ??
api.getServerFeature(
ServerFeatureFlag.HUGGINGFACE_MODEL_IMPORT_ENABLED,
false
)
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
)
},
get linearToggleEnabled() {
@@ -89,15 +70,6 @@ export function useFeatureFlags() {
api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false)
)
},
get asyncModelUploadEnabled() {
return (
remoteConfig.value.async_model_upload_enabled ??
api.getServerFeature(
ServerFeatureFlag.ASYNC_MODEL_UPLOAD_ENABLED,
false
)
)
},
/**
* Whether team workspaces feature is enabled.
* IMPORTANT: Returns false until authenticated remote config is loaded.
@@ -116,6 +88,12 @@ export function useFeatureFlags() {
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
)
},
get userSecretsEnabled() {
return (
remoteConfig.value.user_secrets_enabled ??
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
)
}
})

View File

@@ -4,7 +4,6 @@ import { nextTick, ref } from 'vue'
import type { IFuseOptions } from 'fuse.js'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
const defaultSettingStore = {
get: vi.fn((key: string) => {
@@ -51,6 +50,9 @@ vi.mock('@/scripts/api', () => ({
}
}))
const { useTemplateFiltering } =
await import('@/composables/useTemplateFiltering')
describe('useTemplateFiltering', () => {
beforeEach(() => {
setActivePinia(createPinia())

View File

@@ -1,4 +1,4 @@
import { refThrottled, watchDebounced } from '@vueuse/core'
import { refDebounced, watchDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { computed, ref, watch } from 'vue'
@@ -119,7 +119,7 @@ export function useTemplateFiltering(
)
})
const debouncedSearchQuery = refThrottled(searchQuery, 50)
const debouncedSearchQuery = refDebounced(searchQuery, 150)
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {

View File

@@ -1,5 +1,4 @@
import { remove } from 'es-toolkit'
import { shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type {
@@ -345,7 +344,6 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
requestAnimationFrame(() => {
const input = node.inputs[index]
if (!input) return
node.inputs[index] = shallowReactive(input)
node.onConnectionsChange?.(
LiteGraph.INPUT,
index,

View File

@@ -1,7 +1,7 @@
import { watchDebounced } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { useExtensionService } from '@/services/extensionService'
@@ -14,7 +14,7 @@ useExtensionService().registerExtension({
setup: async () => {
const { isLoggedIn } = useCurrentUser()
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()
// Refresh config when auth or subscription status changes
// Primary auth refresh is handled by WorkspaceAuthGate on mount

View File

@@ -1,7 +1,7 @@
import { watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExtensionService } from '@/services/extensionService'
/**
@@ -12,7 +12,7 @@ useExtensionService().registerExtension({
setup: async () => {
const { isLoggedIn } = useCurrentUser()
const { requireActiveSubscription } = useSubscription()
const { requireActiveSubscription } = useBillingContext()
const checkSubscriptionStatus = () => {
if (!isLoggedIn.value) return

View File

@@ -13,7 +13,9 @@ import './imageCompare'
import './imageCrop'
import './load3d'
import './maskeditor'
import './nodeTemplates'
if (!isCloud) {
await import('./nodeTemplates')
}
import './noteNode'
import './previewAny'
import './rerouteNode'

View File

@@ -157,11 +157,9 @@ class Load3d {
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
this.forceRender()
})
this.resizeObserver.observe(container)
}
@@ -524,6 +522,7 @@ class Load3d {
this.viewHelperManager.recreateViewHelper()
this.handleResize()
this.forceRender()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
@@ -585,6 +584,7 @@ class Load3d {
}
this.handleResize()
this.forceRender()
this.loadingPromise = null
}
@@ -618,6 +618,7 @@ class Load3d {
this.targetHeight = height
this.targetAspectRatio = width / height
this.handleResize()
this.forceRender()
}
addEventListener<T>(event: string, callback: EventCallback<T>): void {
@@ -630,6 +631,7 @@ class Load3d {
refreshViewport(): void {
this.handleResize()
this.forceRender()
}
handleResize(): void {

View File

@@ -10,17 +10,8 @@ import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type {
IBaseWidget,
IWidgetAssetOptions,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import {
assetFilenameSchema,
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { isCloud } from '@/platform/distribution/types'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import {
@@ -28,10 +19,10 @@ import {
addValueControlWidgets,
isValidWidgetType
} from '@/scripts/widgets'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
import { mergeInputSpec } from '@/utils/nodeDefUtil'
import { applyTextReplacements } from '@/utils/searchAndReplace'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {
@@ -237,20 +228,6 @@ export class PrimitiveNode extends LGraphNode {
// Store current size as addWidget resizes the node
const [oldWidth, oldHeight] = this.size
let widget: IBaseWidget
// Cloud: Use asset widget for model-eligible inputs
if (isCloud && type === 'COMBO') {
const isEligible = assetService.isAssetBrowserEligible(
node.comfyClass,
widgetName
)
if (isEligible) {
widget = this.#createAssetWidget(node, widgetName, inputData)
this.#finalizeWidget(widget, oldWidth, oldHeight, recreating)
return
}
}
if (isValidWidgetType(type)) {
widget = (ComfyWidgets[type](this, 'value', inputData, app) || {}).widget
} else {
@@ -300,84 +277,20 @@ export class PrimitiveNode extends LGraphNode {
}
}
this.#finalizeWidget(widget, oldWidth, oldHeight, recreating)
}
#createAssetWidget(
targetNode: LGraphNode,
widgetName: string,
inputData: InputSpec
): IBaseWidget {
const defaultValue = inputData[1]?.default as string | undefined
const assetBrowserDialog = useAssetBrowserDialog()
const openModal = async (widget: IBaseWidget) => {
await assetBrowserDialog.show({
nodeType: targetNode.comfyClass ?? '',
inputName: widgetName,
currentValue: widget.value as string,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error('Invalid asset item:', validatedAsset.error.errors)
return
}
const filename = getAssetFilename(validatedAsset.data)
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors
)
return
}
const oldValue = widget.value
widget.value = validatedFilename.data
widget.callback?.(
widget.value,
app.canvas,
this,
app.canvas.graph_mouse,
{} as CanvasPointerEvent
)
this.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
const options: IWidgetAssetOptions = { openModal }
return this.addWidget(
'asset',
'value',
defaultValue ?? '',
() => {},
options
)
}
#finalizeWidget(
widget: IBaseWidget,
oldWidth: number,
oldHeight: number,
recreating: boolean
) {
// When our value changes, update other widgets to reflect our changes
// e.g. so LoadImage shows correct image
widget.callback = useChainCallback(widget.callback, () => {
this.applyToGraph()
})
// Use the biggest dimensions in case the widgets caused the node to grow
this.setSize([
Math.max(this.size[0], oldWidth),
Math.max(this.size[1], oldHeight)
])
if (!recreating) {
// Grow our node more if required
const sz = this.computeSize()
if (this.size[0] < sz[0]) {
this.size[0] = sz[0]

View File

@@ -1,3 +1,5 @@
import DOMPurify from 'dompurify'
import type {
ContextMenuDivElement,
IContextMenuOptions,
@@ -5,6 +7,38 @@ import type {
} from './interfaces'
import { LiteGraph } from './litegraph'
const ALLOWED_TAGS = ['span', 'b', 'i', 'em', 'strong']
const ALLOWED_STYLE_PROPS = new Set([
'display',
'color',
'background-color',
'padding-left',
'border-left'
])
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
if (data.attrName === 'style') {
const sanitizedStyle = data.attrValue
.split(';')
.map((s) => s.trim())
.filter((s) => {
const colonIdx = s.indexOf(':')
if (colonIdx === -1) return false
const prop = s.slice(0, colonIdx).trim().toLowerCase()
return ALLOWED_STYLE_PROPS.has(prop)
})
.join('; ')
data.attrValue = sanitizedStyle
}
})
function sanitizeMenuHTML(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR: ['style']
})
}
// TODO: Replace this pattern with something more modern.
export interface ContextMenu<TValue = unknown> {
constructor: new (
@@ -123,7 +157,7 @@ export class ContextMenu<TValue = unknown> {
if (options.title) {
const element = document.createElement('div')
element.className = 'litemenu-title'
element.innerHTML = options.title
element.textContent = options.title
root.append(element)
}
@@ -218,11 +252,18 @@ export class ContextMenu<TValue = unknown> {
if (value === null) {
element.classList.add('separator')
} else {
const innerHtml = name === null ? '' : String(name)
const label = name === null ? '' : String(name)
if (typeof value === 'string') {
element.innerHTML = innerHtml
element.textContent = label
} else {
element.innerHTML = value?.title ?? innerHtml
// Use innerHTML for content that contains HTML tags, textContent otherwise
const hasHtmlContent =
value?.content !== undefined && /<[a-z][\s\S]*>/i.test(value.content)
if (hasHtmlContent) {
element.innerHTML = sanitizeMenuHTML(value.content!)
} else {
element.textContent = value?.title ?? label
}
if (value.disabled) {
disabled = true

View File

@@ -46,9 +46,12 @@ describe('LGraph', () => {
expect(graph.extra).toBe('TestGraph')
})
test('is exactly the same type', ({ expect }) => {
// LGraph from barrel export and LiteGraph.LGraph should be the same
expect(LiteGraph.LGraph).toBe(LGraph)
test('is exactly the same type', async ({ expect }) => {
const directImport = await import('@/lib/litegraph/src/LGraph')
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
expect(LiteGraph.LGraph).toBe(directImport.LGraph)
expect(LiteGraph.LGraph).toBe(entryPointImport.LGraph)
})
test('populates optional values', ({ expect, minimalSerialisableGraph }) => {

View File

@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn()
}
}))
describe('LGraphCanvas slot hit detection', () => {
let graph: LGraph
let canvas: LGraphCanvas
let node: LGraphNode
let canvasElement: HTMLCanvasElement
beforeEach(() => {
vi.clearAllMocks()
canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} as unknown as CanvasRenderingContext2D
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true
})
// Create a test node with an output slot
node = new LGraphNode('Test Node')
node.pos = [100, 100]
node.size = [150, 80]
node.addOutput('output', 'number')
graph.add(node)
// Enable Vue nodes mode for the test
LiteGraph.vueNodesMode = true
})
afterEach(() => {
LiteGraph.vueNodesMode = false
})
describe('processMouseDown slot fallback in Vue nodes mode', () => {
it('should query layoutStore.querySlotAtPoint when clicking outside node bounds', () => {
// Click position outside node bounds (node is at 100,100 with size 150x80)
// So node covers x: 100-250, y: 100-180
// Click at x=255 is outside the right edge
const clickX = 255
const clickY = 120
// Verify the click is outside the node bounds
expect(node.isPointInside(clickX, clickY)).toBe(false)
expect(graph.getNodeOnPos(clickX, clickY)).toBeNull()
// Mock the slot query to return our node's slot
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
nodeId: String(node.id),
index: 0,
type: 'output',
position: { x: 252, y: 120 },
bounds: { x: 246, y: 110, width: 20, height: 20 }
})
// Call processMouseDown - this should trigger the slot fallback
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1, // Middle button
clientX: clickX,
clientY: clickY
})
)
// The fix should query the layout store when no node is found at click position
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
x: clickX,
y: clickY
})
})
it('should NOT query layoutStore when node is found directly at click position', () => {
// Initialize node's bounding rect
node.updateArea()
// Populate visible_nodes (normally done during render)
canvas.visible_nodes = [node]
// Click inside the node bounds
const clickX = 150
const clickY = 140
// Verify the click is inside the node bounds
expect(node.isPointInside(clickX, clickY)).toBe(true)
expect(graph.getNodeOnPos(clickX, clickY)).toBe(node)
// Call processMouseDown
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1,
clientX: clickX,
clientY: clickY
})
)
// Should NOT query the layout store since node was found directly
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
})
it('should NOT query layoutStore when not in Vue nodes mode', () => {
LiteGraph.vueNodesMode = false
const clickX = 255
const clickY = 120
// Call processMouseDown
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1,
clientX: clickX,
clientY: clickY
})
)
// Should NOT query the layout store in non-Vue mode
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
})
it('should find node via slot query for input slots extending beyond left edge', () => {
node.addInput('input', 'number')
// Click position left of node (node starts at x=100)
const clickX = 95
const clickY = 140
// Verify outside bounds
expect(node.isPointInside(clickX, clickY)).toBe(false)
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
nodeId: String(node.id),
index: 0,
type: 'input',
position: { x: 98, y: 140 },
bounds: { x: 88, y: 130, width: 20, height: 20 }
})
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1,
clientX: clickX,
clientY: clickY
})
)
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
x: clickX,
y: clickY
})
})
})
})

View File

@@ -2187,9 +2187,21 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!is_inside) return
const node =
let node =
graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) ?? undefined
// In Vue nodes mode, slots extend beyond node bounds due to CSS transforms.
// If no node was found, check if the click is on a slot and use its owning node.
if (!node && LiteGraph.vueNodesMode) {
const slotLayout = layoutStore.querySlotAtPoint({
x: e.canvasX,
y: e.canvasY
})
if (slotLayout) {
node = graph.getNodeById(slotLayout.nodeId) ?? undefined
}
}
this.mouse[0] = x
this.mouse[1] = y
this.graph_mouse[0] = e.canvasX

View File

@@ -73,6 +73,28 @@ describe('LinkConnector SubgraphInput connection validation', () => {
expect(toTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
expect(fromTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
})
it('should allow reconnection to same target', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const node = new LGraphNode('TargetNode')
node.addInput('number_in', 'number')
subgraph.add(node)
const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const renderLink = new ToInputFromIoNodeLink(
subgraph,
subgraph.inputNode,
subgraph.inputNode.slots[0],
undefined,
LinkDirection.CENTER,
link
)
renderLink.connectToInput(node, node.inputs[0], connector.events)
expect(node.inputs[0].link).not.toBeNull()
})
})
describe('MovingOutputLink validation', () => {

View File

@@ -58,6 +58,12 @@ export class ToInputFromIoNodeLink implements RenderLink {
events: CustomEventTarget<LinkConnectorEventMap>
) {
const { fromSlot, fromReroute, existingLink } = this
if (
existingLink &&
node.id === existingLink.target_id &&
node.inputs[existingLink.target_slot] === input
)
return
const newLink = fromSlot.connect(input, node, fromReroute?.id)

View File

@@ -195,6 +195,42 @@ describe('contextMenuCompat', () => {
expect.any(Error)
)
})
it('should handle multiple items with undefined content correctly', () => {
// Setup base method with items that have undefined content
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [
{ content: undefined, title: 'Separator 1' },
{ content: undefined, title: 'Separator 2' },
{ content: 'Item 1', callback: () => {} }
]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Monkey-patch to add an item with undefined content
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: undefined, title: 'Separator 3' })
return items
}
// Extract legacy items
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
// Should extract only the newly added item with undefined content
// (not collapse with existing undefined content items)
expect(legacyItems).toHaveLength(1)
expect(legacyItems[0]).toMatchObject({
content: undefined,
title: 'Separator 3'
})
})
})
describe('integration', () => {

View File

@@ -152,19 +152,51 @@ class LegacyMenuCompat {
const patchedItems = methodToCall.apply(context, args) as
| (IContextMenuValue | null)[]
| undefined
if (!patchedItems) return []
if (!patchedItems) {
return []
}
// Use content-based diff to detect additions (not reference-based)
// Create composite keys from multiple properties to handle undefined content
const createItemKey = (item: IContextMenuValue): string => {
const parts = [
item.content ?? '',
item.title ?? '',
item.className ?? '',
item.property ?? '',
item.type ?? ''
]
return parts.join('|')
}
// Use set-based diff to detect additions by reference
const originalSet = new Set<IContextMenuValue | null>(originalItems)
const addedItems = patchedItems.filter((item) => !originalSet.has(item))
const originalKeys = new Set(
originalItems
.filter(
(item): item is IContextMenuValue =>
item !== null && typeof item === 'object' && 'content' in item
)
.map(createItemKey)
)
const addedItems = patchedItems.filter((item) => {
if (item === null) return false
if (typeof item !== 'object' || !('content' in item)) return false
return !originalKeys.has(createItemKey(item))
})
// Warn if items were removed (patched has fewer original items than expected)
const retainedOriginalCount = patchedItems.filter((item) =>
originalSet.has(item)
const patchedKeys = new Set(
patchedItems
.filter(
(item): item is IContextMenuValue =>
item !== null && typeof item === 'object' && 'content' in item
)
.map(createItemKey)
)
const removedCount = [...originalKeys].filter(
(key) => !patchedKeys.has(key)
).length
if (retainedOriginalCount < originalItems.length) {
if (removedCount > 0) {
console.warn(
`[Context Menu Compat] Monkey patch for ${methodName} removed ${originalItems.length - retainedOriginalCount} original menu item(s). ` +
`[Context Menu Compat] Monkey patch for ${methodName} removed ${removedCount} original menu item(s). ` +
`This may cause unexpected behavior.`
)
}

View File

@@ -1,15 +1,12 @@
import { clamp } from 'es-toolkit/compat'
import { describe, expect } from 'vitest'
import { beforeEach, describe, expect, vi } from 'vitest'
import {
LiteGraphGlobal,
LGraphCanvas,
LiteGraph,
LGraph
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { LGraph as DirectLGraph } from '@/lib/litegraph/src/LGraph'
import { test } from './__fixtures__/testExtensions'
describe('Litegraph module', () => {
@@ -30,9 +27,22 @@ describe('Litegraph module', () => {
})
describe('Import order dependency', () => {
test('Imports reference the same types', ({ expect }) => {
// Both imports should reference the same LGraph class
expect(LiteGraph.LGraph).toBe(DirectLGraph)
expect(LiteGraph.LGraph).toBe(LGraph)
beforeEach(() => {
vi.resetModules()
})
test('Imports without error when entry point is imported first', async ({
expect
}) => {
async function importNormally() {
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
const directImport = await import('@/lib/litegraph/src/LGraph')
// Sanity check that imports were cleared.
expect(Object.is(LiteGraph, entryPointImport.LiteGraph)).toBe(false)
expect(Object.is(LiteGraph.LGraph, directImport.LGraph)).toBe(false)
}
await expect(importNormally()).resolves.toBeUndefined()
})
})

View File

@@ -1,261 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LGraphNode as LGraphNodeType } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IColorWidget } from '@/lib/litegraph/src/types/widgets'
import type { ColorWidget as ColorWidgetType } from '@/lib/litegraph/src/widgets/ColorWidget'
type LGraphCanvasType = InstanceType<typeof LGraphCanvas>
function createMockWidgetConfig(
overrides: Partial<IColorWidget> = {}
): IColorWidget {
return {
type: 'color',
name: 'test_color',
value: '#ff0000',
options: {},
y: 0,
...overrides
}
}
function createMockCanvas(): LGraphCanvasType {
return {
setDirty: vi.fn()
} as Partial<LGraphCanvasType> as LGraphCanvasType
}
function createMockEvent(clientX = 100, clientY = 200): CanvasPointerEvent {
return { clientX, clientY } as CanvasPointerEvent
}
describe('ColorWidget', () => {
let node: LGraphNodeType
let widget: ColorWidgetType
let mockCanvas: LGraphCanvasType
let mockEvent: CanvasPointerEvent
let ColorWidget: typeof ColorWidgetType
let LGraphNode: typeof LGraphNodeType
beforeEach(async () => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset modules to get fresh globalColorInput state
vi.resetModules()
const litegraph = await import('@/lib/litegraph/src/litegraph')
LGraphNode = litegraph.LGraphNode
const colorWidgetModule =
await import('@/lib/litegraph/src/widgets/ColorWidget')
ColorWidget = colorWidgetModule.ColorWidget
node = new LGraphNode('TestNode')
mockCanvas = createMockCanvas()
mockEvent = createMockEvent()
})
afterEach(() => {
vi.useRealTimers()
document
.querySelectorAll('input[type="color"]')
.forEach((el) => el.remove())
})
describe('onClick', () => {
it('should create a color input and append it to document body', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input).toBeTruthy()
expect(input.parentElement).toBe(document.body)
})
it('should set input value from widget value', () => {
widget = new ColorWidget(
createMockWidgetConfig({ value: '#00ff00' }),
node
)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#00ff00')
})
it('should default to #000000 when widget value is empty', () => {
widget = new ColorWidget(createMockWidgetConfig({ value: '' }), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#000000')
})
it('should position input at click coordinates', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const event = createMockEvent(150, 250)
widget.onClick({ e: event, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.style.left).toBe('150px')
expect(input.style.top).toBe('250px')
})
it('should click the input on next animation frame', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click')
expect(clickSpy).not.toHaveBeenCalled()
vi.runAllTimers()
expect(clickSpy).toHaveBeenCalled()
})
it('should reuse the same input element on subsequent clicks', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const firstInput = document.querySelector('input[type="color"]')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const secondInput = document.querySelector('input[type="color"]')
expect(firstInput).toBe(secondInput)
expect(document.querySelectorAll('input[type="color"]').length).toBe(1)
})
it('should update input value when clicking with different widget values', () => {
const widget1 = new ColorWidget(
createMockWidgetConfig({ value: '#ff0000' }),
node
)
const widget2 = new ColorWidget(
createMockWidgetConfig({ value: '#0000ff' }),
node
)
widget1.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#ff0000')
widget2.onClick({ e: mockEvent, node, canvas: mockCanvas })
expect(input.value).toBe('#0000ff')
})
})
describe('onChange', () => {
it('should call setValue when color input changes', () => {
widget = new ColorWidget(
createMockWidgetConfig({ value: '#ff0000' }),
node
)
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', {
e: mockEvent,
node,
canvas: mockCanvas
})
})
it('should call canvas.setDirty after value change', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true)
})
it('should remove change listener after firing once', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
input.value = '#0000ff'
input.dispatchEvent(new Event('change'))
// Should only be called once despite two change events
expect(setValueSpy).toHaveBeenCalledTimes(1)
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', expect.any(Object))
})
it('should register new change listener on subsequent onClick', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const setValueSpy = vi.spyOn(widget, 'setValue')
// First click and change
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
// Second click and change
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
input.value = '#0000ff'
input.dispatchEvent(new Event('change'))
expect(setValueSpy).toHaveBeenCalledTimes(2)
expect(setValueSpy).toHaveBeenNthCalledWith(
1,
'#00ff00',
expect.any(Object)
)
expect(setValueSpy).toHaveBeenNthCalledWith(
2,
'#0000ff',
expect.any(Object)
)
})
})
describe('type', () => {
it('should have type "color"', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
expect(widget.type).toBe('color')
})
})
})

View File

@@ -1,26 +1,12 @@
import { t } from '@/i18n'
import type { IColorWidget } from '../types/widgets'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
import { BaseWidget } from './BaseWidget'
// Have one color input to prevent leaking instances
// Browsers don't seem to fire any events when the color picker is cancelled
let colorInput: HTMLInputElement | null = null
function getColorInput(): HTMLInputElement {
if (!colorInput) {
colorInput = document.createElement('input')
colorInput.type = 'color'
colorInput.style.position = 'absolute'
colorInput.style.opacity = '0'
colorInput.style.pointerEvents = 'none'
colorInput.style.zIndex = '-999'
document.body.appendChild(colorInput)
}
return colorInput
}
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for displaying a color picker using native HTML color input
* Widget for displaying a color picker
* This is a widget that only has a Vue widgets implementation
*/
export class ColorWidget
extends BaseWidget<IColorWidget>
@@ -29,59 +15,35 @@ export class ColorWidget
override type = 'color' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
const { width } = options
const { height, y } = this
const { margin } = BaseWidget
const { y, height } = this
const swatchWidth = 40
const swatchHeight = height - 6
const swatchRadius = swatchHeight / 2
const rightPadding = 10
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
// Swatch fixed on the right
const swatchX = width - margin - rightPadding - swatchWidth
const swatchY = y + 3
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
// Draw color swatch as rounded pill
ctx.beginPath()
ctx.roundRect(swatchX, swatchY, swatchWidth, swatchHeight, swatchRadius)
ctx.fillStyle = this.value || '#000000'
ctx.fill()
ctx.strokeStyle = this.outline_color
ctx.strokeRect(15, y, width - 30, height)
// Draw label on the left
ctx.fillStyle = this.secondary_text_color
ctx.textAlign = 'left'
ctx.fillText(this.displayName, margin * 2 + 5, y + height * 0.7)
// Draw hex value to the left of swatch
ctx.fillStyle = this.text_color
ctx.textAlign = 'right'
ctx.fillText(this.value || '#000000', swatchX - 8, y + height * 0.7)
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
const text = `Color: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick({ e, node, canvas }: WidgetEventOptions): void {
const input = getColorInput()
input.value = this.value || '#000000'
input.style.left = `${e.clientX}px`
input.style.top = `${e.clientY}px`
input.addEventListener(
'change',
() => {
this.setValue(input.value, { e, node, canvas })
canvas.setDirty(true)
},
{ once: true }
)
// Wait for next frame else Chrome doesn't render the color picker at the mouse
// Firefox always opens it in top left of window on Windows
requestAnimationFrame(() => input.click())
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
}
}

View File

@@ -107,7 +107,6 @@
"modelUploaded": "تم استيراد النموذج بنجاح.",
"noAssetsFound": "لم يتم العثور على أصول",
"noModelsInFolder": "لا توجد {type} متاحة في هذا المجلد",
"noResultsCanImport": "حاول تعديل البحث أو عوامل التصفية.\nيمكنك أيضًا إضافة النماذج باستخدام زر \"استيراد\" أعلاه.",
"noValidSourceDetected": "لم يتم اكتشاف مصدر استيراد صالح",
"notSureLeaveAsIs": "لست متأكدًا؟ فقط اتركه كما هو",
"onlyCivitaiUrlsSupported": "يتم دعم روابط Civitai فقط",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "الإجراءات",
"allMissingNodesInstalled": "تم تثبيت جميع العقد المفقودة بنجاح",
"applyChanges": "تطبيق التغييرات",
"basicInfo": "معلومات أساسية",
"changingVersion": "تغيير الإصدار من {from} إلى {to}",
"clickToFinishSetup": "انقر",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "الرخصة",
"loadingVersions": "جاري تحميل الإصدارات...",
"mixedSelectionMessage": "لا يمكن تنفيذ إجراء جماعي على تحديد مختلط",
"nav": {
"allExtensions": "جميع الإضافات",
"allInWorkflow": "الكل في: {workflowName}",
"allInstalled": "جميع المثبتة",
"conflicting": "تعارض",
"inWorkflowSection": "في سير العمل",
"installedSection": "المثبتة",
"missingNodes": "عقد مفقودة",
"notInstalled": "غير مثبت",
"updatesAvailable": "تحديثات متوفرة"
},
"nightlyVersion": "ليلي",
"noDescription": "لا يوجد وصف متاح",
"noNodesFound": "لم يتم العثور على عقد",
"noNodesFoundDescription": "لم يمكن تحليل عقد الحزمة، أو أن الحزمة هي امتداد للواجهة فقط ولا تحتوي على أي عقد.",
"noResultsFound": "لم يتم العثور على نتائج مطابقة لبحثك.",
"nodePack": "حزمة العقد",
"nodePackInfo": "معلومات حزمة العقد",
"notAvailable": "غير متوفر",
"packsSelected": "الحزم المحددة",
"repository": "المستودع",
@@ -1352,7 +1337,6 @@
"restartingBackend": "جاري إعادة تشغيل الخلفية لتطبيق التغييرات...",
"searchPlaceholder": "بحث",
"selectVersion": "اختر الإصدار",
"selected": "المحدد",
"sort": {
"created": "الأحدث",
"downloads": "الأكثر شيوعاً",

View File

@@ -756,7 +756,6 @@
"title": "Queue Progress",
"total": "Total: {percent}",
"colonPercent": ": {percent}",
"inlineTotalLabel": "Total",
"currentNode": "Current node:",
"viewAllJobs": "View all jobs",
"viewList": "List view",
@@ -994,7 +993,8 @@
"showAll": "Show all",
"hidden": "Hidden / nested parameters",
"hideAll": "Hide all",
"showRecommended": "Show recommended widgets"
"showRecommended": "Show recommended widgets",
"cannotDeleteGlobal": "Cannot delete installed blueprints"
},
"electronFileDownload": {
"inProgress": "In Progress",
@@ -1304,7 +1304,8 @@
"Execution": "Execution",
"PLY": "PLY",
"Workspace": "Workspace",
"Other": "Other"
"Other": "Other",
"Secrets": "Secrets"
},
"serverConfigItems": {
"listen": {
@@ -1923,13 +1924,7 @@
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again."
},
"deleteAccount": {
"deleteAccount": "Delete Account",
"confirmTitle": "Delete Account",
"confirmMessage": "Are you sure you want to delete your account? This action cannot be undone and will permanently remove all your data.",
"confirm": "Delete Account",
"cancel": "Cancel",
"success": "Account Deleted",
"successDetail": "Your account has been successfully deleted."
"contactSupport": "To delete your account, please contact {email}"
},
"reauthRequired": {
"title": "Re-authentication Required",
@@ -1988,6 +1983,7 @@
"videosEstimate": "~{count} videos*",
"templateNote": "*Generated with Wan Fun Control template",
"buy": "Buy",
"purchaseSuccess": "Credits added successfully!",
"purchaseError": "Purchase Failed",
"purchaseErrorDetail": "Failed to purchase credits: {error}",
"unknownError": "An unknown error occurred",
@@ -2020,19 +2016,49 @@
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits."
}
},
"billingOperation": {
"subscriptionProcessing": "Processing payment — setting up your workspace...",
"subscriptionSuccess": "Subscription updated successfully",
"subscriptionFailed": "Subscription update failed",
"subscriptionTimeout": "Subscription verification timed out",
"topupProcessing": "Processing payment — adding credits...",
"topupSuccess": "Credits added successfully",
"topupFailed": "Top-up failed",
"topupTimeout": "Top-up verification timed out"
},
"subscription": {
"chooseBestPlanWorkspace": "Choose the best plan for your workspace",
"title": "Subscription",
"titleUnsubscribed": "Subscribe to Comfy Cloud",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud Logo",
"beta": "BETA",
"perMonth": "/ month",
"member": "member",
"usdPerMonth": "USD / mo",
"usdPerMonthPerMember": "USD / mo / member",
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
"managePayment": "Manage Payment",
"cancelSubscription": "Cancel Subscription",
"canceled": "Canceled",
"resubscribe": "Resubscribe",
"resubscribeTo": "Resubscribe to {plan}",
"resubscribeSuccess": "Subscription reactivated successfully",
"canceledCard": {
"title": "Your subscription has been canceled",
"description": "You won't be charged again. Your features remain active until {date}."
},
"cancelSuccess": "Subscription cancelled successfully",
"cancelDialog": {
"title": "Cancel subscription",
"description": "Your access continues until {date}. You won't be charged again, and your workspace and credits will be preserved. You can resubscribe anytime.",
"endOfBillingPeriod": "end of billing period",
"keepSubscription": "Keep subscription",
"confirmCancel": "Cancel subscription",
"failed": "Failed to cancel subscription"
},
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
"partnerNodesDescription": "For running commercial/proprietary models",
"totalCredits": "Total credits",
@@ -2081,16 +2107,20 @@
"required": {
"title": "Subscribe to",
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
"subscribe": "Subscribe"
"subscribe": "Subscribe",
"pollingSuccess": "Subscription activated successfully!",
"pollingFailed": "Subscription activation failed",
"pollingTimeout": "Timed out waiting for subscription. Please refresh and try again."
},
"subscribeToRun": "Subscribe",
"subscribeToRunFull": "Subscribe to Run",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"workspaceNotSubscribed": "This workspace is not on a subscription",
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud",
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud and invite members",
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
"description": "Choose the best plan for you",
"descriptionWorkspace": "Choose the best plan for your workspace",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "View enterprise",
@@ -2102,7 +2132,13 @@
"currentPlan": "Current Plan",
"subscribeTo": "Subscribe to {plan}",
"monthlyCreditsLabel": "Monthly credits",
"monthlyCreditsPerMemberLabel": "Monthly credits / member",
"maxMembersLabel": "Max. members",
"yearlyCreditsLabel": "Total yearly credits",
"membersLabel": "Up to {count} members",
"nextMonthInvoice": "Next month invoice",
"invoiceHistory": "Invoice history",
"memberCount": "{count} member | {count} members",
"maxDurationLabel": "Max run duration",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
@@ -2124,6 +2160,27 @@
"billingComingSoon": {
"title": "Coming Soon",
"message": "Team billing is coming soon. You'll be able to subscribe to a plan for your workspace with per-seat pricing. Stay tuned for updates."
},
"preview": {
"confirmPayment": "Confirm your payment",
"confirmPlanChange": "Confirm your plan change",
"startingToday": "Starting today",
"starting": "Starting {date}",
"ends": "Ends {date}",
"eachMonthCreditsRefill": "Each month credits refill to",
"perMember": "/ member",
"showMoreFeatures": "Show more features",
"hideFeatures": "Hide features",
"proratedRefund": "Prorated refund for {plan}",
"proratedCharge": "Prorated charge for {plan}",
"totalDueToday": "Total due today",
"nextPaymentDue": "Next payment due {date}. Cancel anytime.",
"termsAgreement": "By continuing, you agree to Comfy Org's {terms} and {privacy}.",
"terms": "Terms",
"privacyPolicy": "Privacy Policy",
"addCreditCard": "Add credit card",
"confirm": "Confirm",
"backToAllPlans": "Back to all plans"
}
},
"userSettings": {
@@ -2149,7 +2206,7 @@
"placeholder": "Dashboard workspace settings"
},
"members": {
"membersCount": "{count}/50 Members",
"membersCount": "{count}/{maxSeats} Members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
@@ -2165,6 +2222,9 @@
"revokeInvite": "Revoke invite",
"removeMember": "Remove member"
},
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
"upsellBannerUpgrade": "Upgrade to the Creator plan or above to invite additional team members.",
"viewPlans": "View plans",
"noInvites": "No pending invites",
"noMembers": "No members",
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
@@ -2203,6 +2263,14 @@
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite"
},
"inviteUpsellDialog": {
"titleNotSubscribed": "A subscription is required to invite members",
"titleSingleSeat": "Your current plan supports a single seat",
"messageNotSubscribed": "To add team members to this workspace, you need a Creator plan or above. The Standard plan supports only a single seat (the owner).",
"messageSingleSeat": "The Standard plan includes one seat for the workspace owner. To invite additional members, upgrade to the Creator plan or above to unlock multiple seats.",
"viewPlans": "View Plans",
"upgradeToCreator": "Upgrade to Creator"
},
"inviteMemberDialog": {
"title": "Invite a person to this workspace",
"message": "Create a shareable invite link to send to someone",
@@ -2492,13 +2560,34 @@
"civitaiLinkPlaceholder": "Paste link here",
"confirmModelDetails": "Confirm Model Details",
"connectionError": "Please check your connection and try again",
"errorAccessForbidden": "Access to this resource is forbidden.",
"errorConnectionRefused": "Unable to connect to the source. Please try again later.",
"errorDownloadCancelled": "Download was cancelled.",
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
"errorHttpError": "An error occurred while fetching metadata.",
"errorInternalError": "An unexpected error occurred. Please try again.",
"errorInvalidHost": "The source URL hostname could not be resolved.",
"errorInvalidUrl": "Please provide a URL.",
"errorInvalidUrlFormat": "The URL format is invalid. Please check and try again.",
"errorMetadataFetchFailed": "Failed to fetch file information from the source.",
"errorModelTypeNotSupported": "This model type is not supported",
"errorNetworkError": "A network error occurred. Please check your connection and try again.",
"errorNetworkTimeout": "Request timed out. Please try again.",
"errorRateLimited": "Too many requests. Please try again in a few minutes.",
"errorRequestCancelled": "Request was cancelled.",
"errorResourceNotFound": "The file was not found. Please check the URL and try again.",
"errorServiceUnavailable": "Service temporarily unavailable. Please try again later.",
"errorSourceServerError": "The source server is experiencing issues. Please try again later.",
"errorUnauthorized": "Please sign in to continue.",
"errorUnauthorizedSource": "This resource requires authentication. Please add your API token in settings.",
"errorUnknown": "An unexpected error occurred",
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
"errorUnsupportedSource": "This URL is not supported. Only Hugging Face and Civitai URLs are allowed.",
"errorUploadFailed": "Failed to import asset. Please try again.",
"errorUserTokenAccessDenied": "Your API token does not have access to this resource. Please check your token permissions.",
"errorUserTokenInvalid": "Your stored API token is invalid or expired. Please update your token in settings.",
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"fileFormats": "File formats",
"fileName": "File Name",
@@ -2545,11 +2634,13 @@
"tagsPlaceholder": "e.g., models, checkpoint",
"tryAdjustingFilters": "Try adjusting your search or filters",
"unknown": "Unknown",
"unsupportedUrlSource": "This URL is not supported. Use a direct model link from {sources}. See the how-to videos below for help.",
"unsupportedUrlSource": "Only URLs from {sources} are supported",
"upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.",
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
"upload": "Import",
"uploadFailed": "Import failed",
"apiKeyHint": "Importing private or gated models? {link}.",
"apiKeyHintLink": "Add your API keys in Settings",
"uploadingModel": "Importing model...",
"uploadModel": "Import",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
@@ -2808,8 +2899,9 @@
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
},
"inviteAccepted": "Invite Accepted",
"addedToWorkspace": "You have been added to {workspaceName}",
"inviteFailed": "Failed to Accept Invite"
"addedToWorkspace": "You have been added to:",
"inviteFailed": "Failed to Accept Invite",
"viewWorkspace": "View workspace"
},
"workspaceAuth": {
"errors": {
@@ -2825,5 +2917,45 @@
"label": "Preview Version",
"tooltip": "You are using a nightly version of ComfyUI. Please use the feedback button to share your thoughts about these features."
}
},
"nodeFilters": {
"hideDeprecated": "Hide Deprecated Nodes",
"hideDeprecatedDescription": "Hides nodes marked as deprecated unless explicitly enabled",
"hideExperimental": "Hide Experimental Nodes",
"hideExperimentalDescription": "Hides nodes marked as experimental unless explicitly enabled",
"hideDevOnly": "Hide Dev-Only Nodes",
"hideDevOnlyDescription": "Hides nodes marked as dev-only unless dev mode is enabled",
"hideSubgraph": "Hide Subgraph Nodes",
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
},
"secrets": {
"title": "API Keys & Secrets",
"description": "Secrets are encrypted and used for sensitive data like API keys.",
"descriptionUsage": "Store your tokens here to enable downloading private and gated models from supported providers.",
"modelProviders": "Model Providers",
"addSecret": "Add Secret",
"editSecret": "Edit Secret",
"noSecrets": "No secrets stored. Add your first API key to get started.",
"name": "Name",
"namePlaceholder": "e.g., My API Key",
"provider": "Provider",
"providerHint": "Optional. Selecting a provider enables automatic token usage.",
"secretValue": "Secret Value",
"secretValuePlaceholder": "Enter your API key",
"secretValuePlaceholderEdit": "Enter new value to change",
"secretValueHint": "This value will be encrypted and cannot be viewed again.",
"secretValueHintEdit": "Leave blank to keep the current value.",
"createdAt": "Created {date}",
"lastUsed": "Last used {date}",
"deleteConfirmTitle": "Delete Secret",
"deleteConfirmMessage": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"errors": {
"nameRequired": "Name is required",
"nameTooLong": "Name must be 255 characters or less",
"providerRequired": "Provider is required",
"secretValueRequired": "Secret value is required",
"duplicateName": "A secret with this name already exists",
"duplicateProvider": "A secret for this provider already exists"
}
}
}

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Modelo importado correctamente.",
"noAssetsFound": "No se encontraron recursos",
"noModelsInFolder": "No hay {type} disponibles en esta carpeta",
"noResultsCanImport": "Intenta ajustar tu búsqueda o filtros.\nTambién puedes añadir modelos usando el botón \"Importar\" de arriba.",
"noValidSourceDetected": "No se detectó una fuente de importación válida",
"notSureLeaveAsIs": "¿No estás seguro? Déjalo como está",
"onlyCivitaiUrlsSupported": "Solo se admiten URLs de Civitai",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Acciones",
"allMissingNodesInstalled": "Todos los nodos faltantes se han instalado exitosamente",
"applyChanges": "Aplicar Cambios",
"basicInfo": "Información básica",
"changingVersion": "Cambiando versión de {from} a {to}",
"clickToFinishSetup": "Haz clic",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Licencia",
"loadingVersions": "Cargando versiones...",
"mixedSelectionMessage": "No se puede realizar acción masiva en selección mixta",
"nav": {
"allExtensions": "Todas las extensiones",
"allInWorkflow": "Todo en: {workflowName}",
"allInstalled": "Todo instalado",
"conflicting": "En conflicto",
"inWorkflowSection": "EN EL FLUJO DE TRABAJO",
"installedSection": "INSTALADO",
"missingNodes": "Nodos faltantes",
"notInstalled": "No instalado",
"updatesAvailable": "Actualizaciones disponibles"
},
"nightlyVersion": "Nocturna",
"noDescription": "No hay descripción disponible",
"noNodesFound": "No se encontraron nodos",
"noNodesFoundDescription": "Los nodos del paquete no se pudieron analizar, o el paquete es solo una extensión de frontend y no tiene ningún nodo.",
"noResultsFound": "No se encontraron resultados que coincidan con tu búsqueda.",
"nodePack": "Paquete de Nodos",
"nodePackInfo": "Información del paquete de nodos",
"notAvailable": "No Disponible",
"packsSelected": "Paquetes Seleccionados",
"repository": "Repositorio",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Reiniciando backend para aplicar cambios...",
"searchPlaceholder": "Buscar",
"selectVersion": "Seleccionar Versión",
"selected": "Seleccionado",
"sort": {
"created": "Más reciente",
"downloads": "Más Popular",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "مدل با موفقیت وارد شد.",
"noAssetsFound": "هیچ دارایی‌ای یافت نشد",
"noModelsInFolder": "هیچ {type} در این پوشه موجود نیست",
"noResultsCanImport": "جستجو یا فیلترهای خود را تغییر دهید.\nهمچنین می‌توانید مدل‌ها را با استفاده از دکمه «وارد کردن» در بالا اضافه کنید.",
"noValidSourceDetected": "هیچ منبع واردات معتبری شناسایی نشد",
"notSureLeaveAsIs": "مطمئن نیستید؟ همین را باقی بگذارید",
"onlyCivitaiUrlsSupported": "فقط URLهای Civitai پشتیبانی می‌شوند",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "اقدامات",
"allMissingNodesInstalled": "همه نودهای مفقود با موفقیت نصب شدند",
"applyChanges": "اعمال تغییرات",
"basicInfo": "اطلاعات پایه",
"changingVersion": "تغییر نسخه از {from} به {to}",
"clickToFinishSetup": "کلیک کنید",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "مجوز",
"loadingVersions": "در حال بارگذاری نسخه‌ها...",
"mixedSelectionMessage": "امکان انجام عملیات گروهی روی انتخاب ترکیبی وجود ندارد",
"nav": {
"allExtensions": "همه افزونه‌ها",
"allInWorkflow": "همه در: {workflowName}",
"allInstalled": "همه نصب شده‌ها",
"conflicting": "دارای تداخل",
"inWorkflowSection": "در Workflow",
"installedSection": "نصب شده",
"missingNodes": "Nodeهای مفقود",
"notInstalled": "نصب نشده",
"updatesAvailable": "به‌روزرسانی‌های موجود"
},
"nightlyVersion": "نسخه nightly",
"noDescription": "توضیحی موجود نیست",
"noNodesFound": "نودی یافت نشد",
"noNodesFoundDescription": "نودهای این بسته قابل تجزیه نبودند یا این بسته فقط یک افزونه فرانت‌اند است و نودی ندارد.",
"noResultsFound": "نتیجه‌ای مطابق با جستجوی شما یافت نشد.",
"nodePack": "بسته نود",
"nodePackInfo": "اطلاعات Node Pack",
"notAvailable": "در دسترس نیست",
"packsSelected": "بسته انتخاب شد",
"repository": "مخزن",
@@ -1352,7 +1337,6 @@
"restartingBackend": "در حال راه‌اندازی مجدد backend برای اعمال تغییرات...",
"searchPlaceholder": "جستجو",
"selectVersion": "انتخاب نسخه",
"selected": "انتخاب شده",
"sort": {
"created": "جدیدترین",
"downloads": "محبوب‌ترین",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Modèle importé avec succès.",
"noAssetsFound": "Aucune ressource trouvée",
"noModelsInFolder": "Aucun {type} disponible dans ce dossier",
"noResultsCanImport": "Essayez dajuster votre recherche ou vos filtres.\nVous pouvez également ajouter des modèles en utilisant le bouton « Importer » ci-dessus.",
"noValidSourceDetected": "Aucune source d'importation valide détectée",
"notSureLeaveAsIs": "Vous n'êtes pas sûr ? Laissez tel quel",
"onlyCivitaiUrlsSupported": "Seules les URL Civitai sont prises en charge",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Actions",
"allMissingNodesInstalled": "Tous les nœuds manquants ont été installés avec succès",
"applyChanges": "Appliquer les modifications",
"basicInfo": "Informations de base",
"changingVersion": "Changement de version de {from} à {to}",
"clickToFinishSetup": "Cliquez",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Licence",
"loadingVersions": "Chargement des versions...",
"mixedSelectionMessage": "Impossible d'effectuer une action groupée sur une sélection mixte",
"nav": {
"allExtensions": "Toutes les extensions",
"allInWorkflow": "Tout dans : {workflowName}",
"allInstalled": "Tout installé",
"conflicting": "En conflit",
"inWorkflowSection": "DANS LE FLUX DE TRAVAIL",
"installedSection": "INSTALLÉ",
"missingNodes": "Nœuds manquants",
"notInstalled": "Non installé",
"updatesAvailable": "Mises à jour disponibles"
},
"nightlyVersion": "Nocturne",
"noDescription": "Aucune description disponible",
"noNodesFound": "Aucun nœud trouvé",
"noNodesFoundDescription": "Les nœuds du pack n'ont pas pu être analysés, ou le pack est une extension frontend uniquement et n'a pas de nœuds.",
"noResultsFound": "Aucun résultat trouvé correspondant à votre recherche.",
"nodePack": "Pack de Nœuds",
"nodePackInfo": "Informations sur le pack de nœuds",
"notAvailable": "Non disponible",
"packsSelected": "Packs sélectionnés",
"repository": "Référentiel",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Redémarrage du backend pour appliquer les modifications...",
"searchPlaceholder": "Recherche",
"selectVersion": "Sélectionner la version",
"selected": "Sélectionné",
"sort": {
"created": "Le plus récent",
"downloads": "Le plus populaire",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "モデルが正常にインポートされました。",
"noAssetsFound": "アセットが見つかりません",
"noModelsInFolder": "このフォルダには{type}がありません",
"noResultsCanImport": "検索やフィルターを調整してみてください。\nまた、上の「インポート」ボタンからモデルを追加することもできます。",
"noValidSourceDetected": "有効なインポート元が検出されませんでした",
"notSureLeaveAsIs": "分からない場合はそのままにしてください",
"onlyCivitaiUrlsSupported": "CivitaiのURLのみサポートされています",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "アクション",
"allMissingNodesInstalled": "すべての不足しているノードが正常にインストールされました",
"applyChanges": "変更を適用",
"basicInfo": "基本情報",
"changingVersion": "バージョンを {from} から {to} に変更",
"clickToFinishSetup": "クリック",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "ライセンス",
"loadingVersions": "バージョンを読み込んでいます...",
"mixedSelectionMessage": "混在した選択では一括操作を実行できません",
"nav": {
"allExtensions": "すべての拡張機能",
"allInWorkflow": "{workflowName} 内のすべて",
"allInstalled": "すべてインストール済み",
"conflicting": "競合",
"inWorkflowSection": "ワークフロー内",
"installedSection": "インストール済み",
"missingNodes": "不足しているノード",
"notInstalled": "未インストール",
"updatesAvailable": "アップデートあり"
},
"nightlyVersion": "ナイトリー",
"noDescription": "説明はありません",
"noNodesFound": "ノードが見つかりません",
"noNodesFoundDescription": "パックのノードは解析できなかったか、パックがフロントエンドの拡張機能のみでノードがない可能性があります。",
"noResultsFound": "検索に一致する結果が見つかりませんでした。",
"nodePack": "ノードパック",
"nodePackInfo": "ノードパック情報",
"notAvailable": "利用不可",
"packsSelected": "選択したパック",
"repository": "リポジトリ",
@@ -1352,7 +1337,6 @@
"restartingBackend": "変更を適用するためにバックエンドを再起動しています...",
"searchPlaceholder": "検索",
"selectVersion": "バージョンを選択",
"selected": "選択済み",
"sort": {
"created": "最新",
"downloads": "最も人気",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "모델이 성공적으로 가져와졌습니다.",
"noAssetsFound": "에셋을 찾을 수 없습니다",
"noModelsInFolder": "이 폴더에 사용 가능한 {type}이(가) 없습니다",
"noResultsCanImport": "검색어나 필터를 조정해보세요.\n또는 위의 \"가져오기\" 버튼을 사용해 모델을 추가할 수 있습니다.",
"noValidSourceDetected": "유효한 가져오기 소스를 감지하지 못했습니다",
"notSureLeaveAsIs": "잘 모르겠다면 그대로 두세요",
"onlyCivitaiUrlsSupported": "Civitai URL만 지원됩니다",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "작업",
"allMissingNodesInstalled": "누락된 모든 노드가 성공적으로 설치되었습니다",
"applyChanges": "변경사항 적용",
"basicInfo": "기본 정보",
"changingVersion": "{from}에서 {to}(으)로 버전 변경 중",
"clickToFinishSetup": "클릭",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "라이선스",
"loadingVersions": "버전 로딩 중...",
"mixedSelectionMessage": "혼합 선택에 대해 일괄 작업을 수행할 수 없습니다",
"nav": {
"allExtensions": "모든 확장 프로그램",
"allInWorkflow": "모두: {workflowName}",
"allInstalled": "모두 설치됨",
"conflicting": "충돌",
"inWorkflowSection": "워크플로우 내",
"installedSection": "설치됨",
"missingNodes": "누락된 노드",
"notInstalled": "미설치",
"updatesAvailable": "업데이트 가능"
},
"nightlyVersion": "최신 테스트 버전(nightly)",
"noDescription": "설명이 없습니다",
"noNodesFound": "노드를 찾을 수 없습니다",
"noNodesFoundDescription": "팩의 노드를 파싱할 수 없거나, 팩이 프론트엔드 확장만을 가지고 있어서 노드가 없습니다.",
"noResultsFound": "검색과 일치하는 결과가 없습니다.",
"nodePack": "노드 팩",
"nodePackInfo": "노드 팩 정보",
"notAvailable": "사용 불가",
"packsSelected": "선택한 노드 팩",
"repository": "저장소",
@@ -1352,7 +1337,6 @@
"restartingBackend": "변경사항을 적용하기 위해 백엔드를 다시 시작하는 중...",
"searchPlaceholder": "검색",
"selectVersion": "버전 선택",
"selected": "선택됨",
"sort": {
"created": "최신",
"downloads": "가장 인기 있는",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Modelo importado com sucesso.",
"noAssetsFound": "Nenhum ativo encontrado",
"noModelsInFolder": "Nenhum {type} disponível nesta pasta",
"noResultsCanImport": "Tente ajustar sua busca ou filtros.\nVocê também pode adicionar modelos usando o botão \"Importar\" acima.",
"noValidSourceDetected": "Nenhuma fonte de importação válida detectada",
"notSureLeaveAsIs": "Não tem certeza? Deixe como está",
"onlyCivitaiUrlsSupported": "Apenas URLs do Civitai são suportadas",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Ações",
"allMissingNodesInstalled": "Todos os nodes ausentes foram instalados com sucesso",
"applyChanges": "Aplicar Alterações",
"basicInfo": "Informações Básicas",
"changingVersion": "Alterando versão de {from} para pt-BR",
"clickToFinishSetup": "Clique",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Licença",
"loadingVersions": "Carregando versões...",
"mixedSelectionMessage": "Não é possível realizar ação em massa em seleção mista",
"nav": {
"allExtensions": "Todas as Extensões",
"allInWorkflow": "Todos em: {workflowName}",
"allInstalled": "Todos Instalados",
"conflicting": "Conflitante",
"inWorkflowSection": "NO FLUXO DE TRABALHO",
"installedSection": "INSTALADO",
"missingNodes": "Nós Ausentes",
"notInstalled": "Não Instalado",
"updatesAvailable": "Atualizações Disponíveis"
},
"nightlyVersion": "Noturna",
"noDescription": "Nenhuma descrição disponível",
"noNodesFound": "Nenhum node encontrado",
"noNodesFoundDescription": "Os nodes do pacote não puderam ser analisados ou o pacote é apenas uma extensão de frontend e não possui nodes.",
"noResultsFound": "Nenhum resultado encontrado para sua busca.",
"nodePack": "Node Pack",
"nodePackInfo": "Informações do Pacote de Nós",
"notAvailable": "Não Disponível",
"packsSelected": "pacotes selecionados",
"repository": "Repositório",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Reiniciando backend para aplicar as alterações...",
"searchPlaceholder": "Buscar",
"selectVersion": "Selecionar Versão",
"selected": "Selecionado",
"sort": {
"created": "Mais Novos",
"downloads": "Mais Populares",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Модель успешно импортирована.",
"noAssetsFound": "Ресурсы не найдены",
"noModelsInFolder": "Нет {type} в этой папке",
"noResultsCanImport": "Попробуйте изменить параметры поиска или фильтры.\nВы также можете добавить модели с помощью кнопки «Импортировать» выше.",
"noValidSourceDetected": "Не обнаружен действительный источник импорта",
"notSureLeaveAsIs": "Не уверены? Просто оставьте как есть",
"onlyCivitaiUrlsSupported": "Поддерживаются только ссылки Civitai",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Действия",
"allMissingNodesInstalled": "Все отсутствующие ноды успешно установлены",
"applyChanges": "Применить изменения",
"basicInfo": "Основная информация",
"changingVersion": "Изменение версии с {from} на {to}",
"clickToFinishSetup": "Нажмите",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Лицензия",
"loadingVersions": "Загрузка версий...",
"mixedSelectionMessage": "Невозможно выполнить массовое действие для смешанного выбора",
"nav": {
"allExtensions": "Все расширения",
"allInWorkflow": "Все в: {workflowName}",
"allInstalled": "Все установленные",
"conflicting": "Конфликтующие",
"inWorkflowSection": "В РАБОЧЕМ ПРОЦЕССЕ",
"installedSection": "УСТАНОВЛЕНО",
"missingNodes": "Отсутствующие узлы",
"notInstalled": "Не установлено",
"updatesAvailable": "Доступны обновления"
},
"nightlyVersion": "Ночная",
"noDescription": "Описание отсутствует",
"noNodesFound": "Узлы не найдены",
"noNodesFoundDescription": "Узлы пакета не могут быть проанализированы, или пакет является только расширением интерфейса и не имеет узлов.",
"noResultsFound": "По вашему запросу ничего не найдено.",
"nodePack": "Пакет Узлов",
"nodePackInfo": "Информация о пакете узлов",
"notAvailable": "Недоступно",
"packsSelected": "Выбрано пакетов",
"repository": "Репозиторий",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Перезапуск бэкенда для применения изменений...",
"searchPlaceholder": "Поиск",
"selectVersion": "Выберите версию",
"selected": "Выбрано",
"sort": {
"created": "Новейшие",
"downloads": "Самые популярные",

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