Compare commits

...

12 Commits

Author SHA1 Message Date
github-actions
bcbd119116 [automated] Update test expectations 2026-07-08 18:15:35 +00:00
GitHub Action
6923baafdb [automated] Apply ESLint and Oxfmt fixes 2026-07-08 16:45:31 +00:00
pythongosssss
b79e7db5e0 - change creditbadge tuple to named object
- update generations key
- use text-xs
- nits and tidy
2026-07-08 09:35:15 -07:00
pythongosssss
53c6741db5 scope check per row 2026-07-07 11:14:01 -07:00
pythongosssss
28f8f14005 test coverage 2026-07-07 10:34:07 -07:00
pythongosssss
8ac2c2088b tidy 2026-07-07 09:51:13 -07:00
pythongosssss
ba1f9c2719 fix tests 2026-07-07 09:22:24 -07:00
pythongosssss
3afb8f0494 fix mobile credit button color 2026-07-07 09:08:15 -07:00
pythongosssss
e366813388 fix: guard credits summary against uninitialized graph
- Return no badges when the graph isn't ready yet, before mapAllNodes
  dereferences app.graph.nodes
- Fixes a render-before-init crash in app mode and the LinearControls suite
2026-07-07 06:31:38 -07:00
pythongosssss
1993bf4290 feat(appMode): rename run count to "Generations" and inline with stepper
- Relabel linear-mode run count from "Number of runs" to "Generations", uppercased
- Inline the label with the number stepper on the desktop run panel (flex wrap)
- Shrink label to text-xs in the foreground color
- Tighten vertical spacing and drop the top separator
2026-07-01 12:51:15 -07:00
pythongosssss
927f3f8541 feat(appMode): credits pill + breakdown popover on the run button
Surface a "Uses credits" pill on the app-mode run/subscribe button and a
per-node cost breakdown popover for priced (partner) nodes.

- Add CreditsPill ("Uses credits") to the run/subscribe button; collapses to a coin badge when the button is tight
- Extract useCreditsSummary; make PartnerNodesList a presentational breakdown with an approximate-cost info tooltip
- Show the breakdown via hover card (desktop) / popover (mobile), opening instantly when credits are required
- Add a brand-yellow button variant and apply it to the subscribe-to-run button
- Drop the subscribe-button tooltip when it only repeats the visible label
- Add e2e coverage for the credit breakdown popover
2026-07-01 12:25:43 -07:00
pythongosssss
979a832845 refactor(ui): unify credit icon to lucide--coins
Add a shared CREDITS_ICON constant and use it for the credit icons across balance, pricing, top-up and credit-slider surfaces, replacing the mixed comfy--credits / lucide--component usages.
2026-07-01 12:23:00 -07:00
31 changed files with 542 additions and 160 deletions

View File

@@ -184,3 +184,45 @@ test.describe('App mode usage', () => {
})
})
})
test.describe('App mode credits', () => {
const API_PRICED_NODE = 'FluxProUltraImageNode'
test('shows the credit breakdown popover for priced nodes', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeBadge.ShowApiPricing', true)
await comfyPage.page.evaluate((type) => {
const registered = window.LiteGraph!.registered_node_types[type] as {
nodeData?: { price_badge?: unknown }
}
if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
registered.nodeData.price_badge = {
engine: 'jsonata',
expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
}, API_PRICED_NODE)
await comfyPage.nodeOps.addNode(API_PRICED_NODE)
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// The run/subscribe button flags that the workflow needs credits, even when
// the pill collapses to its icon (kept in the accessible name).
const runButton = comfyPage.appMode.runButton
await expect(runButton).toBeVisible()
await expect(runButton).toHaveAccessibleName(/Uses credits/)
// Hovering the button reveals the per-node credit breakdown.
await runButton.hover()
const breakdown = comfyPage.page.getByRole('list', {
name: 'Credit breakdown by model'
})
await expect(breakdown).toBeVisible()
await expect(breakdown).toContainText('99.9 credits/Run')
await expect(
comfyPage.page.getByText('Requires additional credits')
).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -28,6 +28,8 @@ const formatNumber = ({
return new Intl.NumberFormat(locale, merged).format(value)
}
export const CREDITS_ICON = 'icon-[lucide--coins]'
export const CREDITS_PER_USD = 211
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent

View File

@@ -14,7 +14,7 @@
class="p-1 text-amber-400"
>
<template #icon>
<i class="icon-[lucide--component]" />
<i :class="CREDITS_ICON" />
</template>
</Tag>
<div :class="textClass">
@@ -29,7 +29,10 @@ import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import {
CREDITS_ICON,
formatCreditsFromCents
} from '@/base/credits/comfyCredits'
import { useAuthStore } from '@/stores/authStore'
const { textClass, showCreditsOnly } = defineProps<{

View File

@@ -86,7 +86,7 @@
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 text-gold-500')" />
</template>
</FormattedNumberStepper>
</div>
@@ -98,7 +98,7 @@
v-if="isBelowMin"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
>
<i class="icon-[lucide--component] size-4" />
<i :class="cn(CREDITS_ICON, 'size-4')" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
@@ -109,7 +109,7 @@
v-if="showCeilingWarning"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
>
<i class="icon-[lucide--component] size-4" />
<i :class="cn(CREDITS_ICON, 'size-4')" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))
@@ -154,7 +154,11 @@ import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import {
CREDITS_ICON,
creditsToUsd,
usdToCredits
} from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'

View File

@@ -7,7 +7,7 @@
)
"
>
<i class="icon-[lucide--component] h-full bg-amber-400" />
<i :class="cn(CREDITS_ICON, 'h-full bg-amber-400')" />
<span class="truncate" v-text="text" />
</span>
<span
@@ -21,6 +21,8 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
defineProps<{
text: string
rest?: string

View File

@@ -51,7 +51,7 @@
>
<i
aria-hidden="true"
class="icon-[lucide--component] size-3 text-amber-400"
:class="cn(CREDITS_ICON, 'size-3 text-amber-400')"
/>
<i
aria-hidden="true"
@@ -134,6 +134,8 @@ import { getProviderIcon, getProviderName } from '@/utils/categoryUtil'
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
const {
nodeDef,
currentQuery,

View File

@@ -105,7 +105,8 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
vi.mock('@/base/credits/comfyCredits', () => ({
vi.mock('@/base/credits/comfyCredits', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
}))

View File

@@ -31,7 +31,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i :class="cn(CREDITS_ICON, 'text-sm text-amber-400')" />
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
@@ -153,7 +153,12 @@ import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { cn } from '@comfyorg/tailwind-utils'
import {
CREDITS_ICON,
formatCreditsFromCents
} from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'

View File

@@ -25,7 +25,8 @@ export const buttonVariants = cva({
tertiary:
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90',
'brand-yellow': 'bg-brand-yellow text-black hover:bg-brand-yellow/85'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -58,7 +59,8 @@ const variants = [
'base',
'tertiary',
'overlay-white',
'gradient'
'gradient',
'brand-yellow'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = [
'sm',

View File

@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
@@ -223,7 +224,8 @@ const { t } = useI18n()
<i
:class="
cn(
'icon-[comfy--credits] size-3 shrink-0',
CREDITS_ICON,
'size-3 shrink-0',
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
)
"

View File

@@ -3696,7 +3696,7 @@
"graphMode": "Graph Mode",
"dragAndDropImage": "Click to browse or drag an image",
"mobileControls": "Edit & Run",
"runCount": "Number of runs",
"generations": "Generations",
"rerun": "Rerun",
"reuseParameters": "Reuse Parameters",
"downloadAll": "Download {count} assets from this run",
@@ -3708,6 +3708,9 @@
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"creditApproximateInfo": "Credit consumption shown here is approximate due to 3rd party provider calculations, exact values are available in your job history once the workflow is executed",
"creditBreakdown": "Credit breakdown by model",
"usesCredits": "Uses credits",
"viewGraph": "View node graph",
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
"welcome": {

View File

@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
@@ -215,7 +216,8 @@ const { t } = useI18n()
<i
:class="
cn(
'icon-[comfy--credits] size-3 shrink-0',
CREDITS_ICON,
'size-3 shrink-0',
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
)
"

View File

@@ -19,7 +19,7 @@
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="flex items-baseline gap-2">
<i class="icon-[lucide--component] size-4 self-center text-credit" />
<i :class="cn(CREDITS_ICON, 'size-4 self-center text-credit')" />
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
<span class="text-sm text-muted @max-[300px]:hidden">{{
$t('subscription.remaining')
@@ -81,7 +81,7 @@
v-else
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<i :class="cn(CREDITS_ICON, 'size-4 text-credit')" />
<span class="@max-[180px]:hidden">
{{
$t('subscription.creditsLeftOfTotal', {
@@ -133,7 +133,7 @@
v-else
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<i :class="cn(CREDITS_ICON, 'size-4 text-credit')" />
{{ displayPrepaid }}
</span>
</div>
@@ -179,7 +179,7 @@ import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCredits } from '@/base/credits/comfyCredits'
import { CREDITS_ICON, formatCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'

View File

@@ -123,7 +123,7 @@
</span>
<div class="flex flex-row items-center gap-1">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
:class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')"
aria-hidden="true"
/>
<span
@@ -264,6 +264,7 @@ import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'

View File

@@ -4,14 +4,20 @@
value: buttonTooltip,
showDelay: 600
}"
class="subscribe-to-run-button h-8 gap-1.5 rounded-lg px-4 whitespace-nowrap"
variant="gradient"
:class="
cn(
'subscribe-to-run-button gap-1.5 rounded-lg px-4 whitespace-nowrap [--credits-pill-base:var(--color-brand-yellow)]',
large ? 'h-10 text-sm' : 'h-8'
)
"
variant="brand-yellow"
size="unset"
data-testid="subscribe-to-run-button"
@click="handleSubscribeToRun"
>
<i class="pi pi-lock" />
{{ buttonLabel }}
<slot name="trailing" />
</Button>
</template>
@@ -20,13 +26,18 @@ import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import { isCloud } from '@/platform/distribution/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
const { large = false } = defineProps<{ large?: boolean }>()
const { t } = useI18n()
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMdOrLarger = breakpoints.greaterOrEqual('md')
@@ -43,11 +54,11 @@ const buttonLabel = computed(() => {
: t('subscription.subscribeToRun')
})
const buttonTooltip = computed(() =>
canResubscribe.value
? t('subscription.subscribeToRunFull')
: t('subscription.inactive.memberRunTooltip')
)
const buttonTooltip = computed(() => {
if (!canResubscribe.value) return t('subscription.inactive.memberRunTooltip')
// md+ already shows the full label, so the matching tooltip would be redundant
return isMdOrLarger.value ? undefined : t('subscription.subscribeToRunFull')
})
function handleSubscribeToRun() {
if (isCloud) {

View File

@@ -59,7 +59,7 @@
<!-- Credits Section -->
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i :class="cn(CREDITS_ICON, 'text-sm text-amber-400')" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
@@ -220,7 +220,12 @@ import Skeleton from 'primevue/skeleton'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { cn } from '@comfyorg/tailwind-utils'
import {
CREDITS_ICON,
formatCreditsFromCents
} from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import WorkspaceSwitcherPopover from '@/platform/workspace/components/WorkspaceSwitcherPopover.vue'

View File

@@ -129,7 +129,7 @@
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i :class="cn(CREDITS_ICON, 'text-sm text-amber-400')" />
<span
class="font-inter text-sm/normal font-bold text-base-foreground"
>
@@ -303,6 +303,7 @@ import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import {

View File

@@ -38,7 +38,7 @@
{{ $t(creditsRefillLabelKey) }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')" />
<span class="font-bold text-base-foreground">
{{ refillCredits }}
</span>
@@ -176,6 +176,8 @@ import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscript
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import SubscriptionTermsNote from './SubscriptionTermsNote.vue'
interface Props {

View File

@@ -28,7 +28,7 @@
</span>
</div>
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')" />
<span>{{ displayCredits }} {{ $t('subscription.perMonth') }}</span>
</div>
</div>
@@ -97,6 +97,9 @@
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import Button from '@/components/ui/button/Button.vue'
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'

View File

@@ -66,7 +66,7 @@
<div class="flex items-center justify-between">
<span class="text-base-foreground">{{ refillLabel }}</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')" />
<span class="font-bold text-base-foreground">{{
refillCredits
}}</span>
@@ -89,7 +89,7 @@
{{ $t('subscription.preview.creditsRefillMonthlyTo') }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')" />
<span class="font-bold text-base-foreground">{{
monthlyRefillCredits
}}</span>
@@ -147,6 +147,9 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'

View File

@@ -86,7 +86,7 @@
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 text-gold-500')" />
</template>
</FormattedNumberStepper>
</div>
@@ -98,7 +98,7 @@
v-if="isBelowMin"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
>
<i class="icon-[lucide--component] size-4" />
<i :class="cn(CREDITS_ICON, 'size-4')" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
@@ -109,7 +109,7 @@
v-if="showCeilingWarning"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
>
<i class="icon-[lucide--component] size-4" />
<i :class="cn(CREDITS_ICON, 'size-4')" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))
@@ -154,7 +154,11 @@ import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import {
CREDITS_ICON,
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'

View File

@@ -166,7 +166,7 @@
<div class="mt-auto flex flex-col gap-1">
<div class="flex flex-row items-center gap-2">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
:class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')"
aria-hidden="true"
/>
<span
@@ -244,7 +244,7 @@
<div class="flex flex-col gap-1">
<div class="flex flex-row items-center gap-2">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
:class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')"
aria-hidden="true"
/>
<span
@@ -413,6 +413,7 @@ import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton
import { computed, onMounted, ref, watch } from 'vue'
import { I18nT, useI18n } from 'vue-i18n'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import CreditSlider from '@/components/ui/credit-slider/CreditSlider.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
const { t } = useI18n()
</script>
<template>
<span
class="flex h-5 min-w-0 flex-wrap content-start items-center justify-end overflow-hidden text-xs font-medium [--pill-fill:color-mix(in_srgb,currentColor_15%,var(--credits-pill-base,var(--color-base-foreground)))]"
>
<span
class="z-10 flex size-5 shrink-0 items-center justify-center rounded-full bg-(--pill-fill)"
>
<i :class="cn(CREDITS_ICON, 'size-3.5 bg-current')" />
</span>
<span
class="-ml-2.5 flex h-5 items-center rounded-r-full bg-(--pill-fill) pr-2.5 pl-3"
>
{{ t('linearMode.usesCredits') }}
</span>
</span>
</template>

View File

@@ -5,6 +5,7 @@ import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeError } from '@/schemas/apiSchema'
import type { CreditBadge } from '@/renderer/extensions/linearMode/useCreditsSummary'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import { useAppModeStore } from '@/stores/appModeStore'
@@ -20,6 +21,19 @@ const overlayMock = vi.hoisted(() => ({
overlayTitle: 'Required input missing'
}))
const creditsMock = vi.hoisted(() => ({
badges: [] as CreditBadge[]
}))
vi.mock('@/renderer/extensions/linearMode/useCreditsSummary', async () => {
const { computed } = await import('vue')
return {
useCreditsSummary: () => ({
creditsBadges: computed(() => creditsMock.badges)
})
}
})
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: billingMock.isActiveSubscription
@@ -43,7 +57,8 @@ const i18n = createI18n({
goto: 'Show errors in graph'
},
mobileNoWorkflow: 'No workflow',
runCount: 'Run count',
generations: 'Generations',
usesCredits: 'Uses credits',
viewJob: 'View job'
},
menu: {
@@ -123,6 +138,7 @@ describe('LinearControls', () => {
billingMock.isActiveSubscription = true
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
overlayMock.overlayTitle = 'Required input missing'
creditsMock.badges = []
})
it.for([
@@ -195,6 +211,28 @@ describe('LinearControls', () => {
}
)
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'flags a credit cost on the run button in $label controls',
({ mobile }) => {
creditsMock.badges = [
{ title: 'Flux', price: '99.9 credits/Run', nodeId: toNodeId('1') }
]
renderControls({ mobile })
expect(screen.getByText('Uses credits')).toBeInTheDocument()
}
)
it('does not flag a credit cost when no priced nodes are present', () => {
renderControls()
expect(screen.queryByText('Uses credits')).not.toBeInTheDocument()
})
it('does not show the warning when the error copy is empty', () => {
overlayMock.overlayMessage = ''

View File

@@ -1,9 +1,22 @@
<script setup lang="ts">
import { useTimeout } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
HoverCardContent,
HoverCardPortal,
HoverCardRoot,
HoverCardTrigger,
PopoverContent as RekaPopoverContent,
PopoverPortal as RekaPopoverPortal,
PopoverRoot as RekaPopoverRoot,
PopoverTrigger as RekaPopoverTrigger
} from 'reka-ui'
import { computed, ref, toValue, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import Loader from '@/components/loader/Loader.vue'
@@ -15,9 +28,11 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import CreditsPill from '@/renderer/extensions/linearMode/CreditsPill.vue'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
import { useCreditsSummary } from '@/renderer/extensions/linearMode/useCreditsSummary'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useAppMode } from '@/composables/useAppMode'
@@ -35,6 +50,7 @@ const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
const { overlayMessage } = useErrorOverlayState()
const { creditsBadges } = useCreditsSummary()
const { toastTo, mobile } = defineProps<{
toastTo?: string | HTMLElement
@@ -145,19 +161,35 @@ function handleDragDrop() {
</template>
</div>
</Teleport>
<PartnerNodesList v-if="!mobile" />
<section
v-if="mobile"
:data-testid="linearRunButtonTestId"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
/>
<SubscribeToRunButton v-if="!isActiveSubscription" class="mt-4 w-full">
<template #trailing>
<CreditsPill v-if="creditsBadges.length" class="ml-auto" />
</template>
</SubscribeToRunButton>
<div v-else class="mt-4 flex">
<PartnerNodesList mobile />
<RekaPopoverRoot v-if="creditsBadges.length">
<RekaPopoverTrigger as-child>
<Button class="mr-2 size-10">
<i :class="cn(CREDITS_ICON, 'bg-current')" />
</Button>
</RekaPopoverTrigger>
<RekaPopoverPortal>
<RekaPopoverContent
side="top"
:side-offset="8"
:collision-padding="10"
class="z-1700 w-72 max-w-[calc(100vw-2rem)]"
>
<PartnerNodesList :badges="creditsBadges" />
</RekaPopoverContent>
</RekaPopoverPortal>
</RekaPopoverRoot>
<Popover side="top" @open-auto-focus.prevent>
<template #button>
<Button size="lg" class="-mr-3 pr-7">
@@ -166,19 +198,19 @@ function handleDragDrop() {
</Button>
</template>
<div
class="m-1 mb-2 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
class="m-1 mb-2 text-node-component-slot-text uppercase"
v-text="t('linearMode.generations')"
/>
<ScrubableNumberInput
v-model="batchCount"
:aria-label="t('linearMode.runCount')"
:aria-label="t('linearMode.generations')"
:min="1"
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-10 min-w-40"
/>
</Popover>
<Button
variant="primary"
variant="inverted"
class="grow"
size="lg"
:aria-describedby="
@@ -190,45 +222,73 @@ function handleDragDrop() {
>
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
<CreditsPill
v-if="creditsBadges.length"
class="ml-auto text-brand-blue"
/>
</Button>
</div>
</section>
<section
v-else
:data-testid="linearRunButtonTestId"
class="border-t border-node-component-border p-4 pb-6"
>
<section v-else :data-testid="linearRunButtonTestId" class="p-4 pb-6">
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<div
class="m-1 mb-2 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
/>
<ScrubableNumberInput
v-model="batchCount"
:aria-label="t('linearMode.runCount')"
:min="1"
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-7 min-w-40"
/>
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
/>
<Button
v-else
variant="primary"
class="mt-4 w-full text-sm"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
<div class="mx-1 flex flex-wrap items-center justify-between gap-2">
<div
class="text-xs text-base-foreground uppercase"
v-text="t('linearMode.generations')"
/>
<ScrubableNumberInput
v-model="batchCount"
:aria-label="t('linearMode.generations')"
:min="1"
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-7 min-w-40"
/>
</div>
<HoverCardRoot
:open-delay="creditsBadges.length ? 0 : 150"
:close-delay="100"
>
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
<HoverCardTrigger as-child>
<SubscribeToRunButton
v-if="!isActiveSubscription"
large
class="mt-4 w-full"
>
<template #trailing>
<CreditsPill v-if="creditsBadges.length" class="ml-auto" />
</template>
</SubscribeToRunButton>
<Button
v-else
variant="inverted"
class="mt-4 w-full text-sm"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
<CreditsPill
v-if="creditsBadges.length"
class="ml-auto text-brand-blue"
/>
</Button>
</HoverCardTrigger>
<HoverCardPortal v-if="creditsBadges.length">
<HoverCardContent
side="top"
:side-offset="10"
:collision-padding="10"
class="z-1700 w-(--reka-hover-card-trigger-width)"
>
<PartnerNodesList :badges="creditsBadges" />
</HoverCardContent>
</HoverCardPortal>
</HoverCardRoot>
</section>
</div>
</div>

View File

@@ -1,14 +1,18 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
defineProps<{ title: string; price: string }>()
</script>
<template>
<div class="not-last:mb-4">
<div class="text-muted-foreground" v-text="title" />
<li class="col-span-2 grid grid-cols-subgrid items-center text-xs/4.5">
<span class="line-clamp-2 min-w-0 wrap-break-word" v-text="title" />
<span
class="mt-2 flex h-5 max-w-max items-center rounded-full bg-component-node-widget-background p-2 py-3"
class="inline-flex shrink-0 items-center gap-1 justify-self-end rounded-full bg-current/15 px-1.5 py-1 text-xs/none font-medium"
>
<i class="mr-1 icon-[lucide--component] h-4 bg-amber-400" />
<i :class="cn(CREDITS_ICON, 'size-3 shrink-0 bg-current')" />
{{ price }}
</span>
</div>
</li>
</template>

View File

@@ -0,0 +1,46 @@
import { render, screen, within } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { CreditBadge } from '@/renderer/extensions/linearMode/useCreditsSummary'
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
import { toNodeId } from '@/types/nodeId'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderList(badges: readonly CreditBadge[]) {
return render(PartnerNodesList, {
props: { badges },
global: { plugins: [i18n] }
})
}
describe('PartnerNodesList', () => {
it('pairs each priced node title with its own credit cost, in order', () => {
const badges: CreditBadge[] = [
{
title: 'Flux Pro Ultra',
price: '99.9 credits/Run',
nodeId: toNodeId(1)
},
{ title: 'Kling Video', price: '250 credits/Run', nodeId: toNodeId(2) }
]
renderList(badges)
const breakdown = screen.getByRole('list', {
name: enMessages.linearMode.creditBreakdown
})
const rows = within(breakdown).getAllByRole('listitem')
expect(rows).toHaveLength(badges.length)
badges.forEach(({ title, price }, i) => {
expect(within(rows[i]).getByText(title)).toBeInTheDocument()
expect(within(rows[i]).getByText(price)).toBeInTheDocument()
})
})
})

View File

@@ -1,85 +1,59 @@
<script setup lang="ts">
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipRoot,
TooltipTrigger
} 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 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'
import type { CreditBadge } from '@/renderer/extensions/linearMode/useCreditsSummary'
defineProps<{ mobile?: boolean }>()
defineProps<{ badges: readonly CreditBadge[] }>()
const { isCreditsBadge } = usePriceBadge()
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
})
)
</script>
<template>
<Popover v-if="mobile && creditsBadges.length" side="top">
<template #button>
<Button class="mr-2 size-10">
<i class="icon-[comfy--credits] bg-amber-400" />
</Button>
</template>
<section
class="max-h-(--reka-popover-content-available-height) scroll-shadows-comfy-menu-bg overflow-y-auto"
<div
class="flex flex-col gap-2.5 rounded-xl border border-border-default bg-secondary-background p-3 text-base-foreground shadow-interface"
>
<div class="flex items-center gap-1">
<p
class="m-0 text-xs/snug font-semibold"
v-text="t('linearMode.hasCreditCost')"
/>
<TooltipProvider :delay-duration="100">
<TooltipRoot>
<TooltipTrigger as-child>
<i
class="icon-[lucide--info] size-3.5 shrink-0 cursor-help text-muted-foreground"
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
side="top"
:side-offset="4"
:collision-padding="10"
class="z-2000 max-w-64 rounded-md bg-base-foreground px-2 py-1.5 text-xs/snug text-base-background shadow-interface"
>
{{ t('linearMode.creditApproximateInfo') }}
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</TooltipProvider>
</div>
<ul
:aria-label="t('linearMode.creditBreakdown')"
class="m-0 grid max-h-31 list-none grid-cols-[1fr_auto] gap-x-3 gap-y-2 overflow-y-auto pr-0.5 pl-0"
>
<PartnerNodeItem
v-for="[title, price, key] in creditsBadges"
:key
:title
:price
v-for="badge in badges"
:key="badge.nodeId"
:title="badge.title"
:price="badge.price"
/>
</section>
</Popover>
<div v-else-if="creditsBadges.length === 1">
<PartnerNodeItem
v-for="[title, price, key] in creditsBadges"
:key
:title
:price
class="border-t border-border-subtle pt-2"
/>
</ul>
</div>
<CollapsibleRoot
v-else-if="creditsBadges.length"
v-slot="{ open }"
class="flex max-h-1/2 w-full flex-col"
>
<div class="mb-1 w-full border-b border-border-subtle" />
<CollapsibleTrigger as-child>
<Button variant="textonly" class="w-full text-sm">
<i class="icon-[comfy--credits] size-4 bg-amber-400" />
{{ t('linearMode.hasCreditCost') }}
<i v-if="open" class="ml-auto icon-[lucide--chevron-up]" />
<i v-else class="ml-auto icon-[lucide--chevron-down]" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="scroll-shadows-comfy-menu-bg overflow-y-auto">
<PartnerNodeItem
v-for="[title, price, key] in creditsBadges"
:key
:title
:price
/>
</CollapsibleContent>
</CollapsibleRoot>
</template>

View File

@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCreditsSummary } from '@/renderer/extensions/linearMode/useCreditsSummary'
const appMock = vi.hoisted(() => ({
isGraphReady: true,
graph: { nodes: [] as unknown[] }
}))
const trackNodePrice = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/app', () => ({ app: appMock }))
vi.mock('@/composables/node/usePriceBadge', () => ({
usePriceBadge: () => ({
isCreditsBadge: (badge: { credits?: boolean }) => badge?.credits === true
})
}))
vi.mock(
'@/renderer/extensions/vueNodes/composables/usePartitionedBadges',
() => ({ trackNodePrice })
)
interface FakeNode {
id: string
title: string
badges: unknown[]
isSubgraphNode: () => boolean
subgraph?: { nodes: FakeNode[] }
}
function node(id: string, title: string, badges: unknown[] = []): FakeNode {
return { id, title, badges, isSubgraphNode: () => false }
}
function subgraphNode(id: string, inner: FakeNode[] = []): FakeNode {
return {
id,
title: 'group',
badges: [],
isSubgraphNode: () => true,
subgraph: { nodes: inner }
}
}
const creditsBadge = (text: string) => ({ text, credits: true })
beforeEach(() => {
appMock.isGraphReady = true
appMock.graph = { nodes: [] }
trackNodePrice.mockClear()
})
describe('useCreditsSummary', () => {
it('returns nothing until the graph is ready', () => {
appMock.isGraphReady = false
appMock.graph = { nodes: [node('a', 'A', [creditsBadge('99')])] }
expect(useCreditsSummary().creditsBadges.value).toEqual([])
})
it('summarizes only priced leaf nodes as title/price/nodeId', () => {
appMock.graph = {
nodes: [
node('a', 'Flux', [creditsBadge('99 credits/Run')]),
node('b', 'Free Node', [{ text: 'no cost' }]),
subgraphNode('s', [node('c', 'Inner', [creditsBadge('12 credits')])])
]
}
expect(useCreditsSummary().creditsBadges.value).toEqual([
{ title: 'Flux', price: '99 credits/Run', nodeId: 'a' },
{ title: 'Inner', price: '12 credits', nodeId: 'c' }
])
})
it('tracks pricing dependencies only for the priced nodes', () => {
appMock.graph = {
nodes: [
node('a', 'Flux', [creditsBadge('99')]),
node('b', 'Free Node', [])
]
}
expect(useCreditsSummary().creditsBadges.value).toHaveLength(1)
expect(trackNodePrice).toHaveBeenCalledTimes(1)
expect(trackNodePrice).toHaveBeenCalledWith(
expect.objectContaining({ id: 'a' })
)
})
})

View File

@@ -0,0 +1,41 @@
import { computed, toValue } from 'vue'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { app } from '@/scripts/app'
import type { NodeId } from '@/types/nodeId'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
export interface CreditBadge {
title: string
price: string
nodeId: NodeId
}
/**
* Collects the credit-cost badges of every priced node in the active graph,
* feeding both the run/subscribe button pill and the breakdown popover.
*/
export function useCreditsSummary() {
const { isCreditsBadge } = usePriceBadge()
const creditsBadges = computed<CreditBadge[]>(() => {
if (!app.isGraphReady) return []
return mapAllNodes(app.graph, (node) => {
if (node.isSubgraphNode()) return
const priceBadge = node.badges.find(isCreditsBadge)
if (!priceBadge) return
trackNodePrice(node)
return {
title: node.title,
price: toValue(priceBadge).text,
nodeId: node.id
}
})
})
return { creditsBadges }
}