diff --git a/browser_tests/fixtures/components/Actionbar.ts b/browser_tests/fixtures/components/Actionbar.ts
index 0fad74979a..3bf96cc0dd 100644
--- a/browser_tests/fixtures/components/Actionbar.ts
+++ b/browser_tests/fixtures/components/Actionbar.ts
@@ -8,11 +8,13 @@ export class ComfyActionbar {
public readonly root: Locator
public readonly queueButton: ComfyQueueButton
public readonly propertiesButton: Locator
+ public readonly dragHandle: Locator
constructor(public readonly page: Page) {
this.root = page.locator('.actionbar-container')
this.queueButton = new ComfyQueueButton(this)
this.propertiesButton = this.root.getByLabel('Toggle properties panel')
+ this.dragHandle = this.root.locator('.drag-handle')
}
async isDocked() {
diff --git a/browser_tests/fixtures/components/FreeTierQuota.ts b/browser_tests/fixtures/components/FreeTierQuota.ts
new file mode 100644
index 0000000000..798e262109
--- /dev/null
+++ b/browser_tests/fixtures/components/FreeTierQuota.ts
@@ -0,0 +1,21 @@
+import type { Locator } from '@playwright/test'
+
+import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
+import { TestIds } from '@e2e/fixtures/selectors'
+
+export class FreeTierQuota {
+ readonly root: Locator
+
+ constructor(comfyPage: ComfyPage) {
+ this.root = comfyPage.page.getByTestId(TestIds.topbar.freeTierQuota)
+ }
+
+ async getMax() {
+ const text = await this.root.textContent()
+ return text?.match(/(\d+) \/ (\d+)/)?.[2]
+ }
+ async getAvailable() {
+ const text = await this.root.textContent()
+ return text?.match(/(\d+) \/ (\d+)/)?.[1]
+ }
+}
diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts
index 385736733e..43023d8ee9 100644
--- a/browser_tests/fixtures/selectors.ts
+++ b/browser_tests/fixtures/selectors.ts
@@ -103,7 +103,8 @@ export const TestIds = {
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
workflowTabs: 'topbar-workflow-tabs',
integratedTabBarActions: 'integrated-tab-bar-actions',
- actionBarButtons: 'action-bar-buttons'
+ actionBarButtons: 'action-bar-buttons',
+ freeTierQuota: 'free-tier-quota'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'
diff --git a/browser_tests/tests/freeTierQuota.spec.ts b/browser_tests/tests/freeTierQuota.spec.ts
new file mode 100644
index 0000000000..870311e6b8
--- /dev/null
+++ b/browser_tests/tests/freeTierQuota.spec.ts
@@ -0,0 +1,63 @@
+import { expect, mergeTests } from '@playwright/test'
+
+import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
+import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
+import { FreeTierQuota } from '@e2e/fixtures/components/FreeTierQuota'
+import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
+import { webSocketFixture } from '@e2e/fixtures/ws'
+
+const wstest = mergeTests(test, webSocketFixture)
+
+test.describe('Free Tier Quota', { tag: ['@cloud', '@vue-nodes'] }, () => {
+ test.beforeEach(async ({ page }) => {
+ const features = {
+ free_tier_job_allowance_enabled: true,
+ free_tier_balance: { allowance: 5, remaining: 3, used: 0 }
+ }
+ await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
+ })
+
+ wstest('Free Tier Quota', async ({ comfyPage, comfyMouse, getWebSocket }) => {
+ const execution = new ExecutionHelper(comfyPage, await getWebSocket())
+ const freeTierQuota = new FreeTierQuota(comfyPage)
+
+ await test.step('Populates initial state from config', async () => {
+ await expect.poll(() => freeTierQuota.getAvailable()).toBe('3')
+ expect(await freeTierQuota.getMax()).toBe('5')
+ })
+
+ await test.step('available decrements on run', async () => {
+ await execution.run()
+ await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
+ })
+
+ await test.step('connects to detached run button', async () => {
+ const handle = comfyPage.actionbar.dragHandle
+ await comfyMouse.dragElementBy(handle, { x: -100, y: 100 })
+ await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(false)
+ expect(await freeTierQuota.getAvailable()).toBe('2')
+ await comfyMouse.dragElementBy(handle, { x: 100, y: -100 })
+ await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(true)
+ })
+
+ await test.step('Detects workflows with Partner nodes', async () => {
+ await comfyPage.searchBoxV2.addNode('Node With Price Badge')
+ const node = await comfyPage.vueNodes.getFixtureByTitle('Price Badge')
+ await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
+ await node.delete()
+ await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
+ })
+
+ await test.step('Does not decrease past 0', async () => {
+ await execution.run()
+ await expect.poll(() => freeTierQuota.getAvailable()).toBe('1')
+ await execution.run()
+ await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
+ await execution.run()
+ await execution.run()
+ await execution.run()
+ await comfyPage.nextFrame()
+ expect(await freeTierQuota.getAvailable()).toBe(undefined)
+ })
+ })
+})
diff --git a/src/components/TopMenuSection.test.ts b/src/components/TopMenuSection.test.ts
index d1fd9bdfea..2d9d3b5a56 100644
--- a/src/components/TopMenuSection.test.ts
+++ b/src/components/TopMenuSection.test.ts
@@ -629,7 +629,7 @@ describe('TopMenuSection', () => {
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
- expect(actionbarContainer!.classList).toContain('px-2')
+ expect(actionbarContainer!.classList).not.toContain('w-0')
} finally {
unmount()
vi.unstubAllGlobals()
diff --git a/src/components/TopMenuSection.vue b/src/components/TopMenuSection.vue
index 762dd9bcd1..0bf572b484 100644
--- a/src/components/TopMenuSection.vue
+++ b/src/components/TopMenuSection.vue
@@ -11,7 +11,7 @@
-
+
-
-
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -147,6 +161,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
+import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
@@ -209,21 +224,6 @@ const hasDockedButtons = computed(() => {
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
-const actionbarContainerClass = computed(() => {
- const base =
- 'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
-
- if (isActionbarContainerEmpty.value) {
- return cn(
- base,
- '-ml-2 w-0 min-w-0 border-transparent shadow-none',
- 'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
- 'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
- )
- }
-
- return cn(base, 'px-2', 'border-interface-stroke')
-})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
diff --git a/src/components/actionbar/ComfyActionbar.vue b/src/components/actionbar/ComfyActionbar.vue
index aa28b84c1b..b60784bf57 100644
--- a/src/components/actionbar/ComfyActionbar.vue
+++ b/src/components/actionbar/ComfyActionbar.vue
@@ -75,6 +75,7 @@
+
@@ -109,6 +110,7 @@ import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
+import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
diff --git a/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.test.ts b/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.test.ts
index b1e3d3dfb8..65dce0c9c8 100644
--- a/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.test.ts
+++ b/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.test.ts
@@ -4,11 +4,11 @@ import { nextTick, ref } from 'vue'
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
-const mockIsActiveSubscription = ref(true)
+const mockCanRunWorkflows = ref(true)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
- isActiveSubscription: mockIsActiveSubscription
+ canRunWorkflows: mockCanRunWorkflows
})
}))
@@ -32,7 +32,7 @@ function renderWrapper() {
describe('CloudRunButtonWrapper', () => {
beforeEach(() => {
- mockIsActiveSubscription.value = true
+ mockCanRunWorkflows.value = true
})
it('renders the runnable queue button when the subscription is active', () => {
@@ -45,7 +45,7 @@ describe('CloudRunButtonWrapper', () => {
})
it('locks the run button when the subscription is inactive', () => {
- mockIsActiveSubscription.value = false
+ mockCanRunWorkflows.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
@@ -53,12 +53,12 @@ describe('CloudRunButtonWrapper', () => {
})
it('unlocks the run button once the subscription becomes active again', async () => {
- mockIsActiveSubscription.value = false
+ mockCanRunWorkflows.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
- mockIsActiveSubscription.value = true
+ mockCanRunWorkflows.value = true
await nextTick()
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
diff --git a/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.vue b/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.vue
index 6aa543eeca..e5e8922a94 100644
--- a/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.vue
+++ b/src/components/actionbar/ComfyRunButton/CloudRunButtonWrapper.vue
@@ -1,7 +1,7 @@
diff --git a/src/composables/billing/types.ts b/src/composables/billing/types.ts
index e0be9c5996..c9bbfa55a9 100644
--- a/src/composables/billing/types.ts
+++ b/src/composables/billing/types.ts
@@ -113,4 +113,5 @@ export interface BillingContext extends BillingState, BillingActions {
*/
isLegacyTeamPlan: ComputedRef
getMaxSeats: (tierKey: TierKey) => number
+ canRunWorkflows: ComputedRef
}
diff --git a/src/composables/billing/useBillingContext.ts b/src/composables/billing/useBillingContext.ts
index b24e44f591..626be9ab26 100644
--- a/src/composables/billing/useBillingContext.ts
+++ b/src/composables/billing/useBillingContext.ts
@@ -6,6 +6,7 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
+import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PreviewSubscribeOptions,
@@ -129,6 +130,16 @@ function useBillingContextInternal(): BillingContext {
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
+ const freeTierQuota = useFreeTierQuota()
+
+ const canRunWorkflows = computed(
+ () =>
+ isActiveSubscription.value &&
+ (!isFreeTier.value ||
+ !freeTierQuota.quotaEnabled.value ||
+ freeTierQuota.freeTierExecutionPermitted.value)
+ )
+
const isLegacyTeamPlan = computed(
() =>
type.value === 'workspace' &&
@@ -297,6 +308,7 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
+ canRunWorkflows,
isFreeTier,
isLegacyTeamPlan,
billingStatus,
diff --git a/src/composables/node/usePriceBadge.ts b/src/composables/node/usePriceBadge.ts
index def392b6e8..f3875e7eb4 100644
--- a/src/composables/node/usePriceBadge.ts
+++ b/src/composables/node/usePriceBadge.ts
@@ -1,12 +1,19 @@
+import { createSharedComposable } from '@vueuse/core'
+import { computed, toValue } from 'vue'
+
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
+import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodePricing } from '@/composables/node/useNodePricing'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
+import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
+import { app } from '@/scripts/app'
+import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
-import { useWidgetValueStore } from '@/stores/widgetValueStore'
+import { mapAllNodes } from '@/utils/graphTraversalUtil'
type LinkedWidgetInput = INodeInputSlot & {
_subgraphSlot?: SubgraphInput
@@ -150,3 +157,20 @@ export const usePriceBadge = () => {
updateSubgraphCredits
}
}
+export const useCreditsBadgesInGraph = createSharedComposable(() => {
+ const { isCreditsBadge } = usePriceBadge()
+ const vueNodeLifecycle = useVueNodeLifecycle()
+ return computed(() => {
+ void vueNodeLifecycle.nodeManager.value?.vueNodeData.size
+ if (!app.graph) return []
+ return mapAllNodes(app.graph, (node) => {
+ if (node.isSubgraphNode()) return
+
+ const priceBadge = node.badges.find(isCreditsBadge)
+ if (!priceBadge) return
+
+ trackNodePrice(node)
+ return [node.title, toValue(priceBadge).text, node.id] as const
+ })
+ })
+})
diff --git a/src/composables/useFeatureFlags.ts b/src/composables/useFeatureFlags.ts
index 2f4db71b5e..10829ab4a9 100644
--- a/src/composables/useFeatureFlags.ts
+++ b/src/composables/useFeatureFlags.ts
@@ -33,6 +33,7 @@ export enum ServerFeatureFlag {
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
+ FREE_TIER_JOB_ALLOWANCE_ENABLED = 'free_tier_job_allowance_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
}
@@ -202,6 +203,16 @@ export function useFeatureFlags() {
cachedConsolidatedBillingEnabled
)
},
+ get freeTierJobAllowanceEnabled() {
+ const config = remoteConfig.value as typeof remoteConfig.value & {
+ free_tier_job_allowance_enabled?: boolean
+ }
+ return resolveFlag(
+ ServerFeatureFlag.FREE_TIER_JOB_ALLOWANCE_ENABLED,
+ config.free_tier_job_allowance_enabled,
+ false
+ )
+ },
get signupTurnstileMode() {
return resolveFlag(
ServerFeatureFlag.SIGNUP_TURNSTILE,
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index a53dd6716f..6d6b1408d6 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -3528,6 +3528,9 @@
"dockToTop": "Dock to top",
"feedback": "Feedback",
"feedbackTooltip": "Feedback",
+ "freeTierRuns": "{available} / {MAX_AVAILABLE} runs left",
+ "freeTierRunsExhausted": "No runs left",
+ "freeTierPartner": "Partner nodes need a paid plan",
"share": "Share",
"shareTooltip": "Share workflow"
},
diff --git a/src/platform/cloud/subscription/components/FreeTierQuota.vue b/src/platform/cloud/subscription/components/FreeTierQuota.vue
new file mode 100644
index 0000000000..d9bfead271
--- /dev/null
+++ b/src/platform/cloud/subscription/components/FreeTierQuota.vue
@@ -0,0 +1,65 @@
+
+
+
+
diff --git a/src/platform/cloud/subscription/composables/useFreeTierQuota.ts b/src/platform/cloud/subscription/composables/useFreeTierQuota.ts
new file mode 100644
index 0000000000..f03877e1d1
--- /dev/null
+++ b/src/platform/cloud/subscription/composables/useFreeTierQuota.ts
@@ -0,0 +1,45 @@
+import { createSharedComposable } from '@vueuse/core'
+import { computed, ref, watch } from 'vue'
+
+import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
+import { useFeatureFlags } from '@/composables/useFeatureFlags'
+import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
+
+export const useFreeTierQuota = createSharedComposable(function () {
+ const { flags } = useFeatureFlags()
+ const creditsBadges = useCreditsBadgesInGraph()
+
+ const available = ref(0)
+ const maxAvailable = ref(0)
+ watch(
+ () => remoteConfig.value.free_tier_balance?.remaining,
+ (val) => (available.value = val ?? 0),
+ { immediate: true }
+ )
+ watch(
+ () => remoteConfig.value.free_tier_balance?.allowance,
+ (val) => (maxAvailable.value = val ?? 0),
+ { immediate: true }
+ )
+
+ const quotaEnabled = computed(
+ () => flags.freeTierJobAllowanceEnabled && maxAvailable.value > 0
+ )
+ const hasInvalidNodes = computed(() => creditsBadges.value.length > 0)
+ const freeTierExecutionPermitted = computed(
+ () => !hasInvalidNodes.value && quotaEnabled.value && available.value > 0
+ )
+
+ function trackRun() {
+ if (available.value > 0) available.value--
+ }
+
+ return {
+ available,
+ freeTierExecutionPermitted,
+ hasInvalidNodes,
+ maxAvailable,
+ quotaEnabled,
+ trackRun
+ }
+})
diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts
index c3ab28322a..d4479fe09e 100644
--- a/src/platform/remoteConfig/types.ts
+++ b/src/platform/remoteConfig/types.ts
@@ -110,6 +110,11 @@ export type RemoteConfig = {
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
free_tier_credits?: number
+ free_tier_balance?: {
+ allowance: number
+ used: number
+ remaining: number
+ }
new_free_tier_subscriptions?: boolean
workflow_sharing_enabled?: boolean
comfyhub_upload_enabled?: boolean
diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts
index 7534dc5d60..7848e34daf 100644
--- a/src/platform/telemetry/types.ts
+++ b/src/platform/telemetry/types.ts
@@ -33,6 +33,7 @@ export type PaymentIntentSource =
| 'invite_member_upsell'
| 'upload_model_upgrade'
| 'team_upgrade_resume'
+ | 'free_tier_quota'
export type SubscriptionCheckoutType = 'new' | 'change'
export type SubscriptionCheckoutTier = TierKey | 'team'
diff --git a/src/renderer/extensions/linearMode/LinearControls.test.ts b/src/renderer/extensions/linearMode/LinearControls.test.ts
index a2aa743cee..4a9241ea20 100644
--- a/src/renderer/extensions/linearMode/LinearControls.test.ts
+++ b/src/renderer/extensions/linearMode/LinearControls.test.ts
@@ -12,7 +12,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { toNodeId } from '@/types/nodeId'
const billingMock = vi.hoisted(() => ({
- isActiveSubscription: true
+ canRunWorkflows: true
}))
const overlayMock = vi.hoisted(() => ({
@@ -22,7 +22,7 @@ const overlayMock = vi.hoisted(() => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
- isActiveSubscription: billingMock.isActiveSubscription
+ canRunWorkflows: billingMock.canRunWorkflows
})
}))
@@ -77,14 +77,14 @@ const nodeErrors: Record = {
function renderControls({
hasError = false,
- isActiveSubscription = true,
+ canRunWorkflows = true,
mobile = false
}: {
hasError?: boolean
- isActiveSubscription?: boolean
+ canRunWorkflows?: boolean
mobile?: boolean
} = {}) {
- billingMock.isActiveSubscription = isActiveSubscription
+ billingMock.canRunWorkflows = canRunWorkflows
const pinia = createTestingPinia({
createSpy: vi.fn,
@@ -120,7 +120,7 @@ function renderControls({
describe('LinearControls', () => {
beforeEach(() => {
vi.clearAllMocks()
- billingMock.isActiveSubscription = true
+ billingMock.canRunWorkflows = true
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
overlayMock.overlayTitle = 'Required input missing'
})
@@ -187,7 +187,7 @@ describe('LinearControls', () => {
({ mobile }) => {
renderControls({
hasError: true,
- isActiveSubscription: false,
+ canRunWorkflows: false,
mobile
})
diff --git a/src/renderer/extensions/linearMode/LinearControls.vue b/src/renderer/extensions/linearMode/LinearControls.vue
index 0877485e2f..4f05584201 100644
--- a/src/renderer/extensions/linearMode/LinearControls.vue
+++ b/src/renderer/extensions/linearMode/LinearControls.vue
@@ -11,6 +11,7 @@ import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
+import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -28,7 +29,7 @@ const { t } = useI18n()
const commandStore = useCommandStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
const settingStore = useSettingStore()
-const { isActiveSubscription } = useBillingContext()
+const { canRunWorkflows } = useBillingContext()
const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
@@ -54,7 +55,7 @@ const linearRunButtonTestId = 'linear-run-button'
const showRunErrorWarning = computed(
() =>
hasAnyError.value &&
- toValue(isActiveSubscription) &&
+ toValue(canRunWorkflows) &&
toValue(overlayMessage).trim().length > 0
)
@@ -152,10 +153,7 @@ function handleDragDrop() {
class="border-t border-node-component-border p-4 pb-6"
>
-
+
@@ -210,10 +208,7 @@ function handleDragDrop() {
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-7 min-w-40"
/>
-
+
{{ t('menu.run') }}
+
diff --git a/src/renderer/extensions/linearMode/PartnerNodesList.vue b/src/renderer/extensions/linearMode/PartnerNodesList.vue
index f94a4af650..0db2460b16 100644
--- a/src/renderer/extensions/linearMode/PartnerNodesList.vue
+++ b/src/renderer/extensions/linearMode/PartnerNodesList.vue
@@ -4,33 +4,17 @@ import {
CollapsibleRoot,
CollapsibleTrigger
} from 'reka-ui'
-import { computed, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
-import { usePriceBadge } from '@/composables/node/usePriceBadge'
+import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
import PartnerNodeItem from '@/renderer/extensions/linearMode/PartnerNodeItem.vue'
-import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
-import { app } from '@/scripts/app'
-import { mapAllNodes } from '@/utils/graphTraversalUtil'
defineProps<{ mobile?: boolean }>()
-const { isCreditsBadge } = usePriceBadge()
+const creditsBadges = useCreditsBadgesInGraph()
const { t } = useI18n()
-
-const creditsBadges = computed(() =>
- mapAllNodes(app.graph, (node) => {
- if (node.isSubgraphNode()) return
-
- const priceBadge = node.badges.find(isCreditsBadge)
- if (!priceBadge) return
-
- trackNodePrice(node)
- return [node.title, toValue(priceBadge).text, node.id] as const
- })
-)
diff --git a/src/scripts/app.ts b/src/scripts/app.ts
index a635ee333c..c79a116cc4 100644
--- a/src/scripts/app.ts
+++ b/src/scripts/app.ts
@@ -24,6 +24,7 @@ import { snapPoint } from '@/lib/litegraph/src/measure'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
+import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -1813,6 +1814,7 @@ export class ComfyApp {
isPartialExecution
})
}
+ useFreeTierQuota().trackRun()
this.canvas.draw(true, true)
await this.ui.queue.update()
}
diff --git a/src/storybook/mocks/useBillingContext.ts b/src/storybook/mocks/useBillingContext.ts
index d5af6f513e..1509712167 100644
--- a/src/storybook/mocks/useBillingContext.ts
+++ b/src/storybook/mocks/useBillingContext.ts
@@ -27,6 +27,7 @@ export function useBillingContext(): BillingContext {
isLoading: ref(false),
error: ref(null),
isActiveSubscription: computed(() => false),
+ canRunWorkflows: computed(() => false),
isFreeTier: computed(() => false),
isLegacyTeamPlan: computed(() => false),
billingStatus: computed(() => null),