Compare commits

..

52 Commits

Author SHA1 Message Date
Benjamin Lu
4367aea2f1 [backport cloud/1.38] fix: use existing workflow save prompt i18n key 2026-02-11 16:38:52 -08:00
Benjamin Lu
ebc57e7aad feat: integrate Impact telemetry with checkout attribution for subscriptions (#8688)
Implement Impact telemetry and checkout attribution through cloud
subscription checkout flows.

This PR adds Impact.com tracking support and carries attribution context
from landing-page visits into subscription checkout requests so
conversion attribution can be validated end-to-end.

- Register a new `ImpactTelemetryProvider` during cloud telemetry
initialization.
- Initialize the Impact queue/runtime (`ire`) and load the Universal
Tracking Tag script once.
- Invoke `ire('identify', ...)` on page views with dynamic `customerId`
and SHA-1 `customerEmail` (or empty strings when unknown).
- Expand checkout attribution capture to include `im_ref`, UTM fields,
and Google click IDs, with local persistence across navigation.
- Attempt `ire('generateClickId')` with a timeout and fall back to
URL/local attribution when unavailable.
- Include attribution payloads in checkout creation requests for both:
  - `/customers/cloud-subscription-checkout`
  - `/customers/cloud-subscription-checkout/{tier}`
- Extend begin-checkout telemetry metadata typing to include attribution
fields.
- Add focused unit coverage for provider behavior, attribution
persistence/fallback logic, and checkout request payloads.

Tradeoffs / constraints:
- Attribution collection is treated as best-effort in tiered checkout
flow to avoid blocking purchases.
- Backend checkout handlers must accept and process the additional JSON
attribution fields.

## Screenshots

<img width="908" height="208" alt="image"
src="https://github.com/user-attachments/assets/03c16d60-ffda-40c9-9bd6-8914d841be50"/>
<img width="1144" height="460" alt="image"
src="https://github.com/user-attachments/assets/74b97fde-ce0a-43e6-838e-9a4aba484488"/>
<img width="1432" height="320" alt="image"
src="https://github.com/user-attachments/assets/30c22a9f-7bd8-409f-b0ef-e4d02343780a"/>
<img width="341" height="135" alt="image"
src="https://github.com/user-attachments/assets/f6d918ae-5f80-45e0-855a-601abea61dec"/>

(cherry picked from commit da56c9e554)
2026-02-11 16:19:40 -08:00
Benjamin Lu
d44924a73d fix: keep begin_checkout user_id reactive in subscription flows (#8726)
## Summary

Use reactive `userId` reads for `begin_checkout` telemetry so delayed
auth state updates are reflected at event time instead of using a stale
snapshot.

## Changes

- **What**: switched subscription checkout telemetry paths to
`storeToRefs(useFirebaseAuthStore())` and read `userId.value` when
dispatching `trackBeginCheckout`.
- **What**: added regression tests that mutate `userId` after setup /
after checkout starts and assert telemetry uses the updated ID.

## Review Focus

- Verify `PricingTable` and `performSubscriptionCheckout` still emit
exactly one `begin_checkout` event per action, with `checkout_type:
change` and `checkout_type: new` in their respective paths.
- Verify the new tests would fail with stale store destructuring
(manually validated during development).

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8726-fix-keep-begin_checkout-user_id-reactive-in-subscription-flows-3006d73d365081888c84c0335ab52e09)
by [Unito](https://www.unito.io)

(cherry picked from commit 815be49112)
2026-02-11 16:19:36 -08:00
Benjamin Lu
090da3e2c9 [backport cloud/1.38] fix: route gtm through telemetry entrypoint (#8354) 2026-02-11 16:17:37 -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
323 changed files with 11420 additions and 4399 deletions

1
.gitattributes vendored
View File

@@ -11,7 +11,6 @@
*.ts text eol=lf
*.vue text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
# Generated files
packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true

View File

@@ -104,14 +104,14 @@ runs:
- name: Find existing comment
id: find
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
with:
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: ${{ steps.build.outputs.marker_search }}
- name: Post or update comment
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
with:
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
comment-id: ${{ steps.find.outputs.comment-id }}

View File

@@ -16,7 +16,7 @@ runs:
# Checkout ComfyUI repo, install the dev_tools node and start server
- name: Checkout ComfyUI
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
repository: 'comfyanonymous/ComfyUI'
path: 'ComfyUI'
@@ -33,7 +33,7 @@ runs:
fi
- name: Setup Python
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: '3.10'

View File

@@ -12,17 +12,29 @@ runs:
# Install pnpm, Node.js, build frontend
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
cache-dependency-path: './pnpm-lock.yaml'
# Restore tool caches before running any build/lint operations
- name: Restore tool output cache
uses: actions/cache/restore@v4
with:
path: |
./.cache
./tsconfig.tsbuildinfo
key: tool-cache-${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml') }}-${{ hashFiles('./src/**/*.{ts,vue,js,mts}', './*.config.*') }}
restore-keys: |
tool-cache-${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml') }}-
tool-cache-${{ runner.os }}-
- name: Install dependencies
shell: bash
run: pnpm install --frozen-lockfile

View File

@@ -11,7 +11,7 @@ runs:
echo "playwright-version=$PLAYWRIGHT_VERSION" >> $GITHUB_OUTPUT
- name: Cache Playwright Browsers
uses: actions/cache@v5 # v5.0.2
uses: actions/cache@v4
id: cache-playwright-browsers
with:
path: '~/.cache/ms-playwright'

View File

@@ -13,15 +13,15 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'pnpm'
@@ -36,7 +36,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'

View File

@@ -18,15 +18,15 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'pnpm'
@@ -35,7 +35,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Checkout ComfyUI-Manager repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
repository: Comfy-Org/ComfyUI-Manager
path: ComfyUI-Manager
@@ -86,7 +86,7 @@ jobs:
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'

View File

@@ -17,15 +17,15 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'pnpm'
@@ -34,7 +34,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Checkout comfy-api repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
repository: Comfy-Org/comfy-api
path: comfy-api
@@ -87,7 +87,7 @@ jobs:
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'

View File

@@ -0,0 +1,52 @@
name: 'CI: Dist Telemetry Scan'
on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project
run: pnpm build
- name: Scan dist for telemetry references
run: |
set -euo pipefail
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e 'Google Tag Manager' \
-e '(?i)\bgtm\.js\b' \
-e '(?i)googletagmanager\.com/gtm\.js\\?id=' \
-e '(?i)googletagmanager\.com/ns\.html\\?id=' \
dist; then
echo 'Telemetry references found in dist assets.'
exit 1
fi
echo 'No telemetry references found in dist assets.'

View File

@@ -13,6 +13,6 @@ jobs:
json-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Validate JSON syntax
run: ./scripts/cicd/check-json.sh

View File

@@ -18,12 +18,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run ESLint with auto-fix
run: pnpm lint:fix
@@ -62,7 +73,7 @@ jobs:
- name: Comment on PR about auto-fix
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
@@ -75,7 +86,7 @@ jobs:
- name: Comment on PR about manual fix needed
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
continue-on-error: true
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({

View File

@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.11'

View File

@@ -17,10 +17,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Install pnpm
uses: pnpm/action-setup@v4.1.0
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Build project
run: pnpm build
@@ -35,7 +46,7 @@ jobs:
echo ${{ github.base_ref }} > ./temp/size/base.txt
- name: Upload size data
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: size-data
path: temp/size

View File

@@ -31,11 +31,11 @@ jobs:
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Get PR Number
id: pr
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const { data: prs } = await github.rest.pulls.list({
@@ -68,7 +68,7 @@ jobs:
- name: Download and Deploy Reports
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
@@ -25,7 +25,7 @@ jobs:
# Upload only built dist/ (containerized test jobs will pnpm install without cache)
- name: Upload built frontend
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: dist/
@@ -51,9 +51,9 @@ jobs:
shardTotal: [8]
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Download built frontend
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: dist/
@@ -72,7 +72,7 @@ jobs:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
- name: Upload blob report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: blob-report-chromium-${{ matrix.shardIndex }}
@@ -98,9 +98,9 @@ jobs:
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Download built frontend
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: dist/
@@ -128,7 +128,7 @@ jobs:
pnpm exec playwright merge-reports --reporter=json ./blob-report
- name: Upload Playwright report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.browser }}
@@ -141,13 +141,16 @@ jobs:
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@v4
with:
version: 10
- name: Download blob reports
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
path: ./all-blob-reports
pattern: blob-report-chromium-*
@@ -162,7 +165,7 @@ jobs:
pnpm dlx @playwright/test merge-reports --reporter=json ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: playwright-report-chromium
path: ./playwright-report/
@@ -180,7 +183,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Get start time
id: start-time
@@ -207,10 +210,10 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Download all playwright reports
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
pattern: playwright-report-*
path: reports

View File

@@ -31,11 +31,11 @@ jobs:
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Get PR Number
id: pr
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const { data: prs } = await github.rest.pulls.list({
@@ -68,7 +68,7 @@ jobs:
- name: Download and Deploy Storybook
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}

View File

@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Post starting comment
env:
@@ -36,10 +36,21 @@ jobs:
workflow-url: ${{ steps.workflow-url.outputs.url }}
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Storybook
run: pnpm build-storybook
@@ -58,7 +69,7 @@ jobs:
- name: Upload Storybook build
if: success() && github.event.pull_request.head.repo.fork == false
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: storybook-static/
@@ -75,16 +86,27 @@ jobs:
chromatic-storybook-url: ${{ steps.chromatic.outputs.storybookUrl }}
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 0 # Required for Chromatic baseline
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Storybook and run Chromatic
id: chromatic
uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13.3.5
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
buildScriptName: build-storybook
@@ -114,11 +136,11 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Download Storybook build
if: needs.storybook-build.outputs.conclusion == 'success'
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
name: storybook-static
path: storybook-static
@@ -148,7 +170,7 @@ jobs:
pull-requests: write
steps:
- name: Update comment with Chromatic URLs
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const buildUrl = '${{ needs.chromatic-deployment.outputs.chromatic-build-url }}';

View File

@@ -16,10 +16,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Vitest tests
run: pnpm test:unit

View File

@@ -1,21 +0,0 @@
name: Validate Action SHA Pins
on:
pull_request:
paths:
- '.github/workflows/**'
- '.github/actions/**'
- '.pinact.yaml'
permissions:
contents: read
jobs:
validate-pins:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: suzuki-shunsuke/pinact-action@3d49c6412901042473ffa78becddab1aea46bbea # v1.3.1
with:
skip_push: 'true'

View File

@@ -17,10 +17,10 @@ jobs:
yaml-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.x'

View File

@@ -18,12 +18,12 @@ jobs:
steps:
- name: Checkout merge commit
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
# Setup playwright environment
- name: Setup ComfyUI Frontend

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
# Setup playwright environment with custom node repository
- name: Setup ComfyUI Server (without launching)
@@ -36,7 +36,7 @@ jobs:
# Install the custom node repository
- name: Checkout custom node repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
repository: ${{ inputs.owner }}/${{ inputs.repository }}
path: 'ComfyUI/custom_nodes/${{ inputs.repository }}'
@@ -113,7 +113,7 @@ jobs:
git commit -m "Update locales"
- name: Install SSH key For PUSH
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4 # v2.7.0
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
with:
# PR private key from action server
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
# Setup playwright environment
- name: Setup ComfyUI Server (and start)
uses: ./.github/actions/setup-comfyui-server
@@ -40,7 +40,7 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'Update locales for node definitions'

View File

@@ -64,7 +64,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 0

View File

@@ -23,18 +23,18 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: refs/pull/${{ github.event.pull_request.number }}/head
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
@@ -44,7 +44,7 @@ jobs:
pnpm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38
uses: anthropics/claude-code-action@v1.0.6
with:
label_trigger: 'claude-review'
prompt: |

View File

@@ -33,13 +33,24 @@ jobs:
github.event_name == 'workflow_dispatch'
)
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Install pnpm
uses: pnpm/action-setup@v4.1.0
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Download size data
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
uses: dawidd6/action-download-artifact@v11
with:
name: size-data
run_id: ${{ github.event_name == 'workflow_dispatch' && inputs.run_id || github.event.workflow_run.id }}
@@ -64,7 +75,7 @@ jobs:
fi
- name: Download previous size data
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
uses: dawidd6/action-download-artifact@v11
with:
branch: ${{ steps.pr-base.outputs.content }}
workflow: ci-size-data.yaml
@@ -78,12 +89,12 @@ jobs:
- name: Read size report
id: size-report
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
uses: juliangruber/read-file-action@v1
with:
path: ./size-report.md
- name: Create or update PR comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
uses: actions-cool/maintain-one-comment@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ steps.pr-number.outputs.content }}

View File

@@ -38,7 +38,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Find Update Comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
id: 'find-update-comment'
with:
issue-number: ${{ steps.pr-info.outputs.pr-number }}
@@ -46,7 +46,7 @@ jobs:
body-includes: 'Updating Playwright Expectations'
- name: Add Starting Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
with:
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
issue-number: ${{ steps.pr-info.outputs.pr-number }}
@@ -56,7 +56,7 @@ jobs:
reactions: eyes
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: ${{ steps.pr-info.outputs.branch }}
- name: Setup frontend
@@ -66,7 +66,7 @@ jobs:
# Upload built dist/ (containerized test jobs will pnpm install without cache)
- name: Upload built frontend
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: dist/
@@ -91,11 +91,11 @@ jobs:
shardTotal: [4]
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: ${{ needs.setup.outputs.branch }}
- name: Download built frontend
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: dist/
@@ -149,7 +149,7 @@ jobs:
# Upload ONLY the changed files from this shard
- name: Upload changed snapshots
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
if: steps.changed-snapshots.outputs.has-changes == 'true'
with:
name: snapshots-shard-${{ matrix.shardIndex }}
@@ -157,7 +157,7 @@ jobs:
retention-days: 1
- name: Upload test report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-shard-${{ matrix.shardIndex }}
@@ -170,13 +170,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: ${{ needs.setup.outputs.branch }}
# Download all changed snapshot files from shards
- name: Download snapshot artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
pattern: snapshots-shard-*
path: ./downloaded-snapshots
@@ -301,7 +301,7 @@ jobs:
echo "✓ Commit and push successful"
- name: Add Done Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
if: github.event_name == 'issue_comment' && steps.commit.outputs.has-changes == 'true'
with:
comment-id: ${{ needs.setup.outputs.comment-id }}

View File

@@ -20,13 +20,13 @@ jobs:
dist_tag: ${{ steps.dist.outputs.dist_tag }}
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: '24.x'
@@ -71,7 +71,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout merge commit
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 2

View File

@@ -77,19 +77,19 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: 'pnpm'

View File

@@ -61,13 +61,13 @@ jobs:
steps:
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 0
path: frontend
- name: Checkout ComfyUI (sparse)
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
repository: Comfy-Org/ComfyUI
sparse-checkout: |
@@ -75,12 +75,12 @@ jobs:
path: comfyui
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: lts/*
@@ -169,7 +169,7 @@ jobs:
steps:
- name: Checkout ComfyUI fork
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
repository: ${{ inputs.comfyui_fork || 'Comfy-Org/ComfyUI' }}
token: ${{ secrets.PR_GH_TOKEN }}

View File

@@ -18,13 +18,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.PR_GH_TOKEN || secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 'lts/*'

View File

@@ -19,12 +19,12 @@ jobs:
is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
@@ -55,7 +55,7 @@ jobs:
pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: dist-files
path: |
@@ -66,13 +66,16 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Download dist artifact
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
name: dist-files
- name: Create release
id: create_release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: >-
softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -95,13 +98,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Download dist artifact
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
name: dist-files
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install build dependencies
@@ -116,7 +119,8 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
uses: >-
pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist
@@ -143,7 +147,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout merge commit
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 2

View File

@@ -69,18 +69,18 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: 'lts/*'
cache: 'pnpm'

View File

@@ -15,12 +15,12 @@ jobs:
version: ${{ steps.current_version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
@@ -40,7 +40,7 @@ jobs:
pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: dist-files
path: |
@@ -52,13 +52,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Download dist artifact
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
name: dist-files
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install build dependencies
@@ -73,7 +73,7 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ format('{0}.dev{1}', needs.build.outputs.version, inputs.devVersion) }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist

View File

@@ -65,7 +65,7 @@ jobs:
- name: Close stale nightly version bump PRs
if: github.event_name == 'schedule'
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
github-token: ${{ github.token }}
script: |
@@ -118,7 +118,7 @@ jobs:
core.info(`Closed ${closed.length} stale PR(s).`)
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: ${{ steps.prepared-inputs.outputs.branch }}
fetch-depth: 0
@@ -142,12 +142,12 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: lts/*
@@ -180,7 +180,7 @@ jobs:
echo "capitalised=${CAPITALISED_TYPE@u}" >> "$GITHUB_OUTPUT"
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[release] Increment version to ${{ steps.bump-version.outputs.NEW_VERSION }}'

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
@@ -51,12 +51,12 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: 'pnpm'
@@ -79,7 +79,7 @@ jobs:
echo "capitalised=${VERSION_TYPE@u}" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[release] Increment desktop-ui to ${{ steps.bump-version.outputs.NEW_VERSION }}'

View File

@@ -22,18 +22,18 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 50
fetch-depth: 0
ref: main
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
@@ -49,7 +49,7 @@ jobs:
fi
- name: Run Claude Documentation Review
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38
uses: anthropics/claude-code-action@v1.0.6
with:
prompt: |
Is all documentation still 100% accurate?
@@ -130,7 +130,7 @@ jobs:
- name: Create or Update Pull Request
if: steps.check_changes.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'docs: weekly documentation accuracy update'

2
.gitignore vendored
View File

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

View File

@@ -1,24 +0,0 @@
# pinact configuration
# https://github.com/suzuki-shunsuke/pinact
version: 3
files:
- pattern: .github/workflows/*.yaml
- pattern: .github/actions/**/*.yaml
# Actions that don't need SHA pinning (official GitHub actions are trusted)
ignore_actions:
- name: actions/cache
ref: v5
- name: actions/checkout
ref: v6
- name: actions/setup-node
ref: v6
- name: actions/setup-python
ref: v6
- name: actions/upload-artifact
ref: v6
- name: actions/download-artifact
ref: v7
- name: actions/github-script
ref: v8

View File

@@ -8,6 +8,3 @@ rules:
line-length: disable
document-start: disable
truthy: disable
comments:
min-spaces-from-content: 1

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

@@ -18,8 +18,7 @@ Basic setup for testing Pinia stores:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowStore } from '@/domains/workflow/ui/stores/workflowStore'
@@ -28,8 +27,8 @@ describe('useWorkflowStore', () => {
let store: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
// Create a fresh testing pinia and activate it for each test
setActivePinia(createTestingPinia({ stubActions: false }))
// Create a fresh pinia and activate it for each test
setActivePinia(createPinia())
// Initialize the store
store = useWorkflowStore()

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) })
```
```
```

14
global.d.ts vendored
View File

@@ -5,8 +5,14 @@ declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean
interface ImpactQueueFunction {
(...args: unknown[]): void
a?: unknown[][]
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
@@ -30,6 +36,14 @@ interface Window {
badge?: string
}
}
__ga_identity__?: {
client_id?: string
session_id?: string
session_number?: string
}
dataLayer?: Array<Record<string, unknown>>
ire_o?: string
ire?: ImpactQueueFunction
}
interface Navigator {

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

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

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

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

@@ -21,17 +21,16 @@
</div>
</div>
<div class="option-badges">
<Tag
v-if="nodeDef.deprecated"
:value="$t('g.deprecated')"
severity="danger"
/>
<Tag
v-if="nodeDef.experimental"
:value="$t('g.experimental')"
severity="primary"
/>
<Tag v-if="nodeDef.dev_only" :value="$t('g.devOnly')" severity="info" />
<Tag
v-if="nodeDef.deprecated"
:value="$t('g.deprecated')"
severity="danger"
/>
<Tag
v-if="showNodeFrequency && nodeFrequency > 0"
:value="formatNumberWithSuffix(nodeFrequency, { roundToInt: true })"

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

@@ -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,76 @@
import type { ComputedRef, Ref } from 'vue'
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>
}

View File

@@ -0,0 +1,164 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBillingContext } from './useBillingContext'
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
const isInPersonalWorkspace = { value: true }
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
return {
useTeamWorkspaceStore: () => ({
isInPersonalWorkspace: isInPersonalWorkspace.value,
activeWorkspace: activeWorkspace.value,
_setPersonalWorkspace: (value: boolean) => {
isInPersonalWorkspace.value = value
activeWorkspace.value = value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
}
})
}
})
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', () => {
const plans = { value: [] }
const currentPlanSlug = { value: null }
return {
useBillingPlans: () => ({
plans,
currentPlanSlug,
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()
})
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()
})
})

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