Compare commits

..

3 Commits

Author SHA1 Message Date
pythongosssss
e97cca9e4a feat: show node preview ghost when adding models from dialog & sidebar (#12765)
## Summary

Adds consistent ghost node behavior when clicking "use" on models from
either the model dialog or treeview - matching the Node Library & Node
Search.

## Changes

- **What**: 
- Split `createModelNodeFromAsset` into `resolveModelNodeFromAsset` and
`startModelNodeDragFromAsset` to allow sharing logic between the two
drag sources
- Move NodeDragPreview file & use from being node library tab specific
to app level in GraphCanvas so all drag sources share it
- drag listeners now attach on `startDrag` and detach on cancel instead
of living for the tab's lifetime
- Updated `LGraphNodePreview` to accept widgetValues and prepend combo
options with passed value so it shows in the preview as the default

## Review Focus
- refactored positioning to use RAF with useMouse and transform to fix
laggy-follow behavior present in Firefox

## Screenshots (if applicable)

Model dialog + Node library

https://github.com/user-attachments/assets/b227ac43-c6ea-4cf6-86ed-6cfb196fd80e

Model library sidebar

https://github.com/user-attachments/assets/bb546aee-5099-4df9-abe5-68bccd8fa2eb
2026-06-23 10:41:14 +00:00
Dante
49a7b7b558 feat(billing): UnifiedPricingTable — one table, personal/team plan toggle (B4 / FE-934) (#12666)
## What
**B4 (FE-934): `UnifiedPricingTable`** — one pricing table for the
**Jun-5 model** (typeless workspace; personal/team is a **plan**, shown
as a Gamma-style **plan toggle** on one workspace), per **DES-197**. A
new, flag-gated component that will replace the two legacy tables at
cutover (strangler).


### video 

https://github.com/user-attachments/assets/82b704a4-101e-4609-8ff5-06b7cf7f9cd7



> **Stacked on #12644 (FE-935 `CreditSlider`).** Base is the slider
branch — retarget to `main` once #12644 merges. The slider commits show
in the diff until then.

## Changes
- **`UnifiedPricingTable.vue`** — plan toggle (personal/team, flag-gated
on `teamWorkspacesEnabled`); personal tier cards (facade `plans` +
`TIER_PRICING` fallback, billing-cycle toggle); team column hosting
`<CreditSlider>`; Enterprise card. Reuses `useBillingContext`; emits
`subscribe`/`resubscribe` (personal) + `subscribeTeam` (team).
- **`SubscriptionRequiredDialogContentUnified.vue`** — host; wires
personal checkout through `useSubscriptionCheckout` (full flow incl.
preview/transition + **new `'success'` step**); team checkout stubbed.
- **Confirm/success screens (DES 3084-15873)** — aligned the reused
`SubscriptionAddPaymentPreviewWorkspace` /
`SubscriptionTransitionPreviewWorkspace` to DES-197 (drop `/member`,
`comfy--credits` icon, plan-specific CTAs **"Subscribe to {plan}" /
"Switch to {plan}"**); added **`SubscriptionSuccessWorkspace.vue`**
("You're all set") as the `'success'` checkout step.
- **`showPricingTable`** — renders the unified host when
`teamWorkspacesEnabled`, legacy `PricingTable.vue` for flag-off. (The
old workspace-variant fork is removed; `PricingTableWorkspace.vue`
becomes unused on flag-on → deleted at cutover.)
- **i18n** — `subscription.planScope` / `teamPlan` / `enterprise` +
`subscription.preview.{subscribeToPlan,switchToPlan}` +
`subscription.success.*` keys.

## Strategy (new component + cutover — per FE-934)
Build new, retire old at cutover (a later single PR deletes
`PricingTable.vue` + `PricingTableWorkspace.vue` + the legacy dialog
host + the dispatch fork; `TIER_PRICING` kept only as the flag-off/OSS
fallback). Avoids half-migrated conditional cruft in the live tables.

## Screenshots
Pricing captured live on the authenticated cloud-prod session
(`local.comfy.org`, flag on). Confirm/success captured in Storybook
(prop-driven) — a **personal** workspace can't reach these live until
**B1/FE-966** flips its billing path off the legacy `/customers/*`
adapter (whose `plans` is always empty); the screens themselves are
unchanged regardless of source.

**Pricing table**

| Personal | Team |
|---|---|
| <img width="420" alt="pricing-personal"
src="https://github.com/user-attachments/assets/2be3b8bc-ac54-41db-8c21-5c950d3e7338"
/> | <img width="420" alt="pricing-team"
src="https://github.com/user-attachments/assets/c4078eb4-ee7d-42f6-bcc3-375686ab7f1e"
/> |

**Confirm your payment / plan change**

| New subscription | Plan change (Pro → Creator) |
|---|---|
| <img width="380" alt="confirm-new-subscription"
src="https://github.com/user-attachments/assets/e371c744-dc64-43e8-b977-73f9f99f85bc"
/> | <img width="380" alt="confirm-plan-change"
src="https://github.com/user-attachments/assets/b1ee5ab3-c572-4c40-9b70-f078d66b78f4"
/> |

**You're all set (success)**

<img width="380" alt="success-all-set"
src="https://github.com/user-attachments/assets/8ec9e90f-8ba4-4cb0-81a7-6b4316e0c19e"
/>

## ⚠️ BE-blocked (deferred)
- **Team checkout**: the slider stop → plan-slug / subscribe-request
shape is undefined (doc **Open Q#2** / "Team-slider contract",
**BE-1254**). `subscribeTeam` is stubbed (toast "coming soon") until BE
provides it.
- **Live discount data**: stops come from the hardcoded **DES-197
fallback** (`teamPlanCreditStops.ts`) until `GET /api/billing/plans`
carries them.
- **Confirm "credits you'll get right away" line** + **async-success
routing** (Stripe-tab/`pending_payment` → in-dialog success) deferred —
see ticket notes; credits-cents unit is the open BE-1254 question.

## Verification
- `vue-tsc --noEmit`: clean (pre-commit).
- `oxlint`/`eslint`/`stylelint`/`oxfmt`: pass (pre-commit).
- `vitest` `useSubscriptionDialog.test.ts` (11) +
`useSubscriptionCheckout.test.ts` (17) + new
`SubscriptionSuccessWorkspace.test.ts` (2): pass.
- Personal checkout reuses the existing `useSubscriptionCheckout` flow;
subscribed → new `'success'` step.

## Not in scope
- Pixel-finalizing vs DES-197 (visual reference = Alex's
**reference-only** #12042); personal-card detail refinement.
- Settings / Misc-UX (FE-768/770); team-checkout wiring (BE contract).

Design: **DES-197** / **3084-15873**. Survey: *FE Billing API
Divergence* (B4 / P1 / P2 / P4).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-23 10:11:13 +00:00
jaeone94
8d82944441 fix: limit workflow models to metadata enrichment (#12990)
## Summary

This PR intentionally narrows workflow-embedded model metadata handling
so root-level `models[]` and node-level embedded model metadata can
enrich existing missing-model candidates, but can no longer create new
candidates by themselves.

## Why this PR exists

ADR 0009, **Subgraph promoted widgets use linked inputs**, changes
promoted value ownership for subgraphs. That design was implemented by
[#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197),
**Subgraph Link Only Promotion (ADR 0009)**.

Under ADR 0009, a promoted widget is represented as a standard linked
`SubgraphInput` on the host `SubgraphNode`. The host boundary owns the
promoted value identity through the host node locator plus
`SubgraphInput.name`. The interior source widget remains the provider of
schema, type, options, tooltip, defaults, diagnostics, and migration
metadata, but it is not the persistence owner of the promoted value.

This PR is a preparatory cleanup discovered while working on the missing
model detection follow-up required by that ADR 0009 /
[#12197](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12197)
behavior. The follow-up needs missing model detection to respect the new
subgraph promoted-widget ownership model. While reviewing that path, we
found that the existing embedded model metadata fallback in
`enrichWithEmbeddedMetadata` was doing more than metadata enrichment.

The important finding was that this fallback was not just attaching
metadata to candidates that had already been detected from live node
widgets. It could also synthesize brand-new `MissingModelCandidate`
entries from workflow JSON metadata, including root-level `models[]`
entries, when no live candidate existed.

That behavior is inaccurate for the missing model system for two
reasons.

First, the normal missing model lifecycle is anchored to a real
node/widget binding. A candidate found from a COMBO or asset widget has
a concrete `nodeId + widgetName` reference. That reference is what lets
the UI surface the error, cache it as a pending warning, and later clear
or resolve it when the underlying node/widget value is fixed. A
root-level `models[]` entry does not reliably provide that anchor. If
metadata-only fallback creates a candidate without a real live widget
reference, the resulting error can be detected but cannot reliably
travel through the existing clearing path. In practice, that can become
an effectively unremovable missing model warning unless the user
downloads exactly the same model referenced by the stale metadata.

Second, a missing model error is meant to mean that a model-selecting
widget on an active node references a value that is not available.
Workflow JSON metadata by itself is not the same source of truth. If a
model only appears in root workflow metadata, or appears in node
metadata that is not represented by an active COMBO or asset widget
candidate, that is a different kind of state from the existing missing
model error model. Treating that metadata as a candidate creates a
second, less reliable detector that is not aligned with the scan/clear
lifecycle.

This is especially important before the ADR 0009 missing-model
follow-up. With linked-input promoted widgets, the host promoted value
is the value that matters. The interior source widget may still carry
stale or default metadata, and it must not become a second source of
truth for missing model errors. A detection path that can create
candidates directly from workflow metadata would make it harder to
reason about which value actually produced the warning.

For those reasons, this PR removes metadata-only candidate synthesis and
keeps embedded metadata in the role it can perform safely: metadata
enrichment. If the live widget/asset scan produces a candidate, embedded
metadata may fill in `directory`, `url`, `hash`, and `hashType`. If no
live candidate exists, the metadata is not enough to create a missing
model warning.

This PR is intended to land before the child PR that updates runtime
missing model detection for ADR 0009 linked-input promoted widgets.

## Changes

- **What**: Restrict `enrichWithEmbeddedMetadata` to enriching existing
candidates instead of creating fallback candidates from unmatched root
`models[]` or embedded model metadata.
- **What**: Remove the now-unused installed-model check callback and
asset-support callback from `enrichWithEmbeddedMetadata`.
- **What**: Remove the now-unnecessary `modelStore.loadModelFolders()`
path from the missing model pipeline, since embedded metadata no longer
performs installed-model fallback detection.
- **What**: Remove dead source-tracking metadata
(`EmbeddedModelWithSource`, source node/widget fields, and widget-name
lookup) that only existed to support metadata-only synthesis.
- **What**: Update missing model tests so they assert the new contract:
metadata enriches live candidates, but does not create candidates
without a live scan result.
- **What**: Delete obsolete fixtures that only covered the removed
metadata-only synthesis path.
- **Breaking**: None expected. This is an intentional narrowing of an
inaccurate fallback detector, not a public API change.
- **Dependencies**: None.

## Review Focus

Please focus on whether the candidate lifecycle now has a single source
of truth: live COMBO/asset widget scanning creates candidates, while
workflow metadata only enriches those candidates.

The intended behavioral change is that a model present only in
workflow-level metadata, with no active node widget candidate
referencing it, no longer appears as a missing model. This avoids
surfacing warnings that cannot be cleared through the normal `nodeId +
widgetName` path.

The expected retained behavior is that active widget-referenced missing
models are still detected by `scanAllModelCandidates`, and metadata from
root `models[]` or node `properties.models` still supplies
download-related fields for those live candidates.

## Screenshots (if applicable)

Not applicable. This is a detection/pipeline behavior change covered by
unit tests.

## Validation

- `pnpm test:unit src/platform/missingModel/missingModelScan.test.ts
src/platform/missingModel/missingModelPipeline.test.ts`
- `pnpm exec eslint src/platform/missingModel/missingModelScan.ts
src/platform/missingModel/missingModelScan.test.ts
src/platform/missingModel/missingModelPipeline.ts
src/platform/missingModel/missingModelPipeline.test.ts
src/platform/missingModel/types.ts`
- `pnpm exec oxfmt --check src/platform/missingModel/missingModelScan.ts
src/platform/missingModel/missingModelScan.test.ts
src/platform/missingModel/missingModelPipeline.ts
src/platform/missingModel/missingModelPipeline.test.ts
src/platform/missingModel/types.ts`
- `pnpm typecheck`
- pre-push hook: `knip --cache`
2026-06-23 07:44:43 +00:00
79 changed files with 4475 additions and 1906 deletions

View File

@@ -1,62 +0,0 @@
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, synchronize, closed]
permissions:
actions: write
contents: read # 'read' is enough because signatures live in a REMOTE repo
pull-requests: write
statuses: write
jobs:
cla-assistant:
runs-on: ubuntu-latest
steps:
- name: CLA Assistant
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
if: >
github.event_name == 'pull_request_target' ||
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# PAT required to write to the centralized signatures repo.
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
# Where the CLA document lives (shown to contributors)
path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md
# Centralized signature storage
remote-organization-name: comfy-org
remote-repository-name: comfy-cla
path-to-signatures: signatures/cla.json
branch: main
# Allowlist bots so they don't need to sign (optional, comma-separated).
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
# Custom PR comment messages
custom-notsigned-prcomment: |
🎉 Thank you for your contribution, we really appreciate it! 🎉
Like many open source projects, we require contributors to sign our [Contributor License Agreement (CLA)](https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md). A CLA makes the ownership of contributions explicit, so contributors and the project share a clear understanding of how the code can be used. By signing, you:
- Confirm that you own your contribution.
- Keep the right to reuse your own code.
- Grant us a copyright license to include and share it within our projects.
CLAs are standard practice across major open source projects including those under the Apache Software Foundation and the Linux Foundation. Ours is based on the Apache Software Foundation's CLA. Most importantly, it would enable us to relicense the project under a more permissive license in the future, giving the project and its community greater flexibility.
✍ **To sign, please post a new comment on this PR with exactly the following text:** ✍
custom-pr-sign-comment: I have read and agree to the Contributor License Agreement
custom-allsigned-prcomment: |
✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.

View File

@@ -78,6 +78,11 @@ const config: StorybookConfig = {
find: '@/composables/queue/useJobActions',
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
},
{
find: '@/composables/billing/useBillingContext',
replacement:
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
},
{
find: '@/utils/formatUtil',
replacement:

View File

@@ -30,9 +30,9 @@ function toggle(index: number) {
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 md:top-28 md:w-80 md:py-0"
>
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
<h2 class="text-4xl font-light text-primary-comfy-canvas md:text-5xl">
{{ heading }}
</h2>
</div>
@@ -42,7 +42,7 @@ function toggle(index: number) {
<div
v-for="(faq, index) in faqs"
:key="faq.id"
class="border-primary-comfy-canvas/20 border-b"
class="border-b border-primary-comfy-canvas/20"
>
<button
:id="`faq-trigger-${faq.id}`"
@@ -83,7 +83,7 @@ function toggle(index: number) {
:aria-labelledby="`faq-trigger-${faq.id}`"
class="pb-6"
>
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
{{ faq.answer }}
</p>
</section>

View File

@@ -25,7 +25,7 @@ const {
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
<div class="flex flex-col items-center text-center">
<h2
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
class="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ t(headingKey, locale) }}
</h2>

View File

@@ -40,12 +40,12 @@ const {
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<h2
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
@@ -66,10 +66,10 @@ const {
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
>
<span
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
>
{{ event.label[locale] }}
</span>

View File

@@ -109,7 +109,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
<template>
<footer
ref="footerRef"
class="bg-primary-comfy-ink text-primary-comfy-canvas px-6 py-8 lg:px-20"
class="bg-primary-comfy-ink px-6 py-8 text-primary-comfy-canvas lg:px-20"
>
<div
class="border-primary-warm-gray grid gap-12 border-t pt-16 lg:grid-cols-2 lg:gap-4"

View File

@@ -53,7 +53,7 @@ defineEmits<{ click: [] }>()
<div class="flex w-full items-end justify-between p-4">
<div class="gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-primary-comfy-canvas text-xs">
<p class="text-xs text-primary-comfy-canvas">
<GalleryItemAttribution :item :locale />
</p>
</div>
@@ -82,7 +82,7 @@ defineEmits<{ click: [] }>()
<!-- Mobile metadata -->
<div v-if="mobile" class="mt-2 gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-primary-comfy-canvas text-xs">
<p class="text-xs text-primary-comfy-canvas">
<GalleryItemAttribution :item :locale />
</p>
</div>

View File

@@ -11,7 +11,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
>
<h1
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
class="max-w-4xl text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ t('learning.heroTitle.before', locale) }}
<span class="text-primary-comfy-yellow">ComfyUI</span

View File

@@ -0,0 +1,61 @@
import { expect } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
import { STABLE_CHECKPOINT } from '@e2e/fixtures/data/assetFixtures'
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT]
const test = createCloudAssetsFixture(CLOUD_ASSETS)
test.describe('Browse Model Assets - Use button', { tag: '@cloud' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.nodeOps.clearGraph()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('Use button ghost-places a loader populated with the model', async ({
comfyPage
}) => {
await comfyPage.command.executeCommand('Comfy.BrowseModelAssets')
const modal = comfyPage.page.locator(
'[data-component-id="AssetBrowserModal"]'
)
await expect(modal).toBeVisible()
const card = comfyPage.page.locator(
`[data-component-id="AssetCard"][data-asset-id="${STABLE_CHECKPOINT.id}"]`
)
await expect(card).toBeVisible()
await card.getByRole('button', { name: 'Use' }).click()
// Dialog closes and the ghost is armed; the node is not placed until the
// user clicks the canvas.
await expect(modal).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
.toBe(0)
const canvasBox = (await comfyPage.canvas.boundingBox())!
await comfyPage.canvas.click({
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
'CheckpointLoaderSimple'
)
expect(loader).toBeDefined()
const widget = await loader.getWidgetByName('ckpt_name')
expect(await widget.getValue()).toBe(STABLE_CHECKPOINT.name)
})
})

View File

@@ -143,7 +143,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
ckptName[0] = [...ckptName[0], FAKE_MODEL_NAME]
await route.fulfill({ response, json: objectInfo })
})
@@ -151,21 +151,11 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
const url = new URL(response.url())
return url.pathname.endsWith('/object_info') && response.ok()
})
const modelFoldersResponse = comfyPage.page.waitForResponse(
(response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/experiment/models') && response.ok()
}
)
const refreshButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelRefresh
)
await Promise.all([
objectInfoResponse,
modelFoldersResponse,
refreshButton.click()
])
await Promise.all([objectInfoResponse, refreshButton.click()])
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()

View File

@@ -233,4 +233,64 @@ test.describe('Model library sidebar - empty state', () => {
await expect(tab.folderNodes).toHaveCount(0)
await expect(tab.leafNodes).toHaveCount(0)
})
test.describe('Model library sidebar - add node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
await comfyPage.setup()
await comfyPage.nodeOps.clearGraph()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Clicking a model defers creation until placed on the canvas', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.getFolderByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
await tab.getLeafByLabel('sd_xl_base_1.0').click()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
.toBe(0)
const canvasBox = (await comfyPage.canvas.boundingBox())!
await comfyPage.canvas.click({
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
'CheckpointLoaderSimple'
)
expect(loader).toBeDefined()
const widget = await loader.getWidgetByName('ckpt_name')
expect(await widget.getValue()).toBe('sd_xl_base_1.0.safetensors')
})
test('Ghost preview shows the model in the loader widget before placing', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.getFolderByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
await tab.getLeafByLabel('sd_xl_base_1.0').click()
const ghost = comfyPage.page.locator(
'[data-node-id="preview-CheckpointLoaderSimple"]'
)
await expect(ghost).toContainText('sd_xl_base_1.0.safetensors')
})
})
})

View File

@@ -355,7 +355,7 @@ describe('TreeExplorerV2Node', () => {
const nodeDiv = getTreeNode(container)
await fireEvent.dragStart(nodeDiv)
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
expect(mockStartDrag).toHaveBeenCalledWith(mockData, { mode: 'native' })
})
it('does not call startDrag for folder items on dragstart', async () => {

View File

@@ -93,6 +93,7 @@
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
<NodeDragPreview />
<VueNodeSwitchPopup />
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
@@ -136,6 +137,7 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'

View File

@@ -0,0 +1,97 @@
import { render } from '@testing-library/vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { fromPartial } from '@total-typescript/shoehorn'
vi.mock(
'@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue',
() => ({
default: { template: '<div data-testid="node-preview" />' }
})
)
const nodeDef = fromPartial<ComfyNodeDefImpl>({ name: 'TestNode' })
function moveMouse(clientX: number, clientY: number) {
window.dispatchEvent(new MouseEvent('mousemove', { clientX, clientY }))
}
function ghostElement() {
return document.querySelector('[data-testid="node-preview"]')?.parentElement
?.parentElement
}
describe('NodeDragPreview', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
useNodeDragToCanvas().cancelDrag()
vi.useRealTimers()
})
it('shows no ghost when nothing is being dragged', async () => {
render(NodeDragPreview)
moveMouse(100, 200)
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()).toBeFalsy()
})
it('keeps the ghost hidden until the mouse position is known', async () => {
render(NodeDragPreview)
useNodeDragToCanvas().startDrag(nodeDef)
await nextTick()
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()).toBeFalsy()
})
it('follows the mouse with an offset while dragging', async () => {
render(NodeDragPreview)
useNodeDragToCanvas().startDrag(nodeDef)
await nextTick()
moveMouse(100, 200)
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
moveMouse(300, 400)
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()?.style.transform).toBe('translate(312px, 412px)')
})
it('removes the ghost when the drag is cancelled', async () => {
render(NodeDragPreview)
useNodeDragToCanvas().startDrag(nodeDef)
await nextTick()
moveMouse(100, 200)
vi.advanceTimersByTime(16)
await nextTick()
expect(ghostElement()).toBeTruthy()
useNodeDragToCanvas().cancelDrag()
await nextTick()
expect(ghostElement()).toBeFalsy()
})
})

View File

@@ -0,0 +1,57 @@
<template>
<Teleport to="body">
<div
v-if="showGhost && rafPosition"
class="pointer-events-none fixed top-0 left-0 z-10000 will-change-transform"
:style="{
transform: `translate(${rafPosition.x + 12}px, ${rafPosition.y + 12}px)`
}"
>
<div class="origin-top-left scale-50 opacity-80">
<LGraphNodePreview
:node-def="draggedNode!"
:widget-values="pendingWidgetValues"
position="relative"
/>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useMouse, useRafFn } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
const { isDragging, draggedNode, pendingWidgetValues } = useNodeDragToCanvas()
const { x, y, sourceType } = useMouse({ type: 'client' })
const showGhost = computed(() => Boolean(isDragging.value && draggedNode.value))
const rafPosition = shallowRef<{ x: number; y: number }>()
const { pause, resume } = useRafFn(
() => {
if (sourceType.value === null) return
const pos = rafPosition.value
if (pos && pos.x === x.value && pos.y === y.value) return
rafPosition.value = { x: x.value, y: y.value }
},
{ immediate: false }
)
watch(
showGhost,
(show) => {
if (show) {
resume()
} else {
pause()
rafPosition.value = undefined
}
},
{ immediate: true }
)
</script>

View File

@@ -14,7 +14,7 @@ const {
captureRoot,
getRoot,
resetRoot,
mockAddNodeOnGraph,
mockStartDrag,
mockGetNodeProvider,
mockToggleNodeOnEvent,
mockRefreshModelFolder,
@@ -29,7 +29,7 @@ const {
resetRoot: () => {
capturedRoot = null
},
mockAddNodeOnGraph: vi.fn(),
mockStartDrag: vi.fn(),
mockGetNodeProvider: vi.fn(),
mockToggleNodeOnEvent: vi.fn(),
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
@@ -37,8 +37,8 @@ const {
}
})
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ addNodeOnGraph: mockAddNodeOnGraph })
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
}))
vi.mock('@/stores/modelToNodeStore', () => ({
@@ -173,16 +173,13 @@ describe('ModelLibrarySidebarTab', () => {
expect(screen.getByTestId('search-input')).toBeInTheDocument()
})
it('handles model click and adds node to graph', async () => {
it('starts a ghost drag carrying the widget value to fill on placement', async () => {
const mockNodeDef = { name: 'CheckpointLoaderSimple' }
const mockWidget = { name: 'ckpt_name', value: '' }
const mockGraphNode = { widgets: [mockWidget] }
mockGetNodeProvider.mockReturnValue({
nodeDef: mockNodeDef,
key: 'ckpt_name'
})
mockAddNodeOnGraph.mockReturnValue(mockGraphNode)
renderComponent()
await nextTick()
@@ -198,8 +195,10 @@ describe('ModelLibrarySidebarTab', () => {
await modelLeaf?.handleClick?.(mockEvent)
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef)
expect(mockWidget.value).toBe('model.safetensors')
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
widgetValues: { ckpt_name: 'model.safetensors' },
source: 'sidebar_drag'
})
})
it('toggles folder expansion on click', async () => {

View File

@@ -63,10 +63,9 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { startModelLoaderDrag } from '@/composables/node/startModelNodeDragFromAsset'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useSettingStore } from '@/platform/settings/settingStore'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
import { ResourceState, useModelStore } from '@/stores/modelStore'
@@ -156,15 +155,7 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
if (this.leaf && model) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const graphNode = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(provider.nodeDef)
)
const widget = graphNode?.widgets?.find(
(widget) => widget.name === provider.key
)
if (widget) {
widget.value = model.file_name
}
startModelLoaderDrag(provider, model.file_name)
}
} else {
toggleNodeOnEvent(e, node)

View File

@@ -31,11 +31,8 @@ vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({
isDragging: { value: false },
draggedNode: { value: null },
cursorPosition: { value: { x: 0, y: 0 } },
startDrag: vi.fn(),
cancelDrag: vi.fn(),
setupGlobalListeners: vi.fn(),
cleanupGlobalListeners: vi.fn()
cancelDrag: vi.fn()
})
}))

View File

@@ -115,7 +115,6 @@
</div>
</template>
<template #body>
<NodeDragPreview />
<div class="flex h-full flex-col">
<div
v-if="hasNoMatches"
@@ -215,7 +214,6 @@ import type {
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
const { flags } = useFeatureFlags()

View File

@@ -1,69 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="isDragging && draggedNode && showPreview"
class="pointer-events-none fixed z-10000"
:style="{
left: `${previewPosition.x + 12}px`,
top: `${previewPosition.y + 12}px`
}"
>
<div class="origin-top-left scale-50 opacity-80">
<LGraphNodePreview :node-def="draggedNode" position="relative" />
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
const {
isDragging,
draggedNode,
cursorPosition,
dragMode,
setupGlobalListeners,
cleanupGlobalListeners
} = useNodeDragToCanvas()
const nativeDragPosition = ref({ x: 0, y: 0 })
const previewPosition = computed(() => {
if (dragMode.value === 'native') {
return nativeDragPosition.value
}
return cursorPosition.value
})
const showPreview = computed(() => {
if (dragMode.value === 'native') {
return nativeDragPosition.value.x > 0 || nativeDragPosition.value.y > 0
}
return true
})
function handleDrag(e: DragEvent) {
if (e.clientX === 0 && e.clientY === 0) return
nativeDragPosition.value = { x: e.clientX, y: e.clientY }
}
function handleDragEnd() {
nativeDragPosition.value = { x: 0, y: 0 }
}
onMounted(() => {
setupGlobalListeners()
document.addEventListener('drag', handleDrag)
document.addEventListener('dragend', handleDragEnd)
})
onUnmounted(() => {
cleanupGlobalListeners()
document.removeEventListener('drag', handleDrag)
document.removeEventListener('dragend', handleDragEnd)
})
</script>

View File

@@ -0,0 +1,110 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import CreditSlider from './CreditSlider.vue'
const meta: Meta<typeof CreditSlider> = {
title: 'Components/CreditSlider',
component: CreditSlider,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
disabled: { control: 'boolean' }
},
args: {
disabled: false
},
decorators: [
(story) => ({
components: { story },
// Previews at the real layout width: the Figma "Team Plan" card column is
// 512px wide with 32px padding (DES-197), i.e. a 448px content area — the
// width the slider actually renders into inside PricingTableWorkspace.
template: '<div class="w-[512px] px-8"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { CreditSlider },
setup() {
const value = ref(700)
return { args, value }
},
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { CreditSlider },
setup() {
const value = ref(700)
return { args, value }
},
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
})
}
// Sample `GET /api/billing/plans → team_credit_stops` payload (DES-197 yearly).
// In production this comes from the API; here it shows the stops being driven
// entirely through props rather than the hardcoded default constant.
const apiTeamCreditStops = {
default_stop_index: 2,
stops: [
{
id: 'team_200',
credits: 42_200,
yearly: { price_cents: 20_000, discount_percent: 0 }
},
{
id: 'team_400',
credits: 84_400,
yearly: { price_cents: 38_000, discount_percent: 5 }
},
{
id: 'team_700',
credits: 147_700,
yearly: { price_cents: 63_000, discount_percent: 10 }
},
{
id: 'team_1400',
credits: 295_400,
yearly: { price_cents: 119_000, discount_percent: 15 }
},
{
id: 'team_2500',
credits: 527_500,
yearly: { price_cents: 200_000, discount_percent: 20 }
}
]
}
// Reference adapter (FE-934 will own this in the data layer): API → CreditStop[].
// The pre-discount list price is recovered as discounted / (1 - discount).
const mappedStops = apiTeamCreditStops.stops.map((s) => ({
credits: s.credits,
discountPercentYearly: s.yearly.discount_percent,
usd: Math.round(
s.yearly.price_cents / 100 / (1 - s.yearly.discount_percent / 100)
)
}))
export const BackendDrivenStops: Story = {
name: 'Backend-driven stops (props)',
render: (args) => ({
components: { CreditSlider },
setup() {
const defaultStopIndex = apiTeamCreditStops.default_stop_index
const value = ref(mappedStops[defaultStopIndex].usd)
return { args, value, mappedStops, defaultStopIndex }
},
template:
'<CreditSlider v-model="value" :stops="mappedStops" :default-stop-index="defaultStopIndex" :disabled="args.disabled" />'
})
}

View File

@@ -0,0 +1,208 @@
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { usdToCredits } from '@/base/credits/comfyCredits'
import { TEAM_PLAN_CREDIT_STOPS } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import CreditSlider from './CreditSlider.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
usdPerMonth: 'USD / mo',
billedYearly: '{total} Billed yearly',
billedMonthly: 'Billed monthly',
creditSliderSave: 'Save {percent}% ({amount})'
}
}
}
})
function renderSlider(props: Record<string, unknown> = {}) {
return render(CreditSlider, { props, global: { plugins: [i18n] } })
}
async function flush() {
await nextTick()
await nextTick()
}
describe('CreditSlider', () => {
it('defaults to the $700 stop (index 2) when no value is bound', async () => {
renderSlider()
await flush()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveAttribute('aria-valuemin', '0')
expect(thumb).toHaveAttribute('aria-valuemax', '4')
expect(thumb).toHaveAttribute('aria-valuenow', '2')
})
it('snaps to the next fixed stop on ArrowRight (never a value in between)', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(usd: number) => void>()
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).toHaveBeenCalledWith(1400)
})
it('snaps to the previous fixed stop on ArrowLeft', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(usd: number) => void>()
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowLeft}')
expect(onUpdate).toHaveBeenCalledWith(400)
})
it('emits change with the full {index, usd, credits} payload', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderSlider({ modelValue: 700, onChange })
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenCalledWith({
index: 3,
usd: 1400,
credits: 295_400
})
})
it('emits nothing when disabled (keyboard interaction suppressed)', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(usd: number) => void>()
const onChange = vi.fn()
renderSlider({
modelValue: 700,
disabled: true,
'onUpdate:modelValue': onUpdate,
onChange
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).not.toHaveBeenCalled()
expect(onChange).not.toHaveBeenCalled()
})
it('shows the discounted price, struck original, save badge and yearly total (DES-197)', async () => {
renderSlider() // default $700 stop → 10% yearly discount
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$630')
expect(
screen.getByTestId('credit-slider-original-price')
).toHaveTextContent('$700')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 10% ($70)'
)
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
'$7,560'
)
})
it('halves the discount and reads "billed monthly" when cycle=monthly (PRD)', async () => {
renderSlider({ cycle: 'monthly' }) // default $700 stop → 10% yearly → 5% monthly
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$665')
expect(
screen.getByTestId('credit-slider-original-price')
).toHaveTextContent('$700')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 5% ($35)'
)
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
'Billed monthly'
)
})
it('applies the fractional monthly discount at $400 (2.5%)', async () => {
renderSlider({ modelValue: 400, cycle: 'monthly' })
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$390')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 2.5% ($10)'
)
})
it('hides the discount UI at the 0% stop ($200)', async () => {
renderSlider({ modelValue: 200 })
await flush()
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$200')
expect(
screen.queryByTestId('credit-slider-original-price')
).not.toBeInTheDocument()
expect(screen.queryByTestId('credit-slider-save')).not.toBeInTheDocument()
})
it('renders all five fixed credit stop labels', async () => {
renderSlider({ modelValue: 700 })
await flush()
const stops = within(screen.getByTestId('credit-slider-stops'))
for (const label of ['42.2K', '84.4K', '147.7K', '295.4K', '527.5K']) {
expect(stops.getByText(label)).toBeInTheDocument()
}
})
it('renders stops + default index supplied via props (BE-sourced override)', async () => {
const stops = [
{ usd: 50, credits: 10_550, discountPercentYearly: 0 },
{ usd: 100, credits: 21_100, discountPercentYearly: 25 }
]
// No modelValue → the model default ($700) matches no stop, so selectedIndex
// falls back to defaultStopIndex (here index 1 → $100).
renderSlider({ stops, defaultStopIndex: 1 })
await flush()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveAttribute('aria-valuemax', '1') // 2 stops → max index 1
expect(thumb).toHaveAttribute('aria-valuenow', '1') // default index honored
// index 1 → $100 at 25% yearly → $75 discounted, struck $100, save $25
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$75')
expect(
screen.getByTestId('credit-slider-original-price')
).toHaveTextContent('$100')
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
'Save 25% ($25)'
)
// Only the prop's labels render — none of the DES-197 defaults.
const labels = within(screen.getByTestId('credit-slider-stops'))
expect(labels.getByText('10.6K')).toBeInTheDocument()
expect(labels.getByText('21.1K')).toBeInTheDocument()
expect(labels.queryByText('147.7K')).not.toBeInTheDocument()
})
it('keeps every credit amount equal to usdToCredits(usd) (guards rate drift)', () => {
for (const stop of TEAM_PLAN_CREDIT_STOPS) {
expect(stop.credits).toBe(usdToCredits(stop.usd))
}
})
})

View File

@@ -0,0 +1,235 @@
<script setup lang="ts">
import {
TransitionPresets,
usePreferredReducedMotion,
useTransition
} from '@vueuse/core'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
TEAM_PLAN_CREDIT_STOPS
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
const {
disabled = false,
class: rootClass,
stops = TEAM_PLAN_CREDIT_STOPS,
defaultStopIndex = DEFAULT_TEAM_PLAN_STOP_INDEX,
cycle = 'yearly'
} = defineProps<{
disabled?: boolean
class?: HTMLAttributes['class']
/**
* The fixed credit stops the slider snaps to. Must be non-empty. Defaults to
* the hardcoded DES-197 set; pass the backend-sourced stops once the contract
* lands — map `GET /api/billing/plans → team_credit_stops.stops` to
* `CreditStop[]` (credits, the pre-discount `usd`, and `discountPercentYearly`).
*/
stops?: readonly CreditStop[]
/**
* Stop selected when the bound value matches none (e.g. first render).
* Maps to `team_credit_stops.default_stop_index`. Defaults to DES-197 ($700).
*/
defaultStopIndex?: number
/**
* Billing cycle. Yearly applies the full `discountPercentYearly`; monthly
* applies half of it (PRD: GA Team Billing — "for monthly the discount is
* halved": yearly 0/5/10/15/20% → monthly 0/2.5/5/7.5/10%).
*/
cycle?: 'monthly' | 'yearly'
}>()
const emit = defineEmits<{
/** Fired when the selected stop changes, with the full derived payload. */
change: [stop: { index: number; usd: number; credits: number }]
}>()
/**
* v-model carries the selected USD value (one of the `stops`). The literal
* default keeps `defineModel` statically analyzable; when custom `stops` are
* passed without a matching v-model, `selectedIndex` falls back to
* `defaultStopIndex`, so the displayed stop is still correct.
*/
const usd = defineModel<number>({
default: TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].usd
})
const selectedIndex = computed(() => {
const i = stops.findIndex((stop) => stop.usd === usd.value)
if (i !== -1) return i
// Fall back to the default stop, clamped into range: a backend-driven `stops`
// array can be shorter than expected (or `defaultStopIndex` out of bounds), so
// clamping keeps `current` defined and the price computeds below from reading
// `undefined.usd` at runtime. (`stops` is required to be non-empty.)
return Math.min(Math.max(defaultStopIndex, 0), Math.max(stops.length - 1, 0))
})
const current = computed<CreditStop>(() => stops[selectedIndex.value])
// The discount applies to the monthly figure. Yearly uses the full
// `discountPercentYearly`; monthly halves it (PRD: GA Team Billing). The card
// shows the discounted monthly price, the struck pre-discount price, the
// saving, and — for yearly — the annual total.
const effectiveDiscountPercent = computed(() =>
cycle === 'monthly'
? current.value.discountPercentYearly / 2
: current.value.discountPercentYearly
)
const discountedMonthly = computed(() =>
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
)
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)
/**
* Smoothly count the price figures up/down as the slider moves between stops
* instead of snapping. Honors the user's reduced-motion preference. The save
* badge ("X% ($Y)") is intentionally left snapping — its percent is a discrete
* tier, so animating the bracketed amount alone would read inconsistently.
*/
const prefersReducedMotion = usePreferredReducedMotion()
const priceTween = {
duration: 350,
easing: TransitionPresets.easeOutCubic,
disabled: computed(() => prefersReducedMotion.value === 'reduce')
}
const animatedMonthly = useTransition(discountedMonthly, priceTween)
const animatedOriginal = useTransition(() => current.value.usd, priceTween)
const displayMonthly = computed(() => Math.round(animatedMonthly.value))
const displayOriginal = computed(() => Math.round(animatedOriginal.value))
// Derive the yearly total from the displayed monthly so it always reads as
// exactly 12× the price shown — even mid-count — rather than drifting as a
// second, independently-phased tween would.
const displayBilledYearly = computed(() => displayMonthly.value * 12)
/**
* Bridge the discrete stop index (0..n-1) to the reka-ui slider's `number[]`
* model. Driving the slider in index space with `step = 1` guarantees the
* thumb can only land on the fixed stops — never a value in between.
*/
const sliderModel = computed<number[]>({
get: () => [selectedIndex.value],
set: ([index]) => {
const stop = stops[index]
if (!stop) return
usd.value = stop.usd
emit('change', { index, usd: stop.usd, credits: stop.credits })
}
})
const lastIndex = computed(() => Math.max(stops.length - 1, 0))
const formatUsd = (value: number) => `$${value.toLocaleString('en-US')}`
const formatCreditsCompact = (value: number) =>
new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1
}).format(value)
const { t } = useI18n()
</script>
<template>
<div :class="cn('flex w-full flex-col gap-3', rootClass)">
<!-- Price: discounted monthly + struck pre-discount + save badge -->
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
<span
class="text-[2rem]/none font-semibold text-base-foreground tabular-nums"
data-testid="credit-slider-price"
>
{{ formatUsd(displayMonthly) }}
</span>
<span
v-if="hasDiscount"
class="text-base text-muted-foreground tabular-nums line-through"
data-testid="credit-slider-original-price"
>
{{ formatUsd(displayOriginal) }}
</span>
<span class="text-base text-muted-foreground">
{{ t('subscription.usdPerMonth') }}
</span>
</span>
<!-- Save badge: outlined primary pill. On wide layouts it's pushed to
the right of the price; when the column narrows (mobile) it wraps
and aligns left under the price instead (DES QA). -->
<span
v-if="hasDiscount"
data-testid="credit-slider-save"
class="shrink-0 rounded-full border-2 border-primary-background px-2 py-1 text-sm font-bold whitespace-nowrap text-primary-background xl:ms-auto"
>
{{
t('subscription.creditSliderSave', {
percent: effectiveDiscountPercent,
amount: formatUsd(saveAmount)
})
}}
</span>
</div>
<p
class="m-0 text-sm text-muted-foreground tabular-nums"
data-testid="credit-slider-billed-yearly"
>
{{
cycle === 'monthly'
? t('subscription.billedMonthly')
: t('subscription.billedYearly', {
total: formatUsd(displayBilledYearly)
})
}}
</p>
</div>
<!-- Discrete slider: snaps to the 5 fixed DES-197 stops -->
<Slider
v-model="sliderModel"
:min="0"
:max="lastIndex"
:step="1"
:disabled="disabled"
range-class="bg-base-foreground"
thumb-class="bg-base-foreground"
/>
<!-- Credit stop labels; the selected stop is emphasized -->
<ol
data-testid="credit-slider-stops"
class="m-0 flex list-none justify-between p-0"
>
<li
v-for="(stop, i) in stops"
:key="stop.usd"
:data-selected="i === selectedIndex ? '' : undefined"
:class="
cn(
'flex items-center gap-1 text-xs tabular-nums',
i === selectedIndex
? 'font-semibold text-base-foreground'
: 'text-muted-foreground'
)
"
>
<i
:class="
cn(
'icon-[comfy--credits] size-3 shrink-0',
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
)
"
aria-hidden="true"
/>
{{ formatCreditsCompact(stop.credits) }}
</li>
</ol>
</div>
</template>

View File

@@ -15,7 +15,11 @@ import { cn } from '@comfyorg/tailwind-utils'
const props = defineProps<
// eslint-disable-next-line vue/no-unused-properties
SliderRootProps & { class?: HTMLAttributes['class'] }
SliderRootProps & {
class?: HTMLAttributes['class']
rangeClass?: HTMLAttributes['class']
thumbClass?: HTMLAttributes['class']
}
>()
const pressed = ref(false)
@@ -25,7 +29,7 @@ const setPressed = (val: boolean) => {
const emits = defineEmits<SliderRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, 'class', 'rangeClass', 'thumbClass')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -60,7 +64,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
>
<SliderRange
data-slot="slider-range"
class="absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
:class="
cn(
'absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
props.rangeClass
)
"
/>
</SliderTrack>
@@ -74,7 +83,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
'cursor-grab',
'before:absolute before:-inset-1 before:block before:rounded-full before:bg-transparent',
'hover:ring-2 focus-visible:ring-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
{ 'cursor-grabbing': pressed }
{ 'cursor-grabbing': pressed },
props.thumbClass
)
"
/>

View File

@@ -5,11 +5,13 @@ import type {
BillingStatus,
BillingSubscriptionStatus,
CreateTopupResponse,
CurrentTeamCreditStop,
Plan,
PreviewSubscribeResponse,
SubscribeResponse,
SubscriptionDuration,
SubscriptionTier
SubscriptionTier,
TeamCreditStops
} from '@/platform/workspace/api/workspaceApi'
export type BillingType = 'legacy' | 'workspace'
@@ -71,6 +73,10 @@ export interface BillingState {
balance: ComputedRef<BalanceInfo | null>
plans: ComputedRef<Plan[]>
currentPlanSlug: ComputedRef<string | null>
/** Team per-credit pricing ladder; null for personal/legacy. */
teamCreditStops: ComputedRef<TeamCreditStops | null>
/** The team's currently-subscribed credit stop; null for personal/legacy. */
currentTeamCreditStop: ComputedRef<CurrentTeamCreditStop | null>
isLoading: Ref<boolean>
error: Ref<string | null>
isActiveSubscription: ComputedRef<boolean>
@@ -83,5 +89,10 @@ export interface BillingState {
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
/**
* True when the active team workspace is still on a pre-credit-slider
* (legacy) per-member tier plan, which keeps the old team pricing table.
*/
isLegacyTeamPlan: ComputedRef<boolean>
getMaxSeats: (tierKey: TierKey) => number
}

View File

@@ -1,20 +1,39 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import type {
BillingStatusResponse,
Plan
} from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext'
const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
}
const {
mockTeamWorkspacesEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits
mockPurchaseCredits,
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn()
mockPurchaseCredits: vi.fn(),
mockBillingStatus: {
value: {
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
} as BillingStatusResponse
}
}))
vi.mock('@vueuse/core', async (importOriginal) => {
@@ -103,12 +122,7 @@ vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getBillingStatus: vi.fn().mockResolvedValue({
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
}),
getBillingStatus: vi.fn(() => Promise.resolve(mockBillingStatus.value)),
getBillingBalance: vi.fn().mockResolvedValue({
amount_micros: 10000000,
currency: 'usd'
@@ -125,6 +139,7 @@ describe('useBillingContext', () => {
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
})
it('returns legacy type for personal workspace', () => {
@@ -252,4 +267,158 @@ describe('useBillingContext', () => {
expect(getMaxSeats('creator')).toBe(5)
})
})
describe('isLegacyTeamPlan', () => {
it('is false for a personal workspace', () => {
const { isLegacyTeamPlan } = useBillingContext()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('is true for an active team plan: team- slug and no credit stop', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'STANDARD',
subscription_duration: 'ANNUAL',
plan_slug: 'team-standard-annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(true)
})
it('is true for any legacy team tier, not just standard', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
subscription_duration: 'ANNUAL',
plan_slug: 'team-pro-annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(true)
})
it('is false for a new credit-slider team subscriber', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
// Real BE shape: underscore slug + populated credit stop. (subscription_tier
// is 'TEAM' on the wire, not yet in the FE SubscriptionTier union, so it is
// omitted here — the predicate does not depend on it.)
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_status: 'active',
subscription_duration: 'ANNUAL',
plan_slug: 'team_per_credit_annual',
team_credit_stop: {
id: 'team_700',
credits_monthly: 147700,
stop_usd: 700
}
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('is false for a new team sub even before its credit stop is populated', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
// Provisioning lag: credit stop not yet attached. The underscore slug
// (team_per_credit, not team-) must still exclude it from the legacy table.
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_status: 'active',
subscription_duration: 'ANNUAL',
plan_slug: 'team_per_credit_annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('is false for a team workspace on a personal-tier plan', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'STANDARD',
subscription_duration: 'ANNUAL',
plan_slug: 'standard-annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('stays true for a cancelled-but-still-active legacy team sub', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_status: 'canceled',
subscription_tier: 'STANDARD',
subscription_duration: 'ANNUAL',
plan_slug: 'team-standard-annual',
cancel_at: '2099-01-01T00:00:00Z'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(true)
})
it('is false for a FREE-tier team even on a team- prefixed slug', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'FREE',
plan_slug: 'team-free'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(false)
})
it('matches the legacy slug case-insensitively', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'STANDARD',
subscription_duration: 'ANNUAL',
plan_slug: 'Team-Standard-Annual'
}
const { initialize, isLegacyTeamPlan } = useBillingContext()
await initialize()
expect(isLegacyTeamPlan.value).toBe(true)
})
})
})

View File

@@ -20,6 +20,12 @@ import type {
import { useLegacyBilling } from './useLegacyBilling'
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
// Legacy per-member team plans use a hyphenated `team-{tier}-{cycle}` slug; the
// new credit-slider plan uses an underscore `team_per_credit_{cycle}` slug and
// carries a team_credit_stop. The hyphen prefix alone separates the two, so a
// new sub is never misrouted even before its credit stop is populated.
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
/**
* Unified billing context that automatically switches between legacy (user-scoped)
* and workspace billing based on the active workspace type.
@@ -116,12 +122,32 @@ function useBillingContextInternal(): BillingContext {
toValue(activeContext.value.currentPlanSlug)
)
const teamCreditStops = computed(() =>
toValue(activeContext.value.teamCreditStops)
)
const currentTeamCreditStop = computed(() =>
toValue(activeContext.value.currentTeamCreditStop)
)
const isActiveSubscription = computed(() =>
toValue(activeContext.value.isActiveSubscription)
)
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
const isLegacyTeamPlan = computed(
() =>
type.value === 'workspace' &&
isActiveSubscription.value &&
!isFreeTier.value &&
currentTeamCreditStop.value === null &&
(currentPlanSlug.value
?.toLowerCase()
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
false)
)
const billingStatus = computed(() =>
toValue(activeContext.value.billingStatus)
)
@@ -254,10 +280,13 @@ function useBillingContextInternal(): BillingContext {
balance,
plans,
currentPlanSlug,
teamCreditStops,
currentTeamCreditStop,
isLoading,
error,
isActiveSubscription,
isFreeTier,
isLegacyTeamPlan,
billingStatus,
subscriptionStatus,
tier,

View File

@@ -93,6 +93,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
const currentPlanSlug = computed(() => null)
const teamCreditStops = computed(() => null)
const currentTeamCreditStop = computed(() => null)
async function initialize(): Promise<void> {
if (isInitialized.value) return
@@ -200,6 +202,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
balance,
plans,
currentPlanSlug,
teamCreditStops,
currentTeamCreditStop,
isLoading,
error,
isActiveSubscription,

View File

@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
const { mockStartDrag, mockGetNodeProvider } = vi.hoisted(() => ({
mockStartDrag: vi.fn(),
mockGetNodeProvider: vi.fn()
}))
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
}))
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-123',
name: 'sd_xl_base_1.0.safetensors',
size: 1024,
created_at: '2025-10-01T00:00:00Z',
tags: ['models', 'checkpoints'],
user_metadata: { filename: 'sd_xl_base_1.0.safetensors' },
...overrides
}
}
describe('startModelNodeDragFromAsset', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('starts a ghost drag for the resolved node carrying the widget value', () => {
const nodeDef = { name: 'CheckpointLoaderSimple' }
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
const error = startModelNodeDragFromAsset(createAsset())
expect(error).toBeUndefined()
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' },
source: 'sidebar_drag'
})
})
it('threads the node-add source through to the drag', () => {
const nodeDef = { name: 'CheckpointLoaderSimple' }
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
startModelNodeDragFromAsset(createAsset(), 'asset_browser')
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' },
source: 'asset_browser'
})
})
it('carries no widget value when the provider has no key', () => {
const nodeDef = { name: 'FL_ChatterboxVC' }
mockGetNodeProvider.mockReturnValue({ nodeDef, key: '' })
startModelNodeDragFromAsset(
createAsset({
tags: ['models', 'chatterbox/chatterbox_vc'],
user_metadata: { filename: 'chatterbox_vc_model.pt' }
})
)
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
widgetValues: undefined,
source: 'sidebar_drag'
})
})
it('returns the resolution error and does not start a drag for an invalid asset', () => {
mockGetNodeProvider.mockReturnValue(null)
const error = startModelNodeDragFromAsset(createAsset())
expect(error?.code).toBe('NO_PROVIDER')
expect(mockStartDrag).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,38 @@
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
import type { ResolveModelNodeError } from '@/platform/assets/utils/resolveModelNodeFromAsset'
import type { NodeAddSource } from '@/platform/telemetry/types'
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
/**
* Arms a ghost drag for a model loader node. Providers with no widget key
* (auto-load nodes) start the drag without widget values.
*/
export function startModelLoaderDrag(
provider: ModelNodeProvider,
filename: string,
source: NodeAddSource = 'sidebar_drag'
) {
const widgetValues = provider.key ? { [provider.key]: filename } : undefined
useNodeDragToCanvas().startDrag(provider.nodeDef, { widgetValues, source })
}
/**
* Starts a ghost drag for the model loader node described by an asset. The
* node is created where the user next clicks the canvas, with the asset's
* filename written into the loader widget.
*
* @returns the resolution error when the asset cannot be mapped to a node,
* otherwise `undefined`.
*/
export function startModelNodeDragFromAsset(
asset: AssetItem,
source: NodeAddSource = 'sidebar_drag'
): ResolveModelNodeError | undefined {
const resolved = resolveModelNodeFromAsset(asset)
if (!resolved.success) return resolved.error
const { provider, filename } = resolved.value
startModelLoaderDrag(provider, filename, source)
}

View File

@@ -7,7 +7,8 @@ const {
mockAddNodeOnGraph,
mockConvertEventToCanvasOffset,
mockSelectItems,
mockCanvas
mockCanvas,
mockToastAdd
} = vi.hoisted(() => {
const mockConvertEventToCanvasOffset = vi.fn()
const mockSelectItems = vi.fn()
@@ -15,6 +16,7 @@ const {
mockAddNodeOnGraph: vi.fn(),
mockConvertEventToCanvasOffset,
mockSelectItems,
mockToastAdd: vi.fn(),
mockCanvas: {
canvas: {
getBoundingClientRect: vi.fn()
@@ -37,6 +39,12 @@ vi.mock('@/services/litegraphService', () => ({
}))
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({ add: mockToastAdd }))
}))
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
describe('useNodeDragToCanvas', () => {
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
@@ -54,8 +62,8 @@ describe('useNodeDragToCanvas', () => {
})
afterEach(() => {
const { cleanupGlobalListeners } = useNodeDragToCanvas()
cleanupGlobalListeners()
const { cancelDrag } = useNodeDragToCanvas()
cancelDrag()
vi.restoreAllMocks()
})
@@ -71,22 +79,6 @@ describe('useNodeDragToCanvas', () => {
expect(isDragging.value).toBe(true)
expect(draggedNode.value).toBe(mockNodeDef)
})
it('should set dragMode to click by default', () => {
const { dragMode, startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(dragMode.value).toBe('click')
})
it('should set dragMode to native when specified', () => {
const { dragMode, startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
expect(dragMode.value).toBe('native')
})
})
describe('cancelDrag', () => {
@@ -102,30 +94,15 @@ describe('useNodeDragToCanvas', () => {
expect(isDragging.value).toBe(false)
expect(draggedNode.value).toBeNull()
})
it('should reset dragMode to click', () => {
const { dragMode, startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
expect(dragMode.value).toBe('native')
cancelDrag()
expect(dragMode.value).toBe('click')
})
})
describe('setupGlobalListeners', () => {
it('should add event listeners to document', () => {
describe('drag listener lifecycle', () => {
it('should attach document listeners on startDrag', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { setupGlobalListeners } = useNodeDragToCanvas()
const { startDrag } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointermove',
expect.any(Function)
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
@@ -142,35 +119,53 @@ describe('useNodeDragToCanvas', () => {
)
})
it('should only setup listeners once', () => {
it('should not attach drag listeners until a drag starts', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { setupGlobalListeners } = useNodeDragToCanvas()
useNodeDragToCanvas()
setupGlobalListeners()
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
true
)
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
'keydown',
expect.any(Function)
)
})
it('should detach document listeners on cancelDrag', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
const { startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
cancelDrag()
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
true
)
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
true
)
})
it('should only attach listeners once across re-arms', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const callCount = addEventListenerSpy.mock.calls.length
setupGlobalListeners()
startDrag(mockNodeDef)
expect(addEventListenerSpy.mock.calls.length).toBe(callCount)
})
})
describe('cursorPosition', () => {
it('should update on pointermove', () => {
const { cursorPosition, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const pointerEvent = new PointerEvent('pointermove', {
clientX: 100,
clientY: 200
})
document.dispatchEvent(pointerEvent)
expect(cursorPosition.value).toEqual({ x: 100, y: 200 })
})
})
describe('endDrag behavior', () => {
it('should add node when pointer is over canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
@@ -181,9 +176,7 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
@@ -206,10 +199,7 @@ describe('useNodeDragToCanvas', () => {
bottom: 500
})
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
@@ -224,10 +214,7 @@ describe('useNodeDragToCanvas', () => {
})
it('should cancel drag on Escape key', () => {
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(isDragging.value).toBe(true)
@@ -239,10 +226,7 @@ describe('useNodeDragToCanvas', () => {
})
it('should not cancel drag on other keys', () => {
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef)
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter' })
@@ -262,8 +246,7 @@ describe('useNodeDragToCanvas', () => {
const placedNode = { id: 1 }
mockAddNodeOnGraph.mockReturnValue(placedNode)
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
document.dispatchEvent(
@@ -277,6 +260,102 @@ describe('useNodeDragToCanvas', () => {
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
})
it('should apply the requested widget values to the placed node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const widget = { name: 'ckpt_name', value: '' }
mockAddNodeOnGraph.mockReturnValue({ id: 1, widgets: [widget] })
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, {
widgetValues: { ckpt_name: 'model.safetensors' }
})
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(widget.value).toBe('model.safetensors')
})
it('should warn but still place the node when a requested widget is missing', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const placedNode = { id: 1, widgets: [] }
mockAddNodeOnGraph.mockReturnValue(placedNode)
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, {
widgetValues: { ckpt_name: 'model.safetensors' }
})
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'warn',
detail: 'assetBrowser.failedToSetModelValue'
})
)
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('ckpt_name')
)
})
it('should show an error toast when the graph fails to add the node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
mockAddNodeOnGraph.mockReturnValue(null)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'assetBrowser.failedToCreateNode'
})
)
})
it('should not call selectItems when graph returns no node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
@@ -286,9 +365,9 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
mockAddNodeOnGraph.mockReturnValue(null)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
document.dispatchEvent(
@@ -311,11 +390,8 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
const pointerEvent = new PointerEvent('pointerup', {
clientX: 250,
@@ -341,7 +417,7 @@ describe('useNodeDragToCanvas', () => {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(250, 250)
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
@@ -359,7 +435,7 @@ describe('useNodeDragToCanvas', () => {
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(600, 250)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
@@ -377,7 +453,7 @@ describe('useNodeDragToCanvas', () => {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'click')
startDrag(mockNodeDef)
handleNativeDrop(250, 250)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
@@ -392,14 +468,12 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
const { startDrag, handleNativeDrop, isDragging, dragMode } =
useNodeDragToCanvas()
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(250, 250)
expect(isDragging.value).toBe(false)
expect(dragMode.value).toBe('click')
})
})
@@ -426,31 +500,29 @@ describe('useNodeDragToCanvas', () => {
})
it('should stop propagation when in click-drag mode over canvas', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(dispatchPointerDown(250, 250)).toHaveBeenCalled()
})
it('should not stop propagation when not dragging', () => {
const { setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
it('should not stop propagation once the drag is cancelled', () => {
const { startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
cancelDrag()
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
})
it('should not stop propagation in native drag mode', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
})
it('should not stop propagation when pointer is outside canvas', () => {
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
@@ -477,10 +549,8 @@ describe('useNodeDragToCanvas', () => {
}
it('should prefer tracked drag position over dragend coordinates', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
fireDrag(250, 250)
// dragend supplies a bad position (the Firefox bug); the tracked one
@@ -494,10 +564,8 @@ describe('useNodeDragToCanvas', () => {
})
it('should ignore drag events with (0, 0)', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
fireDrag(250, 250)
fireDrag(0, 0)
@@ -510,10 +578,8 @@ describe('useNodeDragToCanvas', () => {
})
it('should fall back to dragend coordinates when no drag fired', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(250, 250)
@@ -523,32 +589,14 @@ describe('useNodeDragToCanvas', () => {
})
})
it('should ignore dragover events fired before startDrag', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
fireDrag(250, 250)
startDrag(mockNodeDef, 'native')
handleNativeDrop(300, 300)
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
clientX: 300,
clientY: 300
})
})
it('should clear tracked position between drags', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
fireDrag(250, 250)
handleNativeDrop(1505, 102)
// Second drag - no drag events, so we should fall back to args.
startDrag(mockNodeDef, 'native')
startDrag(mockNodeDef, { mode: 'native' })
handleNativeDrop(300, 300)
expect(mockConvertEventToCanvasOffset).toHaveBeenLastCalledWith({

View File

@@ -1,23 +1,32 @@
import { ref, shallowRef } from 'vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import type { NodeAddSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
type DragMode = 'click' | 'native'
type WidgetValues = Record<string, string>
type Position = { x: number; y: number }
interface StartDragOptions {
mode?: DragMode
widgetValues?: WidgetValues
source?: NodeAddSource
}
const isDragging = ref(false)
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
const cursorPosition = ref({ x: 0, y: 0 })
const dragMode = ref<DragMode>('click')
const lastNativeDragPosition = shallowRef<{ x: number; y: number }>()
const lastNativeDragPosition = shallowRef<Position>()
const pendingWidgetValues = shallowRef<WidgetValues>()
const pendingSource = ref<NodeAddSource>('sidebar_drag')
let listenersSetup = false
function updatePosition(e: PointerEvent) {
cursorPosition.value = { x: e.clientX, y: e.clientY }
}
// Firefox dragend can report stale clientX/Y and `drag` can fire with
// (0, 0). dragover on the target reliably reports real client coords.
// https://bugzilla.mozilla.org/show_bug.cgi?id=1773886
@@ -27,11 +36,20 @@ function trackNativeDragPosition(e: DragEvent) {
lastNativeDragPosition.value = { x: e.clientX, y: e.clientY }
}
function cancelDrag() {
isDragging.value = false
draggedNode.value = null
dragMode.value = 'click'
lastNativeDragPosition.value = undefined
function applyWidgetValues(node: LGraphNode, values: WidgetValues) {
for (const [name, value] of Object.entries(values)) {
const widget = node.widgets?.find((w) => w.name === name)
if (!widget) {
console.error(`Widget ${name} not found on node ${node.type}`)
useToastStore().add({
severity: 'warn',
summary: t('g.warning'),
detail: t('assetBrowser.failedToSetModelValue')
})
continue
}
widget.value = value
}
}
function isOverCanvas(clientX: number, clientY: number): boolean {
@@ -59,10 +77,22 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
clientX,
clientY
} as PointerEvent)
const node = withNodeAddSource('sidebar_drag', () =>
const node = withNodeAddSource(pendingSource.value, () =>
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
)
if (node) canvas.selectItems([node])
if (!node) {
console.error(`Failed to add node to graph: ${nodeDef.name}`)
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('assetBrowser.failedToCreateNode')
})
return true
}
if (pendingWidgetValues.value)
applyWidgetValues(node, pendingWidgetValues.value)
canvas.selectItems([node])
return true
}
@@ -92,7 +122,6 @@ function setupGlobalListeners() {
if (listenersSetup) return
listenersSetup = true
document.addEventListener('pointermove', updatePosition)
document.addEventListener('pointerdown', blockCommitPointerDown, true)
document.addEventListener('pointerup', endDrag, true)
document.addEventListener('keydown', handleKeydown)
@@ -103,22 +132,37 @@ function cleanupGlobalListeners() {
if (!listenersSetup) return
listenersSetup = false
document.removeEventListener('pointermove', updatePosition)
document.removeEventListener('pointerdown', blockCommitPointerDown, true)
document.removeEventListener('pointerup', endDrag, true)
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('dragover', trackNativeDragPosition)
}
if (isDragging.value && dragMode.value === 'click') {
cancelDrag()
}
function cancelDrag() {
isDragging.value = false
draggedNode.value = null
dragMode.value = 'click'
lastNativeDragPosition.value = undefined
pendingWidgetValues.value = undefined
pendingSource.value = 'sidebar_drag'
cleanupGlobalListeners()
}
export function useNodeDragToCanvas() {
function startDrag(nodeDef: ComfyNodeDefImpl, mode: DragMode = 'click') {
function startDrag(
nodeDef: ComfyNodeDefImpl,
{
mode = 'click',
widgetValues,
source = 'sidebar_drag'
}: StartDragOptions = {}
) {
isDragging.value = true
draggedNode.value = nodeDef
dragMode.value = mode
pendingWidgetValues.value = widgetValues
pendingSource.value = source
setupGlobalListeners()
}
function handleNativeDrop(clientX: number, clientY: number) {
@@ -134,12 +178,9 @@ export function useNodeDragToCanvas() {
return {
isDragging,
draggedNode,
cursorPosition,
dragMode,
pendingWidgetValues,
startDrag,
cancelDrag,
handleNativeDrop,
setupGlobalListeners,
cleanupGlobalListeners
handleNativeDrop
}
}

View File

@@ -124,7 +124,9 @@ describe('useNodePreviewAndDrag', () => {
expect(result.isDragging.value).toBe(true)
expect(result.isHovered.value).toBe(false)
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, 'native')
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
mode: 'native'
})
expect(mockDataTransfer.effectAllowed).toBe('copy')
expect(mockDataTransfer.setData).toHaveBeenCalledWith(
'application/x-comfy-node',

View File

@@ -125,7 +125,7 @@ export function useNodePreviewAndDrag(
isDragging.value = true
isHovered.value = false
startDrag(nodeDef.value, 'native')
startDrag(nodeDef.value, { mode: 'native' })
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'copy'

View File

@@ -5,11 +5,13 @@ import { ref } from 'vue'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useExternalLink } from '@/composables/useExternalLink'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type * as ModelStoreModule from '@/stores/modelStore'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { fromPartial } from '@total-typescript/shoehorn'
// Mock vue-i18n for useExternalLink
const mockLocale = ref('en')
@@ -135,6 +137,23 @@ vi.mock('@/stores/toastStore', () => ({
useToastStore: vi.fn(() => ({}))
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({ add: mockToastAdd }))
}))
const mockAssetBrowse = vi.hoisted(() =>
vi.fn<(options: { onAssetSelected?: (asset: AssetItem) => void }) => void>()
)
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => ({
useAssetBrowserDialog: vi.fn(() => ({ browse: mockAssetBrowse }))
}))
const mockStartModelNodeDrag = vi.hoisted(() => vi.fn())
vi.mock('@/composables/node/startModelNodeDragFromAsset', () => ({
startModelNodeDragFromAsset: mockStartModelNodeDrag
}))
const mockChangeTracker = vi.hoisted(() => ({
captureCanvasState: vi.fn()
}))
@@ -618,4 +637,47 @@ describe('useCoreCommands', () => {
expect(mockShowAbout).toHaveBeenCalled()
})
})
describe('BrowseModelAssets command', () => {
const asset = fromPartial<AssetItem>({ id: 'asset-1' })
async function selectAssetFromBrowser() {
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true))
const command = useCoreCommands().find(
(cmd) => cmd.id === 'Comfy.BrowseModelAssets'
)!
await command.function()
const { onAssetSelected } = mockAssetBrowse.mock.calls[0][0]
onAssetSelected?.(asset)
}
it('starts a model node drag for the selected asset', async () => {
mockStartModelNodeDrag.mockReturnValue(undefined)
await selectAssetFromBrowser()
expect(mockStartModelNodeDrag).toHaveBeenCalledWith(
asset,
'asset_browser'
)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('shows an error toast when the asset cannot start a drag', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
mockStartModelNodeDrag.mockReturnValue({
code: 'NO_PROVIDER',
message: 'No node provider registered',
assetId: 'asset-1'
})
await selectAssetFromBrowser()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
})
})

View File

@@ -2,6 +2,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
import { useExternalLink } from '@/composables/useExternalLink'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
@@ -21,7 +22,6 @@ import {
import type { Point } from '@/lib/litegraph/src/litegraph'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
@@ -1307,14 +1307,14 @@ export function useCoreCommands(): ComfyCommand[] {
assetType: 'models',
title: t('sideToolbar.modelLibrary'),
onAssetSelected: (asset) => {
const result = createModelNodeFromAsset(asset)
if (!result.success) {
const error = startModelNodeDragFromAsset(asset, 'asset_browser')
if (error) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('assetBrowser.failedToCreateNode')
})
console.error('Node creation failed:', result.error)
console.error('Node creation failed:', error)
}
}
})

View File

@@ -2529,13 +2529,17 @@
"name": "Founder's Edition"
},
"standard": {
"name": "Standard"
"name": "Standard",
"feature1": "30 minute max workflow runtime",
"feature2": "Add more credits anytime"
},
"creator": {
"name": "Creator"
"name": "Creator",
"feature1": "Import your own models"
},
"pro": {
"name": "Pro"
"name": "Pro",
"feature1": "Longer workflow runtime (up to 1 hr)"
}
},
"required": {
@@ -2563,10 +2567,52 @@
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud and invite members",
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
"description": "Choose the best plan for you",
"descriptionWorkspace": "Choose the best plan for your workspace",
"descriptionWorkspace": "Choose a Plan",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "View enterprise",
"planScope": {
"personal": "For Personal",
"team": "For Teams"
},
"teamHeader": "For teams wanting to collaborate. Need more members? {learnMore} about enterprise.",
"teamHeaderLearnMore": "Learn more",
"personalHeader": "Personal plans are for individual use only. {action}",
"personalHeaderAction": "To add teammates, subscribe to the team plan.",
"whatsIncluded": "What's included:",
"everythingInPlus": "Everything in {plan}, plus:",
"monthlyCredits": "monthly credits",
"videoEstimate": "Generates ~{count} 5s videos*",
"saveYearly": "Save 20%",
"saveYearlyUpTo": "Save up to 20%",
"teamPlan": {
"name": "Team Plan",
"tagline": "Choose your own monthly credit subscription. Get a larger discount with a larger credit subscription.",
"detailsTitle": "Details",
"perkInviteMembers": "Invite team members",
"perkConcurrentRuns": "Members can run workflows concurrently",
"perkSharedPool": "Shared credit pool for all members",
"perkRolePermissions": "Role-based permissions",
"comingSoonLabel": "Coming soon:",
"perkProjectAssets": "Project & asset management",
"cta": "Subscribe to Team Yearly",
"ctaMonthly": "Subscribe to Team Monthly",
"changePlan": "Change plan",
"currentPlan": "Current plan",
"checkoutComingSoon": "Team plan checkout is coming soon."
},
"enterprise": {
"name": "Enterprise",
"needMoreMembers": "Need more members?",
"flexibility": "Looking for more flexibility or custom features?",
"reachOut": "Reach out to us and let's schedule a chat.",
"cta": "Learn more"
},
"pricingBlurb": "*Based on this template, {seeDetails}. Contact us for {questions} or {enterpriseDiscussions}. For more pricing details, {clickHere}.",
"pricingBlurbSeeDetails": "see details",
"pricingBlurbQuestions": "questions",
"pricingBlurbEnterprise": "enterprise discussions",
"pricingBlurbClickHere": "click here",
"freeTier": {
"title": "You're on the Free plan",
"description": "Your free plan includes {credits} credits each month to try Comfy Cloud.",
@@ -2626,6 +2672,9 @@
"starting": "Starting {date}",
"ends": "Ends {date}",
"eachMonthCreditsRefill": "Each month credits refill to",
"everyMonthStarting": "Every month starting {date}",
"creditsRefillTo": "Credits refill to",
"youllBeCharged": "You'll be charged",
"perMember": "/ member",
"showMoreFeatures": "Show more features",
"hideFeatures": "Hide features",
@@ -2638,7 +2687,14 @@
"privacyPolicy": "Privacy Policy",
"addCreditCard": "Add credit card",
"confirm": "Confirm",
"subscribeToPlan": "Subscribe to {plan}",
"switchToPlan": "Switch to {plan}",
"backToAllPlans": "Back to all plans"
},
"success": {
"allSet": "You're all set",
"planUpdated": "Your plan has been successfully updated.",
"receiptEmailed": "A receipt has been emailed to you."
}
},
"userSettings": {
@@ -3121,6 +3177,7 @@
"invalidFilename": "Invalid Filename",
"invalidFilenameDetail": "The asset filename could not be determined. Please try again.",
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"failedToSetModelValue": "Node added, but its model could not be set automatically. Check the console for details.",
"fileFormats": "File formats",
"fileName": "File Name",
"fileSize": "File Size",

View File

@@ -6,7 +6,7 @@
* input key where the model name is inserted.
*
* An empty key ('') means the node auto-loads models without a widget
* selector (createModelNodeFromAsset skips widget assignment).
* selector, so no widget value is assigned when the node is added.
*
* Hierarchical fallback is handled by the store: "a/b/c" tries
* "a/b/c" → "a/b" → "a", so registering a parent directory covers

View File

@@ -1,439 +0,0 @@
// oxlint-disable no-misused-spread
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { markRaw } from 'vue'
import type { Raw } from 'vue'
import type { LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type * as LitegraphModule from '@/lib/litegraph/src/litegraph'
import type * as ModelToNodeStoreModule from '@/stores/modelToNodeStore'
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type * as LitegraphServiceModule from '@/services/litegraphService'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
// Mock dependencies
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`)
}
}))
vi.mock('@/stores/modelToNodeStore', async (importOriginal) => {
const actual = await importOriginal<typeof ModelToNodeStoreModule>()
return {
...actual,
useModelToNodeStore: vi.fn()
}
})
vi.mock(
'@/platform/workflow/management/stores/workflowStore',
async (importOriginal) => {
const actual = await importOriginal<typeof WorkflowStoreModule>()
return {
...actual,
useWorkflowStore: vi.fn()
}
}
)
vi.mock('@/services/litegraphService', async (importOriginal) => {
const actual = await importOriginal<typeof LitegraphServiceModule>()
return {
...actual,
useLitegraphService: vi.fn()
}
})
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = await importOriginal<typeof LitegraphModule>()
return {
...actual,
LiteGraph: {
...actual.LiteGraph,
createNode: vi.fn()
}
}
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
graph: {
add: vi.fn()
}
}
}
}))
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-123',
name: 'test-model.safetensors',
size: 1024,
created_at: '2025-10-01T00:00:00Z',
tags: ['models', 'checkpoints'],
user_metadata: {
filename: 'models/checkpoints/test-model.safetensors'
},
...overrides
}
}
async function createMockNode(overrides?: {
widgetName?: string
widgetValue?: string
hasWidgets?: boolean
}): Promise<LGraphNode> {
const {
widgetName = 'ckpt_name',
widgetValue = '',
hasWidgets = true
} = overrides || {}
const { LGraphNode: ActualLGraphNode } = await vi.importActual<
typeof LitegraphModule
>('@/lib/litegraph/src/litegraph')
if (!hasWidgets) {
return Object.create(ActualLGraphNode.prototype)
}
type Widget = NonNullable<LGraphNode['widgets']>[number]
const widget: Pick<Widget, 'name' | 'value' | 'type' | 'options' | 'y'> = {
name: widgetName,
value: widgetValue,
type: 'string',
options: {},
y: 0
}
return Object.create(ActualLGraphNode.prototype, {
widgets: { value: [widget], writable: true }
})
}
function createMockNodeProvider(
overrides: {
nodeDef?: { name: string; display_name: string }
key?: string
} = {}
) {
return {
nodeDef: {
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
...overrides.nodeDef
},
key: overrides.key ?? 'ckpt_name'
}
}
/**
* Configures all mocked dependencies with sensible defaults.
* Uses semantic parameters for clearer test intent.
* For error paths or edge cases, pass null values or specific overrides.
*/
async function setupMocks(
overrides: {
nodeProvider?: ReturnType<typeof createMockNodeProvider> | null
canvasCenter?: [number, number]
activeSubgraph?: Raw<Subgraph>
createdNode?: Awaited<ReturnType<typeof createMockNode>> | null
} = {}
) {
const {
nodeProvider = createMockNodeProvider(),
canvasCenter = [100, 200],
activeSubgraph,
createdNode = await createMockNode()
} = overrides
vi.mocked(useModelToNodeStore).mockReturnValue({
...useModelToNodeStore(),
getNodeProvider: vi.fn().mockReturnValue(nodeProvider)
})
vi.mocked(useLitegraphService).mockReturnValue({
...useLitegraphService(),
getCanvasCenter: vi.fn().mockReturnValue(canvasCenter)
})
vi.mocked(useWorkflowStore).mockReturnValue({
...useWorkflowStore(),
activeSubgraph,
isSubgraphActive: !!activeSubgraph
})
vi.mocked(LiteGraph.createNode).mockReturnValue(createdNode)
}
describe('createModelNodeFromAsset', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
describe('when creating nodes from valid assets', () => {
it('should create the appropriate loader node for the asset category', async () => {
const asset = createMockAsset()
await setupMocks()
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
if (result.success) {
expect(
vi.mocked(useModelToNodeStore)().getNodeProvider
).toHaveBeenCalledWith('checkpoints')
expect(LiteGraph.createNode).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
'Load Checkpoint',
{ pos: [100, 200] }
)
}
})
it('should place node at canvas center by default', async () => {
const asset = createMockAsset()
await setupMocks({
canvasCenter: [150, 250]
})
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(
vi.mocked(useLitegraphService)().getCanvasCenter
).toHaveBeenCalled()
expect(LiteGraph.createNode).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
'Load Checkpoint',
{ pos: [150, 250] }
)
})
it('should place node at specified position when position is provided', async () => {
const asset = createMockAsset()
await setupMocks()
const result = createModelNodeFromAsset(asset, { position: [300, 400] })
expect(result.success).toBe(true)
expect(
vi.mocked(useLitegraphService)().getCanvasCenter
).not.toHaveBeenCalled()
expect(LiteGraph.createNode).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
'Load Checkpoint',
{ pos: [300, 400] }
)
})
it('should populate the loader widget with the asset file path', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode()
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(mockNode.widgets?.[0].value).toBe(
'models/checkpoints/test-model.safetensors'
)
})
it('should add node to root graph when no subgraph is active', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode()
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(vi.mocked(app).canvas.graph!.add).toHaveBeenCalledWith(mockNode)
})
it('should fallback to asset.metadata.filename when user_metadata.filename missing', async () => {
const asset = createMockAsset({
user_metadata: {},
metadata: { filename: 'models/checkpoints/from-metadata.safetensors' }
})
const mockNode = await createMockNode()
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(mockNode.widgets?.[0].value).toBe(
'models/checkpoints/from-metadata.safetensors'
)
})
it('should fallback to asset.name when both filename sources missing', async () => {
const asset = createMockAsset({
user_metadata: {},
metadata: undefined
})
const mockNode = await createMockNode()
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(mockNode.widgets?.[0].value).toBe('test-model.safetensors')
})
it('should add node to active subgraph when present', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode()
const { Subgraph } = await vi.importActual<typeof LitegraphModule>(
'@/lib/litegraph/src/litegraph'
)
const mockSubgraph = markRaw(
Object.create(Subgraph.prototype, {
add: { value: vi.fn() }
})
)
await setupMocks({
createdNode: mockNode,
activeSubgraph: mockSubgraph
})
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(mockSubgraph.add).toHaveBeenCalledWith(mockNode)
expect(vi.mocked(app).canvas.graph!.add).not.toHaveBeenCalled()
})
it('should succeed when provider has empty key (auto-load nodes)', async () => {
const asset = createMockAsset({
tags: ['models', 'chatterbox/chatterbox_vc'],
user_metadata: { filename: 'chatterbox_vc_model.pt' }
})
const mockNode = await createMockNode({ hasWidgets: false })
const nodeProvider = createMockNodeProvider({
nodeDef: {
name: 'FL_ChatterboxVC',
display_name: 'FL Chatterbox VC'
},
key: ''
})
await setupMocks({ createdNode: mockNode, nodeProvider })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(vi.mocked(app).canvas.graph!.add).toHaveBeenCalledWith(mockNode)
})
})
describe('when asset data is incomplete or invalid', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it.for([
{
case: 'missing user_metadata with no fallback',
overrides: { user_metadata: undefined, metadata: undefined, name: '' },
expectedCode: 'INVALID_ASSET' as const,
errorPattern: /Invalid filename.*expected non-empty string/
},
{
case: 'empty filename with no fallback',
overrides: {
user_metadata: { filename: '' },
metadata: undefined,
name: ''
},
expectedCode: 'INVALID_ASSET' as const,
errorPattern: /Invalid filename.*expected non-empty string/
}
])(
'should fail when asset has $case',
({ overrides, expectedCode, errorPattern }) => {
const asset = createMockAsset(overrides)
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe(expectedCode)
expect(result.error.message).toMatch(errorPattern)
expect(result.error.assetId).toBe('asset-123')
}
}
)
it.for([
{
case: 'no tags',
overrides: { tags: undefined },
expectedCode: 'INVALID_ASSET' as const,
errorMessage: 'Asset has no tags defined'
},
{
case: 'only excluded tags',
overrides: { tags: ['models', 'missing'] },
expectedCode: 'INVALID_ASSET' as const,
errorMessage: 'Asset has no valid category tag'
},
{
case: 'only the models tag',
overrides: { tags: ['models'] },
expectedCode: 'INVALID_ASSET' as const,
errorMessage: 'Asset has no valid category tag'
}
])(
'should fail when asset has $case',
({ overrides, expectedCode, errorMessage }) => {
const asset = createMockAsset(overrides)
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe(expectedCode)
expect(result.error.message).toBe(errorMessage)
}
}
)
})
describe('when system resources are unavailable', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('should fail when no provider registered for category', async () => {
const asset = createMockAsset()
await setupMocks({ nodeProvider: null })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('NO_PROVIDER')
expect(result.error.message).toContain('checkpoints')
expect(result.error.details?.category).toBe('checkpoints')
}
})
it('should fail when node creation fails', async () => {
const asset = createMockAsset()
await setupMocks()
vi.mocked(LiteGraph.createNode).mockReturnValue(null)
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('NODE_CREATION_FAILED')
expect(result.error.message).toContain('CheckpointLoaderSimple')
}
})
it('should fail when widget is missing from node', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode({ widgetName: 'wrong_widget' })
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('MISSING_WIDGET')
expect(result.error.message).toContain('ckpt_name')
expect(result.error.message).toContain('CheckpointLoaderSimple')
expect(result.error.details?.widgetName).toBe('ckpt_name')
}
})
it('should fail when node has no widgets array', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode({ hasWidgets: false })
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('MISSING_WIDGET')
expect(result.error.message).toContain('ckpt_name not found')
}
})
it('should not add node to graph when widget validation fails', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode({ hasWidgets: false })
await setupMocks({ createdNode: mockNode })
createModelNodeFromAsset(asset)
expect(vi.mocked(app).canvas.graph!.add).not.toHaveBeenCalled()
})
})
describe('when graph is null', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(app).canvas.graph = null
})
it('should fail when no graph is available', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode()
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('NO_GRAPH')
expect(result.error.message).toBe('No active graph available')
}
})
})
})

View File

@@ -1,198 +0,0 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
import { assetItemSchema } from '@/platform/assets/schemas/assetSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
MISSING_TAG,
MODELS_TAG
} from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
interface ModelNodeCreateOptions {
position?: Point
}
type NodeCreationErrorCode =
| 'INVALID_ASSET'
| 'NO_PROVIDER'
| 'NODE_CREATION_FAILED'
| 'MISSING_WIDGET'
| 'NO_GRAPH'
interface NodeCreationError {
code: NodeCreationErrorCode
message: string
assetId: string
details?: Record<string, unknown>
}
type Result<T, E> = { success: true; value: T } | { success: false; error: E }
/**
* Creates a LiteGraph node from an asset item.
*
* **Boundary Function**: Bridges Vue reactive domain with LiteGraph canvas domain.
*
* @param asset - Asset item to create node from (Vue domain)
* @param options - Optional position and configuration
* @returns Result with LiteGraph node (Canvas domain) or error details
*
* @remarks
* This function performs side effects on the canvas graph. Validation failures
* return error results rather than throwing to allow graceful degradation in UI contexts.
* Widget validation occurs before graph mutation to prevent orphaned nodes.
*/
export function createModelNodeFromAsset(
asset: AssetItem,
options?: ModelNodeCreateOptions
): Result<LGraphNode, NodeCreationError> {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
const errorMessage = validatedAsset.error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ')
console.error('Invalid asset item:', errorMessage)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset schema validation failed',
assetId: asset.id,
details: { validationErrors: errorMessage }
}
}
}
const validAsset = validatedAsset.data
const filename = getAssetFilename(validAsset)
if (filename.length === 0) {
console.error(
`Asset ${validAsset.id} has invalid user_metadata.filename (expected non-empty string, got ${typeof filename})`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: `Invalid filename (expected non-empty string, got ${typeof filename})`,
assetId: validAsset.id
}
}
}
if (validAsset.tags.length === 0) {
console.error(
`Asset ${validAsset.id} has no tags defined (expected at least one category tag)`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset has no tags defined',
assetId: validAsset.id
}
}
}
const category = validAsset.tags.find(
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
)
if (!category) {
console.error(
`Asset ${validAsset.id} has no valid category tag. Available tags: ${validAsset.tags.join(', ')} (expected tag other than '${MODELS_TAG}' or '${MISSING_TAG}')`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset has no valid category tag',
assetId: validAsset.id,
details: { availableTags: validAsset.tags }
}
}
}
const modelToNodeStore = useModelToNodeStore()
const provider = modelToNodeStore.getNodeProvider(category)
if (!provider) {
console.error(`No node provider registered for category: ${category}`)
return {
success: false,
error: {
code: 'NO_PROVIDER',
message: `No node provider registered for category: ${category}`,
assetId: validAsset.id,
details: { category }
}
}
}
const litegraphService = useLitegraphService()
const pos = options?.position ?? litegraphService.getCanvasCenter()
const node = LiteGraph.createNode(
provider.nodeDef.name,
provider.nodeDef.display_name,
{ pos }
)
if (!node) {
console.error(`Failed to create node for type: ${provider.nodeDef.name}`)
return {
success: false,
error: {
code: 'NODE_CREATION_FAILED',
message: `Failed to create node for type: ${provider.nodeDef.name}`,
assetId: validAsset.id,
details: { nodeType: provider.nodeDef.name }
}
}
}
const workflowStore = useWorkflowStore()
const targetGraph = workflowStore.isSubgraphActive
? workflowStore.activeSubgraph
: app.canvas.graph
if (!targetGraph) {
console.error('No active graph available')
return {
success: false,
error: {
code: 'NO_GRAPH',
message: 'No active graph available',
assetId: validAsset.id
}
}
}
// Set widget value if provider specifies a key (some nodes auto-load models without a widget)
if (provider.key) {
const widget = node.widgets?.find((w) => w.name === provider.key)
if (!widget) {
console.error(
`Widget ${provider.key} not found on node ${provider.nodeDef.name}`
)
return {
success: false,
error: {
code: 'MISSING_WIDGET',
message: `Widget ${provider.key} not found on node ${provider.nodeDef.name}`,
assetId: validAsset.id,
details: { widgetName: provider.key, nodeType: provider.nodeDef.name }
}
}
}
widget.value = filename
}
// Add the node to the graph
targetGraph.add(node)
return { success: true, value: node }
}

View File

@@ -0,0 +1,208 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
const mockGetNodeProvider = vi.hoisted(() => vi.fn())
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
}))
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-123',
name: 'test-model.safetensors',
size: 1024,
created_at: '2025-10-01T00:00:00Z',
tags: ['models', 'checkpoints'],
user_metadata: {
filename: 'models/checkpoints/test-model.safetensors'
},
...overrides
}
}
function createMockNodeProvider(
overrides: {
nodeDef?: { name: string; display_name: string }
key?: string
} = {}
) {
return {
nodeDef: {
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
...overrides.nodeDef
},
key: overrides.key ?? 'ckpt_name'
}
}
function mockProvider(
provider: ReturnType<typeof createMockNodeProvider> | null
) {
mockGetNodeProvider.mockReturnValue(provider)
}
describe('resolveModelNodeFromAsset', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
describe('valid assets', () => {
it('resolves the provider for the asset category and the filename', () => {
mockProvider(createMockNodeProvider())
const result = resolveModelNodeFromAsset(createMockAsset())
expect(result.success).toBe(true)
if (result.success) {
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
expect(result.value.provider.nodeDef.name).toBe(
'CheckpointLoaderSimple'
)
expect(result.value.filename).toBe(
'models/checkpoints/test-model.safetensors'
)
}
})
it('falls back to metadata.filename when user_metadata.filename missing', () => {
mockProvider(createMockNodeProvider())
const result = resolveModelNodeFromAsset(
createMockAsset({
user_metadata: {},
metadata: { filename: 'models/checkpoints/from-metadata.safetensors' }
})
)
expect(result.success).toBe(true)
if (result.success) {
expect(result.value.filename).toBe(
'models/checkpoints/from-metadata.safetensors'
)
}
})
it('falls back to asset.name when both filename sources missing', () => {
mockProvider(createMockNodeProvider())
const result = resolveModelNodeFromAsset(
createMockAsset({ user_metadata: {}, metadata: undefined })
)
expect(result.success).toBe(true)
if (result.success) {
expect(result.value.filename).toBe('test-model.safetensors')
}
})
it('resolves a provider with an empty key (auto-load nodes)', () => {
mockProvider(
createMockNodeProvider({
nodeDef: {
name: 'FL_ChatterboxVC',
display_name: 'FL Chatterbox VC'
},
key: ''
})
)
const result = resolveModelNodeFromAsset(
createMockAsset({
tags: ['models', 'chatterbox/chatterbox_vc'],
user_metadata: { filename: 'chatterbox_vc_model.pt' }
})
)
expect(result.success).toBe(true)
if (result.success) {
expect(result.value.provider.key).toBe('')
expect(result.value.filename).toBe('chatterbox_vc_model.pt')
}
})
})
describe('invalid assets', () => {
it('fails when the asset does not match the schema', () => {
const invalid = {
id: 'asset-123',
tags: ['models', 'checkpoints']
} as unknown as AssetItem
const result = resolveModelNodeFromAsset(invalid)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('INVALID_ASSET')
expect(result.error.message).toBe('Asset schema validation failed')
expect(result.error.assetId).toBe('asset-123')
expect(result.error.details?.validationErrors).toBeTruthy()
}
})
it.for([
{
case: 'missing user_metadata with no fallback',
overrides: {
user_metadata: undefined,
metadata: undefined,
name: ''
},
errorPattern: /Invalid filename.*expected non-empty string/
},
{
case: 'empty filename with no fallback',
overrides: {
user_metadata: { filename: '' },
metadata: undefined,
name: ''
},
errorPattern: /Invalid filename.*expected non-empty string/
}
])('fails when asset has $case', ({ overrides, errorPattern }) => {
const result = resolveModelNodeFromAsset(createMockAsset(overrides))
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('INVALID_ASSET')
expect(result.error.message).toMatch(errorPattern)
expect(result.error.assetId).toBe('asset-123')
}
})
it.for([
{
case: 'no tags',
overrides: { tags: undefined },
message: 'Asset has no tags defined'
},
{
case: 'only excluded tags',
overrides: { tags: ['models', 'missing'] },
message: 'Asset has no valid category tag'
},
{
case: 'only the models tag',
overrides: { tags: ['models'] },
message: 'Asset has no valid category tag'
}
])('fails when asset has $case', ({ overrides, message }) => {
const result = resolveModelNodeFromAsset(createMockAsset(overrides))
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('INVALID_ASSET')
expect(result.error.message).toBe(message)
}
})
it('fails when no provider is registered for the category', () => {
mockProvider(null)
const result = resolveModelNodeFromAsset(createMockAsset())
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('NO_PROVIDER')
expect(result.error.message).toContain('checkpoints')
expect(result.error.details?.category).toBe('checkpoints')
}
})
})
})

View File

@@ -0,0 +1,117 @@
import { assetItemSchema } from '@/platform/assets/schemas/assetSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
MISSING_TAG,
MODELS_TAG
} from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
type ResolveErrorCode = 'INVALID_ASSET' | 'NO_PROVIDER'
export interface ResolveModelNodeError {
code: ResolveErrorCode
message: string
assetId: string
details?: Record<string, unknown>
}
export interface ResolvedModelNode {
provider: ModelNodeProvider
filename: string
}
type Result<T, E> = { success: true; value: T } | { success: false; error: E }
/**
* Resolves an asset item to the node provider and filename needed to add a
* model loader node. Validation failures return error results rather than
* throwing, so callers can degrade gracefully in UI contexts.
*/
export function resolveModelNodeFromAsset(
asset: AssetItem
): Result<ResolvedModelNode, ResolveModelNodeError> {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
const errorMessage = validatedAsset.error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ')
console.error('Invalid asset item:', errorMessage)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset schema validation failed',
assetId: typeof asset?.id === 'string' ? asset.id : 'unknown',
details: { validationErrors: errorMessage }
}
}
}
const validAsset = validatedAsset.data
const filename = getAssetFilename(validAsset)
if (filename.length === 0) {
console.error(
`Asset ${validAsset.id} has invalid user_metadata.filename (expected non-empty string, got ${typeof filename})`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: `Invalid filename (expected non-empty string, got ${typeof filename})`,
assetId: validAsset.id
}
}
}
if (validAsset.tags.length === 0) {
console.error(
`Asset ${validAsset.id} has no tags defined (expected at least one category tag)`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset has no tags defined',
assetId: validAsset.id
}
}
}
const category = validAsset.tags.find(
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
)
if (!category) {
console.error(
`Asset ${validAsset.id} has no valid category tag. Available tags: ${validAsset.tags.join(', ')} (expected tag other than '${MODELS_TAG}' or '${MISSING_TAG}')`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset has no valid category tag',
assetId: validAsset.id,
details: { availableTags: validAsset.tags }
}
}
}
const provider = useModelToNodeStore().getNodeProvider(category)
if (!provider) {
console.error(`No node provider registered for category: ${category}`)
return {
success: false,
error: {
code: 'NO_PROVIDER',
message: `No node provider registered for category: ${category}`,
assetId: validAsset.id,
details: { category }
}
}
}
return { success: true, value: { provider, filename } }
}

View File

@@ -1,10 +1,14 @@
import { computed, ref } from 'vue'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import type {
Plan,
TeamCreditStops
} from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
const plans = ref<Plan[]>([])
const currentPlanSlug = ref<string | null>(null)
const teamCreditStops = ref<TeamCreditStops | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
@@ -19,6 +23,7 @@ export function useBillingPlans() {
const response = await workspaceApi.getBillingPlans()
plans.value = response.plans
currentPlanSlug.value = response.current_plan_slug ?? null
teamCreditStops.value = response.team_credit_stops ?? null
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch plans'
console.error('[useBillingPlans] Failed to fetch plans:', err)
@@ -48,6 +53,7 @@ export function useBillingPlans() {
return {
plans,
currentPlanSlug,
teamCreditStops,
isLoading,
error,
monthlyPlans,

View File

@@ -9,6 +9,8 @@ const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
const mockCanManageSubscription = vi.hoisted(() => ({ value: true }))
vi.mock('vue', async (importOriginal) => {
const actual = await importOriginal()
@@ -61,6 +63,22 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isLegacyTeamPlan: mockIsLegacyTeamPlan
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
get value() {
return { canManageSubscription: mockCanManageSubscription.value }
}
}
})
}))
describe('useSubscriptionDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -68,6 +86,8 @@ describe('useSubscriptionDialog', () => {
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockTeamWorkspacesEnabled.value = false
mockIsLegacyTeamPlan.value = false
mockCanManageSubscription.value = true
try {
sessionStorage.clear()
@@ -94,6 +114,82 @@ describe('useSubscriptionDialog', () => {
expect(mockShowLayoutDialog).toHaveBeenCalled()
})
it('uses the unified table (no onChooseTeam) when team workspaces are enabled', () => {
mockTeamWorkspacesEnabled.value = true
// Unified table is workspace-type-agnostic (Jun-5 model): same path for
// a personal-plan or team-plan workspace.
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props).not.toHaveProperty('onChooseTeam')
})
it('defaults to the personal tab in a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props.initialPlanMode).toBe('personal')
})
it('opens the team tab when planMode is forced from a personal workspace', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ planMode: 'team' })
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props.initialPlanMode).toBe('team')
})
it('uses the legacy table (with onChooseTeam) when team workspaces are disabled', () => {
mockTeamWorkspacesEnabled.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props).toHaveProperty('onChooseTeam')
})
it('routes an existing per-member (legacy) team subscriber to the old team table', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = false
mockIsLegacyTeamPlan.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
const props = mockShowLayoutDialog.mock.calls[0][0].props
// The legacy team dialog takes onClose + reason and none of the unified
// props. `reason` separates it from the read-only member dialog (onClose
// only); the absent initialPlanMode separates it from the unified table.
expect(props).toHaveProperty('reason')
expect(props).not.toHaveProperty('initialPlanMode')
expect(props).not.toHaveProperty('onChooseTeam')
})
it('keeps a non-legacy (credit-slider) team subscriber on the unified table', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = false
mockIsLegacyTeamPlan.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props.initialPlanMode).toBe('team')
})
})
describe('startTeamWorkspaceUpgradeFlow', () => {

View File

@@ -1,6 +1,7 @@
import { defineAsyncComponent } from 'vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
@@ -16,6 +17,16 @@ export type SubscriptionDialogReason =
| 'out_of_credits'
| 'top_up_blocked'
export interface SubscriptionDialogOptions {
reason?: SubscriptionDialogReason
/**
* Forces the unified pricing dialog to open on a specific plan tab,
* overriding the workspace-derived default (e.g. an "Upgrade to Team" CTA
* always lands on the team tab even from a personal workspace).
*/
planMode?: 'personal' | 'team'
}
export const useSubscriptionDialog = () => {
const { flags } = useFeatureFlags()
const dialogService = useDialogService()
@@ -29,7 +40,7 @@ export const useSubscriptionDialog = () => {
dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY })
}
function showPricingTable(options?: { reason?: SubscriptionDialogReason }) {
function showPricingTable(options?: SubscriptionDialogOptions) {
if (!isCloud) return
// Members can't manage the workspace subscription, so a blocked run shows a
@@ -57,47 +68,100 @@ export const useSubscriptionDialog = () => {
return
}
const useWorkspaceVariant =
flags.teamWorkspacesEnabled && !workspaceStore.isInPersonalWorkspace
const component = useWorkspaceVariant
? defineAsyncComponent(
() =>
import('@/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue')
)
: defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue')
)
const personalProps = {
onClose: hide,
reason: options?.reason,
onChooseTeam: () => startTeamWorkspaceUpgradeFlow()
// Shared dialog shell styling for both variants.
const dialogComponentProps = {
style: 'width: min(1328px, 95vw); max-height: 958px;',
pt: {
root: {
class: 'rounded-2xl bg-transparent h-full'
},
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)] h-full'
}
}
}
const workspaceProps = {
onClose: hide,
reason: options?.reason
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
// one workspace) when team workspaces are enabled. Replaces the old
// personal-vs-team workspace fork. Flag-off keeps the legacy table.
if (flags.teamWorkspacesEnabled) {
// Existing per-member (legacy) team subscribers keep the old tier-based
// team table; the unified credit-slider table is for everyone else.
// Resolved lazily (not at composable setup): these three composables form
// an import cycle (useBillingContext -> useWorkspaceBilling ->
// useSubscriptionDialog), so a setup-time read would deref the shared
// context before its state is constructed.
const { isLegacyTeamPlan } = useBillingContext()
if (isLegacyTeamPlan.value) {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue')
),
props: {
onClose: hide,
reason: options?.reason
},
// The legacy table hosts a PrimeVue Popover teleported to body; Reka
// modal mode traps focus and disables body pointer-events, making it
// unclickable. The unified table has no such overlay.
dialogComponentProps: { ...dialogComponentProps, modal: false }
})
return
}
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/SubscriptionRequiredDialogContentUnified.vue')
),
props: {
onClose: hide,
reason: options?.reason,
// A team workspace lands on the For Teams tab; personal on For
// Personal. An explicit caller (e.g. an "Upgrade to Team" CTA) can
// override via options.planMode.
initialPlanMode:
options?.planMode ??
(workspaceStore.isInPersonalWorkspace ? 'personal' : 'team')
},
dialogComponentProps: {
// The dialog hugs its content so each step sizes itself: the pricing
// table stays wide/fixed (cards fill it, DES QA 2026-06-13) while the
// compact confirm/success steps shrink instead of floating in the big
// pricing modal. Sizes are set on the content root per checkoutStep.
style: 'max-width: 95vw; max-height: 90vh;',
pt: {
root: { class: 'rounded-2xl bg-transparent' },
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
}
}
})
return
}
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component,
props: useWorkspaceVariant ? workspaceProps : personalProps,
dialogComponentProps: {
renderer: 'reka',
size: 'full',
// The pricing tables host a PrimeVue Popover teleported to body.
// Reka's modal mode traps focus and disables body pointer-events,
// making the popover unclickable. Mirrors Settings/Manager.
modal: false,
contentClass:
'w-[min(1328px,95vw)] max-w-[min(1328px,95vw)] sm:max-w-[min(1328px,95vw)] h-full max-h-[958px] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
}
component: defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue')
),
props: {
onClose: hide,
reason: options?.reason,
onChooseTeam: () => startTeamWorkspaceUpgradeFlow()
},
dialogComponentProps
})
}
function show(options?: { reason?: SubscriptionDialogReason }) {
function show(options?: SubscriptionDialogOptions) {
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
const component = defineAsyncComponent(
() =>

View File

@@ -6,14 +6,25 @@ export interface CreditStop {
/**
* Yearly-commitment discount applied to `usd`, as a whole-number percent.
* Threshold-based per the pricing decision (Slack — Alex Tov, 2026-05-08):
* yearly tiers are 0 / 5 / 10 / 15 / 20% with nothing in between (monthly is
* halved, but still being iterated). Only the $700 → 10% tier is
* design-confirmed (DES-197 shows "Save 10% ($70)"); the rest follow the
* agreed 0/5/10/15/20 sequence and should be re-confirmed with design/BE.
* yearly tiers are 0 / 5 / 10 / 15 / 20% with nothing in between.
* Monthly halves these (0 / 2.5 / 5 / 7.5 / 10%) — confirmed in PRD: GA Team
* Billing ("for monthly the discount is halved"). `CreditSlider` derives the
* monthly value from this field via its `cycle` prop, so only the yearly
* tiers are stored here.
*/
discountPercentYearly: number
}
/** A selected slider stop, as emitted by the pricing table's team column. */
export interface TeamPlanSelection {
/** Pre-discount monthly price in USD (the struck-through list price). */
usd: number
/** Monthly credit grant at this stop. */
credits: number
/** Cycle-adjusted discounted monthly price in USD — what the user actually pays. */
discountedUsd: number
}
/**
* Team-plan credit-subscription slider stops.
*
@@ -37,3 +48,22 @@ export const TEAM_PLAN_CREDIT_STOPS: readonly CreditStop[] = [
/** Default stop per DES-197: index 2 = $700 / 147,700 credits. */
export const DEFAULT_TEAM_PLAN_STOP_INDEX = 2
/**
* Discounted monthly price for a stop's list `usd`, applying the billing-cycle
* discount (yearly = full `discountPercentYearly`; monthly halves it). Shared by
* the slider display and the checkout confirm step so the two never drift.
* Falls back to the list price when `usd` is not a known stop.
*/
export function getDiscountedMonthlyUsd(
usd: number,
cycle: 'monthly' | 'yearly'
): number {
const stop = TEAM_PLAN_CREDIT_STOPS.find((s) => s.usd === usd)
if (!stop) return usd
const percent =
cycle === 'monthly'
? stop.discountPercentYearly / 2
: stop.discountPercentYearly
return Math.round(usd * (1 - percent / 100))
}

View File

@@ -1,83 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [0, 0],
"size": [100, 100],
"flags": {},
"order": 1,
"mode": 0,
"properties": {},
"widgets_values": [0, "randomize", 20]
},
{
"id": 2,
"type": "subgraph-x",
"pos": [300, 0],
"size": [100, 100],
"flags": {},
"order": 0,
"mode": 0,
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
"definitions": {
"subgraphs": [
{
"id": "subgraph-x",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "x",
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [0, 0],
"size": [100, 100],
"flags": {},
"order": 0,
"mode": 0,
"properties": {
"models": [
{
"name": "rare_model.safetensors",
"directory": "checkpoints"
}
]
},
"widgets_values": ["some_other_model.safetensors"]
}
],
"links": [],
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
"inputs": [],
"outputs": [],
"widgets": []
}
]
},
"models": [
{
"name": "rare_model.safetensors",
"url": "https://example.com/rare",
"directory": "checkpoints"
}
]
}

View File

@@ -1,83 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [0, 0],
"size": [100, 100],
"flags": {},
"order": 1,
"mode": 0,
"properties": {},
"widgets_values": [0, "randomize", 20]
},
{
"id": 2,
"type": "subgraph-x",
"pos": [300, 0],
"size": [100, 100],
"flags": {},
"order": 0,
"mode": 4,
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
"definitions": {
"subgraphs": [
{
"id": "subgraph-x",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "x",
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [0, 0],
"size": [100, 100],
"flags": {},
"order": 0,
"mode": 0,
"properties": {
"models": [
{
"name": "rare_model.safetensors",
"directory": "checkpoints"
}
]
},
"widgets_values": ["some_other_model.safetensors"]
}
],
"links": [],
"inputNode": { "id": -10, "bounding": [0, 0, 0, 0] },
"outputNode": { "id": -20, "bounding": [0, 0, 0, 0] },
"inputs": [],
"outputs": [],
"widgets": []
}
]
},
"models": [
{
"name": "rare_model.safetensors",
"url": "https://example.com/rare",
"directory": "checkpoints"
}
]
}

View File

@@ -34,10 +34,6 @@ const { mockHandles } = vi.hoisted(() => {
executionErrorStore: {
surfaceMissingModels: vi.fn()
},
modelStore: {
loadModelFolders: vi.fn(),
getLoadedModelFolder: vi.fn()
},
modelToNodeStore: {
getCategoryForNodeType: vi.fn()
},
@@ -49,14 +45,9 @@ const { mockHandles } = vi.hoisted(() => {
): MissingModelCandidate[] => []
),
enrichWithEmbeddedMetadata: vi.fn(
async (
(
_candidates: readonly MissingModelCandidate[],
_graphData: ComfyWorkflowJSON,
_checkModelInstalled: (
name: string,
directory: string
) => Promise<boolean>,
_isAssetSupported?: (nodeType: string, widgetName: string) => boolean
_graphData: ComfyWorkflowJSON
) => state.enrichedCandidates
),
verifyAssetSupportedCandidates: vi.fn(
@@ -104,10 +95,6 @@ vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => mockHandles.executionErrorStore
}))
vi.mock('@/stores/modelStore', () => ({
useModelStore: () => mockHandles.modelStore
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => mockHandles.modelToNodeStore
}))
@@ -121,16 +108,8 @@ vi.mock('@/platform/missingModel/missingModelScan', () => ({
mockHandles.scanAllModelCandidates(graph, isAssetSupported, getDirectory),
enrichWithEmbeddedMetadata: (
candidates: readonly MissingModelCandidate[],
graphData: ComfyWorkflowJSON,
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
) =>
mockHandles.enrichWithEmbeddedMetadata(
candidates,
graphData,
checkModelInstalled,
isAssetSupported
),
graphData: ComfyWorkflowJSON
) => mockHandles.enrichWithEmbeddedMetadata(candidates, graphData),
verifyAssetSupportedCandidates: (
candidates: readonly MissingModelCandidate[],
signal: AbortSignal
@@ -186,8 +165,6 @@ describe('missingModelPipeline', () => {
mockHandles.missingModelStore.createVerificationAbortController.mockImplementation(
() => new AbortController()
)
mockHandles.modelStore.loadModelFolders.mockResolvedValue(undefined)
mockHandles.modelStore.getLoadedModelFolder.mockResolvedValue(undefined)
mockHandles.modelToNodeStore.getCategoryForNodeType.mockReturnValue(
undefined
)
@@ -253,9 +230,7 @@ describe('missingModelPipeline', () => {
expect(mockHandles.enrichWithEmbeddedMetadata).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({ models: activeModels }),
expect.any(Function),
undefined
expect.objectContaining({ models: activeModels })
)
expect(
mockHandles.executionErrorStore.surfaceMissingModels
@@ -305,9 +280,7 @@ describe('missingModelPipeline', () => {
hash_type: 'sha256'
}
]
}),
expect.any(Function),
undefined
})
)
expect(
mockHandles.executionErrorStore.surfaceMissingModels
@@ -325,9 +298,7 @@ describe('missingModelPipeline', () => {
expect(mockHandles.enrichWithEmbeddedMetadata).toHaveBeenCalledWith(
expect.any(Array),
graphData,
expect.any(Function),
undefined
graphData
)
})

View File

@@ -15,7 +15,6 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyW
import type { ModelFile } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useModelStore } from '@/stores/modelStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { MissingNodeType } from '@/types/comfy'
@@ -121,20 +120,7 @@ export async function runMissingModelPipeline({
getDirectory
)
const modelStore = useModelStore()
await modelStore.loadModelFolders()
const enrichedAll = await enrichWithEmbeddedMetadata(
candidates,
graphData,
async (name, directory) => {
const folder = await modelStore.getLoadedModelFolder(directory)
const models = folder?.models
return !!(
models && Object.values(models).some((m) => m.file_name === name)
)
},
isCloud ? isAssetBrowserWidget : undefined
)
const enrichedAll = enrichWithEmbeddedMetadata(candidates, graphData)
// Drop candidates whose enclosing subgraph is muted/bypassed. Per-node
// scans only checked each node's own mode; the cascade from an

View File

@@ -15,8 +15,6 @@ import {
verifyAssetSupportedCandidates,
MODEL_FILE_EXTENSIONS
} from '@/platform/missingModel/missingModelScan'
import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/activeSubgraphUnmatchedModel.json' with { type: 'json' }
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
@@ -671,11 +669,8 @@ function makeCandidate(
}
}
const alwaysMissing = async () => false
const alwaysInstalled = async () => true
describe('enrichWithEmbeddedMetadata', () => {
it('enriches existing candidate with url and directory from embedded metadata', async () => {
it('enriches existing candidate with url and directory from embedded metadata', () => {
const candidates = [makeCandidate('model_a.safetensors')]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
@@ -709,18 +704,14 @@ describe('enrichWithEmbeddedMetadata', () => {
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result[0].url).toBe('https://example.com/model_a')
expect(result[0].directory).toBe('checkpoints')
expect(result[0].hash).toBe('abc123')
})
it('does not overwrite existing fields on candidate', async () => {
it('does not overwrite existing fields on candidate', () => {
const candidates = [
makeCandidate('model_a.safetensors', {
directory: 'existing_dir',
@@ -757,18 +748,13 @@ describe('enrichWithEmbeddedMetadata', () => {
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
// ??= should not overwrite existing values
expect(result[0].url).toBe('https://existing.com')
expect(result[0].directory).toBe('existing_dir')
})
it('does not mutate the original candidates array', async () => {
it('does not mutate the original candidates array', () => {
const candidates = [makeCandidate('model_a.safetensors')]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
@@ -801,12 +787,12 @@ describe('enrichWithEmbeddedMetadata', () => {
})
const originalUrl = candidates[0].url
await enrichWithEmbeddedMetadata(candidates, graphData, alwaysMissing)
enrichWithEmbeddedMetadata(candidates, graphData)
expect(candidates[0].url).toBe(originalUrl)
})
it('adds new candidate for embedded model not found by COMBO scan', async () => {
it('does not add a candidate for embedded metadata without a live candidate', () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
@@ -838,18 +824,12 @@ describe('enrichWithEmbeddedMetadata', () => {
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('model_a.safetensors')
expect(result[0].isMissing).toBe(true)
expect(result).toHaveLength(0)
})
it('does not add candidate when model is already installed', async () => {
it('does not add a candidate from root metadata without live references', () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 0,
@@ -869,117 +849,12 @@ describe('enrichWithEmbeddedMetadata', () => {
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysInstalled
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result).toHaveLength(0)
})
it('skips embedded models from muted nodes', async () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 2, // NEVER (muted)
properties: {},
widgets_values: { ckpt_name: 'model.safetensors' }
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result).toHaveLength(0)
})
it('drops workflow-level model entries when only referencing nodes are bypassed (other active nodes present)', async () => {
// Regression: a previous `hasActiveNodes` check kept workflow-level
// models in a mixed graph if ANY active node existed, even when every
// node that actually referenced the model was bypassed. The correct
// check drops unmatched workflow-level entries since candidates are
// derived from active-node widgets.
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 2,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 4, // BYPASS — only node referencing the model
properties: {},
widgets_values: { ckpt_name: 'model.safetensors' }
},
{
id: 2,
type: 'KSampler',
pos: [200, 0],
size: [100, 100],
flags: {},
order: 1,
mode: 0, // ALWAYS — unrelated active node
properties: {},
widgets_values: {}
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result).toHaveLength(0)
})
it('keeps unmatched node-sourced entries in a mixed graph', async () => {
// A node-sourced unmatched entry (sourceNodeType !== '') must survive
// the workflow-level filter. This ensures the simplification does not
// over-filter legitimate per-node missing models.
it('enriches existing candidates from node-sourced metadata', () => {
const candidates = [
makeCandidate('node_model.safetensors', { nodeId: '1' })
]
@@ -1015,18 +890,14 @@ describe('enrichWithEmbeddedMetadata', () => {
models: []
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('node_model.safetensors')
expect(result[0].url).toBe('https://example.com/node_model')
})
it('skips embedded models from bypassed nodes', async () => {
const candidates: MissingModelCandidate[] = []
it('does not enrich from muted node metadata', () => {
const candidates = [makeCandidate('model.safetensors')]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
@@ -1038,8 +909,16 @@ describe('enrichWithEmbeddedMetadata', () => {
size: [100, 100],
flags: {},
order: 0,
mode: 4, // BYPASS
properties: {},
mode: 2,
properties: {
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
},
widgets_values: { ckpt_name: 'model.safetensors' }
}
],
@@ -1048,58 +927,152 @@ describe('enrichWithEmbeddedMetadata', () => {
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
models: []
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result).toHaveLength(0)
expect(result[0].url).toBeUndefined()
})
it('drops workflow-level entries when only reference is in a bypassed subgraph interior', async () => {
// Interior properties.models references the workflow-level model
// but its widget value does not — forcing the workflow-level entry
// down the unmatched path where isModelReferencedByActiveNode
// decides. Previously the helper ignored the bypassed container.
const result = await enrichWithEmbeddedMetadata(
[],
fromAny<ComfyWorkflowJSON, unknown>(bypassedSubgraphUnmatchedModel),
alwaysMissing
)
it('does not enrich from bypassed node metadata', () => {
const candidates = [makeCandidate('model.safetensors')]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 4,
properties: {
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
},
widgets_values: { ckpt_name: 'model.safetensors' }
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: []
})
expect(result).toHaveLength(0)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result[0].url).toBeUndefined()
})
it('keeps workflow-level entries when reference is in an active subgraph interior', async () => {
// Positive control for the bypassed case above: identical fixture
// with container mode=0 must still surface the unmatched workflow-
// level model. Guards against a regression where the ancestor gate
// drops every workflow-level entry regardless of context.
const result = await enrichWithEmbeddedMetadata(
[],
fromAny<ComfyWorkflowJSON, unknown>(activeSubgraphUnmatchedModel),
alwaysMissing
)
it.for([
{ state: 'muted', ancestorMode: 2 },
{ state: 'bypassed', ancestorMode: 4 }
])(
'does not enrich from metadata inside a $state ancestor subgraph',
({ ancestorMode }) => {
const candidates = [
makeCandidate('shared_model.safetensors', {
directory: 'checkpoints'
})
]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 2,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
properties: {},
widgets_values: { ckpt_name: 'shared_model.safetensors' }
},
{
id: 2,
type: 'InactiveSubgraph',
pos: [200, 0],
size: [100, 100],
flags: {},
order: 1,
mode: ancestorMode,
properties: {},
widgets_values: {}
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [],
definitions: {
subgraphs: [
{
id: 'InactiveSubgraph',
name: 'InactiveSubgraph',
nodes: [
{
id: 10,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
properties: {
models: [
{
name: 'shared_model.safetensors',
url: 'https://example.com/inactive-branch',
directory: 'checkpoints',
hash: 'inactive-hash',
hash_type: 'sha256'
}
]
},
widgets_values: {
ckpt_name: 'shared_model.safetensors'
}
}
],
links: [],
groups: [],
config: {},
extra: {},
inputNode: {},
outputNode: {}
}
]
}
})
expect(result).toHaveLength(1)
expect(result[0].name).toBe('rare_model.safetensors')
})
const result = enrichWithEmbeddedMetadata(candidates, graphData)
it('drops workflow-level entries when interior reference is under a different directory', async () => {
// Same name, different directory: the interior's properties.models
// entry is not the same asset as the workflow-level entry, so the
// fallback helper must not treat it as a reference that keeps the
// workflow-level model alive.
expect(result[0].url).toBeUndefined()
expect(result[0].hash).toBeUndefined()
expect(result[0].hashType).toBeUndefined()
}
)
it('does not enrich candidates from different-directory metadata', () => {
const candidates = [
makeCandidate('collide_model.safetensors', {
directory: 'checkpoints'
})
]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
@@ -1132,43 +1105,19 @@ describe('enrichWithEmbeddedMetadata', () => {
{
name: 'collide_model.safetensors',
url: 'https://example.com/collide',
directory: 'checkpoints'
directory: 'loras'
}
]
})
const result = await enrichWithEmbeddedMetadata(
[],
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result).toHaveLength(0)
expect(result[0].url).toBeUndefined()
})
})
describe('OSS missing model detection (non-Cloud path)', () => {
it('scanAllModelCandidates returns empty array when not called (simulating isCloud === false guard)', () => {
// In the app, when isCloud is false, scanAllModelCandidates is not called
// and an empty array is used instead. This test verifies the OSS path
// starts with an empty candidates list.
const isCloud = false
const graph = makeGraph([
makeNode(1, 'CheckpointLoaderSimple', [
makeComboWidget('ckpt_name', 'missing_model.safetensors', [])
])
])
const modelCandidates = isCloud
? scanAllModelCandidates(graph, noAssetSupport)
: []
expect(modelCandidates).toEqual([])
})
it('enrichWithEmbeddedMetadata detects missing embedded models without prior COMBO scan (OSS dialog path)', async () => {
// OSS path: candidates start empty, enrichWithEmbeddedMetadata adds
// missing embedded models so the dialog can show them.
it('does not detect embedded models without prior candidate scan', () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 2,
@@ -1216,67 +1165,15 @@ describe('OSS missing model detection (non-Cloud path)', () => {
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
expect(result).toHaveLength(2)
expect(result.every((c) => c.isMissing === true)).toBe(true)
expect(result.map((c) => c.name)).toEqual([
'sd_xl_base_1.0.safetensors',
'detail_enhancer.safetensors'
])
expect(result).toHaveLength(0)
})
it('enrichWithEmbeddedMetadata sets isMissing=true when isAssetSupported is not provided (OSS)', async () => {
// When isAssetSupported is omitted (OSS), unmatched embedded models
// should have isMissing=true (not undefined), enabling the dialog.
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
properties: {},
widgets_values: { ckpt_name: 'missing_model.safetensors' }
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'missing_model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result).toHaveLength(1)
expect(result[0].isMissing).toBe(true)
expect(result[0].isAssetSupported).toBe(false)
})
it('enrichWithEmbeddedMetadata correctly filters for dialog: only isMissing=true with url', async () => {
const candidates: MissingModelCandidate[] = []
it('enriches live OSS candidates for dialog filtering', () => {
const candidates: MissingModelCandidate[] = [
makeCandidate('missing_model.safetensors')
]
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
@@ -1312,64 +1209,13 @@ describe('OSS missing model detection (non-Cloud path)', () => {
]
})
const selectiveInstallCheck = async (name: string) =>
name === 'installed_model.safetensors'
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
selectiveInstallCheck
)
const result = enrichWithEmbeddedMetadata(candidates, graphData)
const dialogModels = result.filter((c) => c.isMissing === true && c.url)
expect(dialogModels).toHaveLength(1)
expect(dialogModels[0].name).toBe('missing_model.safetensors')
expect(dialogModels[0].url).toBe('https://example.com/model')
})
it('enrichWithEmbeddedMetadata with isAssetSupported leaves isMissing undefined for asset-supported models (Cloud path)', async () => {
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 1,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 0,
properties: {},
widgets_values: { ckpt_name: 'model.safetensors' }
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing,
() => true
)
expect(result).toHaveLength(1)
expect(result[0].isMissing).toBeUndefined()
expect(result[0].isAssetSupported).toBe(true)
})
})
const { mockUpdateModelsForNodeType, mockGetAssets } = vi.hoisted(() => ({

View File

@@ -1,11 +1,7 @@
import type { ModelFile } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { FlattenableWorkflowGraph } from '@/platform/workflow/core/utils/workflowFlattening'
import { flattenWorkflowNodes } from '@/platform/workflow/core/utils/workflowFlattening'
import type {
MissingModelCandidate,
MissingModelViewModel,
EmbeddedModelWithSource
} from './types'
import type { MissingModelCandidate, MissingModelViewModel } from './types'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// eslint-disable-next-line import-x/no-restricted-paths
@@ -17,13 +13,13 @@ import type {
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import { getParentExecutionIds } from '@/types/nodeIdentification'
import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import { getParentExecutionIds } from '@/types/nodeIdentification'
export type MissingModelWorkflowData = FlattenableWorkflowGraph & {
models?: ModelFile[]
@@ -70,6 +66,10 @@ function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
return widget.type === 'asset'
}
function isInactiveMode(mode: number | undefined): boolean {
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
}
// Full set of model file extensions used for scanning candidate widgets.
// Intentionally broader than ALLOWED_SUFFIXES in missingModelDownload.ts,
// which restricts which files are eligible for download.
@@ -111,11 +111,7 @@ export function scanAllModelCandidates(
// Skip subgraph container nodes: their promoted widgets are synthetic
// views of interior widgets, which are already scanned via recursion.
if (node.isSubgraphNode?.()) continue
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
if (isInactiveMode(node.mode)) continue
candidates.push(
...scanNodeModelCandidates(
@@ -217,14 +213,12 @@ function scanComboWidget(
}
}
export async function enrichWithEmbeddedMetadata(
export function enrichWithEmbeddedMetadata(
candidates: readonly MissingModelCandidate[],
graphData: MissingModelWorkflowData,
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
): Promise<MissingModelCandidate[]> {
graphData: MissingModelWorkflowData
): MissingModelCandidate[] {
const allNodes = flattenWorkflowNodes(graphData)
const embeddedModels = collectEmbeddedModelsWithSource(allNodes, graphData)
const embeddedModels = collectEmbeddedModels(allNodes, graphData)
const enriched = candidates.map((c) => ({ ...c }))
const candidatesByKey = new Map<string, MissingModelCandidate[]>()
@@ -240,7 +234,7 @@ export async function enrichWithEmbeddedMetadata(
else candidatesByKey.set(nameKey, [c])
}
const deduped: EmbeddedModelWithSource[] = []
const deduped: ModelFile[] = []
const enrichedKeys = new Set<string>()
for (const model of embeddedModels) {
const dedupeKey = `${model.name}::${model.directory}`
@@ -249,195 +243,60 @@ export async function enrichWithEmbeddedMetadata(
deduped.push(model)
}
const unmatched: EmbeddedModelWithSource[] = []
for (const model of deduped) {
const dirKey = `${model.name}::${model.directory}`
const exact = candidatesByKey.get(dirKey)
const fallback = candidatesByKey.get(model.name)
const existing = exact?.length ? exact : fallback
if (existing) {
for (const c of existing) {
if (c.directory && c.directory !== model.directory) continue
c.directory ??= model.directory
c.url ??= model.url
c.hash ??= model.hash
c.hashType ??= model.hash_type
}
} else {
unmatched.push(model)
if (!existing) continue
for (const c of existing) {
if (c.directory && c.directory !== model.directory) continue
c.directory ??= model.directory
c.url ??= model.url
c.hash ??= model.hash
c.hashType ??= model.hash_type
}
}
// Workflow-level entries (sourceNodeType === '') survive only when
// some active (non-muted, non-bypassed) node actually references the
// model — not merely because any unrelated active node exists. A
// reference is any widget value (or node.properties.models entry)
// that matches the model name on an active node.
// Hoist the id→node map once; isModelReferencedByActiveNode would
// otherwise rebuild it on every unmatched entry.
const flattenedNodeById = new Map(allNodes.map((n) => [String(n.id), n]))
const activeUnmatched = unmatched.filter(
(m) =>
m.sourceNodeType !== '' ||
isModelReferencedByActiveNode(
m.name,
m.directory,
allNodes,
flattenedNodeById
)
)
const settled = await Promise.allSettled(
activeUnmatched.map(async (model) => {
const installed = await checkModelInstalled(model.name, model.directory)
if (installed) return null
const nodeIsAssetSupported = isAssetSupported
? isAssetSupported(model.sourceNodeType, model.sourceWidgetName)
: false
return {
nodeId: model.sourceNodeId,
nodeType: model.sourceNodeType,
widgetName: model.sourceWidgetName,
isAssetSupported: nodeIsAssetSupported,
name: model.name,
directory: model.directory,
url: model.url,
hash: model.hash,
hashType: model.hash_type,
isMissing: nodeIsAssetSupported ? undefined : true
} satisfies MissingModelCandidate
})
)
for (const r of settled) {
if (r.status === 'rejected') {
console.warn(
'[Missing Model Pipeline] checkModelInstalled failed:',
r.reason
)
continue
}
if (r.value) enriched.push(r.value)
}
return enriched
}
function isModelReferencedByActiveNode(
modelName: string,
modelDirectory: string | undefined,
allNodes: ReturnType<typeof flattenWorkflowNodes>,
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
): boolean {
for (const node of allNodes) {
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
if (!isAncestorPathActiveInFlattened(String(node.id), nodeById)) continue
// Require directory agreement when both sides specify one, so a
// same-name entry under a different folder does not keep an
// unrelated workflow-level model alive as missing.
const embeddedModels = (
node.properties as
| { models?: Array<{ name: string; directory?: string }> }
| undefined
)?.models
if (
embeddedModels?.some(
(m) =>
m.name === modelName &&
(modelDirectory === undefined ||
m.directory === undefined ||
m.directory === modelDirectory)
)
) {
return true
}
// widgets_values carries only the name, so directory cannot be
// checked here — fall back to filename matching.
const values = node.widgets_values
if (!values) continue
const valueArray = Array.isArray(values) ? values : Object.values(values)
for (const v of valueArray) {
if (typeof v === 'string' && v === modelName) return true
}
}
return false
}
function isAncestorPathActiveInFlattened(
executionId: string,
nodeById: Map<string, ReturnType<typeof flattenWorkflowNodes>[number]>
): boolean {
for (const ancestorId of getParentExecutionIds(executionId)) {
const ancestor = nodeById.get(ancestorId)
if (!ancestor) continue
if (
ancestor.mode === LGraphEventMode.NEVER ||
ancestor.mode === LGraphEventMode.BYPASS
)
return false
}
return true
}
function collectEmbeddedModelsWithSource(
function collectEmbeddedModels(
allNodes: ReturnType<typeof flattenWorkflowNodes>,
graphData: MissingModelWorkflowData
): EmbeddedModelWithSource[] {
const result: EmbeddedModelWithSource[] = []
): ModelFile[] {
const result: ModelFile[] = []
const nodesById = new Map(allNodes.map((node) => [String(node.id), node]))
for (const node of allNodes) {
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
if (!isNodeAndAncestorsActive(node, nodesById)) continue
const selected = getSelectedModelsMetadata(node)
if (!selected?.length) continue
for (const model of selected) {
result.push({
...model,
sourceNodeId: node.id,
sourceNodeType: node.type,
sourceWidgetName: findWidgetNameForModel(node, model.name)
})
}
result.push(...selected)
}
// Workflow-level model entries have no originating node; sourceNodeId
// remains undefined and empty-string node type/widget are handled by
// groupCandidatesByName (no nodeId → no referencing node entry).
if (graphData.models?.length) {
for (const model of graphData.models) {
result.push({
...model,
sourceNodeType: '',
sourceWidgetName: ''
})
}
}
if (graphData.models?.length) result.push(...graphData.models)
return result
}
function findWidgetNameForModel(
function isNodeAndAncestorsActive(
node: ReturnType<typeof flattenWorkflowNodes>[number],
modelName: string
): string {
if (Array.isArray(node.widgets_values) || !node.widgets_values) return ''
for (const [key, val] of Object.entries(node.widgets_values)) {
if (val === modelName) return key
nodesById: ReadonlyMap<
string,
ReturnType<typeof flattenWorkflowNodes>[number]
>
): boolean {
if (isInactiveMode(node.mode)) return false
for (const ancestorId of getParentExecutionIds(String(node.id))) {
const ancestor = nodesById.get(ancestorId)
if (isInactiveMode(ancestor?.mode)) return false
}
return ''
return true
}
interface AssetVerifier {

View File

@@ -1,7 +1,4 @@
import type {
ModelFile,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
/**
* A single (node, widget, model) binding detected by the missing model pipeline.
@@ -28,13 +25,6 @@ export interface MissingModelCandidate {
isMissing: boolean | undefined
}
export interface EmbeddedModelWithSource extends ModelFile {
/** Undefined for workflow-level models not tied to a specific node. */
sourceNodeId?: NodeId
sourceNodeType: string
sourceWidgetName: string
}
/** View model grouping multiple candidate references under a single model name. */
export interface MissingModelViewModel {
name: string

View File

@@ -309,6 +309,7 @@ export interface SearchQueryMetadata {
*/
export type NodeAddSource =
| 'sidebar_drag'
| 'asset_browser'
| 'search_modal'
| 'paste'
| 'programmatic'

View File

@@ -118,9 +118,27 @@ export interface Plan {
seat_summary: PlanSeatSummary
}
interface TeamCreditStopPrice {
list_price_cents: number
price_cents: number
}
interface TeamCreditStop {
id: string
credits: number
monthly: TeamCreditStopPrice
yearly: TeamCreditStopPrice
}
export interface TeamCreditStops {
default_stop_index: number
stops: TeamCreditStop[]
}
interface BillingPlansResponse {
current_plan_slug?: string
plans: Plan[]
team_credit_stops?: TeamCreditStops
}
type SubscriptionTransitionType =
@@ -214,6 +232,12 @@ export type BillingStatus =
| 'payment_failed'
| 'inactive'
export interface CurrentTeamCreditStop {
id: string
credits_monthly: number
stop_usd: number
}
export interface BillingStatusResponse {
is_active: boolean
subscription_status?: BillingSubscriptionStatus
@@ -224,6 +248,7 @@ export interface BillingStatusResponse {
has_funds: boolean
cancel_at?: string
renewal_date?: string
team_credit_stop?: CurrentTeamCreditStop
}
export interface BillingBalanceResponse {

View File

@@ -0,0 +1,55 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
n: (value: number) => value.toLocaleString('en-US')
})
}))
const globalOptions = {
mocks: { $t: (key: string) => key },
stubs: {
'i18n-t': { template: '<span />' },
Button: {
template: '<button @click="$emit(\'click\')"><slot /></button>'
}
}
}
describe('SubscriptionAddPaymentPreviewWorkspace', () => {
it('renders personal tier price and credits from tierKey', () => {
render(SubscriptionAddPaymentPreviewWorkspace, {
props: { tierKey: 'creator' },
global: globalOptions
})
expect(screen.getByText('subscription.tiers.creator.name')).toBeTruthy()
expect(screen.getByText('$35')).toBeTruthy()
})
it('renders the team plan from the selected slider stop', () => {
render(SubscriptionAddPaymentPreviewWorkspace, {
props: { teamPlan: { usd: 400, credits: 84_400, discountedUsd: 380 } },
global: globalOptions
})
expect(screen.getByText('subscription.teamPlan.name')).toBeTruthy()
expect(screen.getByText('$380')).toBeTruthy()
expect(screen.getAllByText('84,400').length).toBeGreaterThan(0)
expect(screen.getByText('$380.00')).toBeTruthy()
})
it('emits addCreditCard from the team confirm CTA', async () => {
const { emitted } = render(SubscriptionAddPaymentPreviewWorkspace, {
props: { teamPlan: { usd: 400, credits: 84_400, discountedUsd: 380 } },
global: globalOptions
})
await userEvent.click(
screen.getByText('subscription.preview.subscribeToPlan')
)
expect(emitted().addCreditCard).toBeTruthy()
})
})

View File

@@ -16,9 +16,16 @@
${{ displayPrice }}
</span>
<span class="text-xl text-base-foreground">
{{ $t('subscription.usdPerMonthPerMember') }}
{{ $t('subscription.usdPerMonth') }}
</span>
</div>
<div
v-if="teamPlan"
class="flex items-center gap-1 text-sm text-muted-foreground"
>
<i class="icon-[comfy--credits] size-3.5 shrink-0 bg-amber-400" />
<span>{{ displayCredits }} {{ $t('subscription.perMonth') }}</span>
</div>
<span class="text-muted-foreground">
{{ $t('subscription.preview.startingToday') }}
</span>
@@ -31,13 +38,10 @@
{{ $t('subscription.preview.eachMonthCreditsRefill') }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<span class="font-bold text-base-foreground">
{{ displayCredits }}
</span>
<span class="text-base-foreground">
{{ $t('subscription.preview.perMember') }}
</span>
</div>
</div>
@@ -63,36 +67,48 @@
/>
</button>
<div v-show="!isFeaturesCollapsed" class="flex flex-col gap-2 pt-2">
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.maxDurationLabel') }}
</span>
<span class="text-sm font-bold text-base-foreground">
{{ maxDuration }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.gpuLabel') }}
</span>
<i class="pi pi-check text-success-foreground text-xs" />
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.addCreditsLabel') }}
</span>
<i class="pi pi-check text-success-foreground text-xs" />
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.customLoRAsLabel') }}
</span>
<i
v-if="hasCustomLoRAs"
class="pi pi-check text-success-foreground text-xs"
/>
<i v-else class="pi pi-times text-xs text-muted-foreground" />
</div>
<template v-if="teamPlan">
<div
v-for="perk in teamPerks"
:key="perk"
class="flex items-center gap-2"
>
<i class="pi pi-check text-success-foreground text-xs" />
<span class="text-sm text-base-foreground">{{ perk }}</span>
</div>
</template>
<template v-else>
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.maxDurationLabel') }}
</span>
<span class="text-sm font-bold text-base-foreground">
{{ maxDuration }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.gpuLabel') }}
</span>
<i class="pi pi-check text-success-foreground text-xs" />
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.addCreditsLabel') }}
</span>
<i class="pi pi-check text-success-foreground text-xs" />
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
{{ $t('subscription.customLoRAsLabel') }}
</span>
<i
v-if="hasCustomLoRAs"
class="pi pi-check text-success-foreground text-xs"
/>
<i v-else class="pi pi-times text-xs text-muted-foreground" />
</div>
</template>
</div>
</div>
@@ -118,30 +134,7 @@
<!-- Footer -->
<div class="flex flex-col gap-2 pt-8">
<!-- Terms Agreement -->
<p class="text-center text-xs text-muted-foreground">
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
<template #terms>
<a
href="https://www.comfy.org/terms"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.terms') }}
</a>
</template>
<template #privacy>
<a
href="https://www.comfy.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.privacyPolicy') }}
</a>
</template>
</i18n-t>
</p>
<SubscriptionTermsNote />
<!-- Add Credit Card Button -->
<Button
@@ -151,7 +144,7 @@
:loading="isLoading"
@click="$emit('addCreditCard')"
>
{{ $t('subscription.preview.addCreditCard') }}
{{ $t('subscription.preview.subscribeToPlan', { plan: tierName }) }}
</Button>
<!-- Back Link -->
@@ -171,6 +164,7 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import {
getTierCredits,
getTierFeatures,
@@ -181,18 +175,24 @@ import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscript
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import { cn } from '@comfyorg/tailwind-utils'
import SubscriptionTermsNote from './SubscriptionTermsNote.vue'
interface Props {
tierKey: Exclude<TierKey, 'free' | 'founder'>
/** Personal-tier checkout. Required unless `teamPlan` is set. */
tierKey?: Exclude<TierKey, 'free' | 'founder'>
billingCycle?: BillingCycle
isLoading?: boolean
previewData?: PreviewSubscribeResponse | null
/** Team-plan checkout (selected slider stop); overrides tier-derived display. */
teamPlan?: TeamPlanSelection | null
}
const {
tierKey,
billingCycle = 'monthly',
isLoading = false,
previewData = null
previewData = null,
teamPlan = null
} = defineProps<Props>()
defineEmits<{
@@ -204,24 +204,42 @@ const { t, n } = useI18n()
const isFeaturesCollapsed = ref(true)
const tierName = computed(() => t(`subscription.tiers.${tierKey}.name`))
const tierName = computed(() =>
teamPlan
? t('subscription.teamPlan.name')
: t(`subscription.tiers.${tierKey}.name`)
)
const displayPrice = computed(() => {
if (teamPlan) return teamPlan.discountedUsd
if (previewData?.new_plan) {
return (previewData.new_plan.price_cents / 100).toFixed(0)
}
return getTierPrice(tierKey, billingCycle === 'yearly')
return tierKey ? getTierPrice(tierKey, billingCycle === 'yearly') : 0
})
const displayCredits = computed(() => n(getTierCredits(tierKey) ?? 0))
const displayCredits = computed(() =>
n(teamPlan ? teamPlan.credits : tierKey ? (getTierCredits(tierKey) ?? 0) : 0)
)
const hasCustomLoRAs = computed(() => getTierFeatures(tierKey).customLoRAs)
const teamPerks = computed(() => [
t('subscription.teamPlan.perkInviteMembers'),
t('subscription.teamPlan.perkConcurrentRuns'),
t('subscription.teamPlan.perkSharedPool'),
t('subscription.teamPlan.perkRolePermissions')
])
const hasCustomLoRAs = computed(() =>
tierKey ? getTierFeatures(tierKey).customLoRAs : false
)
const maxDuration = computed(() => t(`subscription.maxDuration.${tierKey}`))
const totalDueToday = computed(() => {
if (teamPlan) return teamPlan.discountedUsd.toFixed(2)
if (previewData) {
return (previewData.cost_today_cents / 100).toFixed(2)
}
if (!tierKey) return '0.00'
const priceValue = getTierPrice(tierKey, billingCycle === 'yearly')
if (billingCycle === 'yearly') {
return (priceValue * 12).toFixed(2)

View File

@@ -0,0 +1,126 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
import SubscriptionSuccessWorkspace from './SubscriptionSuccessWorkspace.vue'
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
type PreviewPlanInfo = PreviewSubscribeResponse['new_plan']
/**
* Checkout steps of the unified subscription dialog (FE-934): the
* "Confirm your payment" / "Confirm your plan change" preview screens and the
* "You're all set" success screen. Driven by props (no API in Storybook).
*/
const meta: Meta = {
title: 'Components/SubscriptionCheckoutSteps',
parameters: { layout: 'centered' }
}
export default meta
type Story = StoryObj
const creatorPlan: PreviewPlanInfo = {
slug: 'creator-annual',
tier: 'CREATOR',
duration: 'ANNUAL',
price_cents: 2800,
credits_cents: 740000,
seat_summary: {
seat_count: 1,
total_cost_cents: 2800,
total_credits_cents: 740000
},
period_end: '2027-07-10T00:00:00Z'
}
const proPlan: PreviewPlanInfo = {
slug: 'pro-annual',
tier: 'PRO',
duration: 'ANNUAL',
price_cents: 8000,
credits_cents: 2110000,
seat_summary: {
seat_count: 1,
total_cost_cents: 8000,
total_credits_cents: 2110000
},
period_end: '2026-07-10T00:00:00Z'
}
const shell =
'<div class="mx-auto flex h-[680px] w-[460px] flex-col rounded-2xl border border-border-default bg-secondary-background p-12">'
/** New subscription — "Confirm your payment" (AddPayment preview). */
export const ConfirmNewSubscription: Story = {
render: () => ({
components: { SubscriptionAddPaymentPreviewWorkspace },
data: () => ({
previewData: {
allowed: true,
transition_type: 'new_subscription',
effective_at: '2026-07-10T00:00:00Z',
is_immediate: true,
cost_today_cents: 2800,
cost_next_period_cents: 2800,
credits_today_cents: 740000,
credits_next_period_cents: 740000,
new_plan: creatorPlan
} satisfies PreviewSubscribeResponse
}),
template: `${shell}<SubscriptionAddPaymentPreviewWorkspace tier-key="creator" billing-cycle="yearly" :preview-data="previewData" /></div>`
})
}
/** Team subscription — "Confirm your payment" rendered from a slider stop. */
export const ConfirmTeamSubscription: Story = {
render: () => ({
components: { SubscriptionAddPaymentPreviewWorkspace },
data: () => ({ teamPlan: { usd: 400, credits: 84_400 } }),
template: `${shell}<SubscriptionAddPaymentPreviewWorkspace :team-plan="teamPlan" /></div>`
})
}
/** Plan change — "Confirm your plan change" (Transition preview, Pro → Creator). */
export const ConfirmPlanChange: Story = {
render: () => ({
components: { SubscriptionTransitionPreviewWorkspace },
data: () => ({
previewData: {
allowed: true,
transition_type: 'downgrade',
effective_at: '2026-07-10T00:00:00Z',
is_immediate: false,
cost_today_cents: 0,
cost_next_period_cents: 2800,
credits_today_cents: 0,
credits_next_period_cents: 740000,
current_plan: proPlan,
new_plan: creatorPlan
} satisfies PreviewSubscribeResponse
}),
template: `${shell}<SubscriptionTransitionPreviewWorkspace :preview-data="previewData" /></div>`
})
}
/** Success — "You're all set". */
export const SuccessAllSet: Story = {
render: () => ({
components: { SubscriptionSuccessWorkspace },
data: () => ({
previewData: {
allowed: true,
transition_type: 'new_subscription',
effective_at: '2026-07-10T00:00:00Z',
is_immediate: true,
cost_today_cents: 2800,
cost_next_period_cents: 2800,
credits_today_cents: 740000,
credits_next_period_cents: 740000,
new_plan: creatorPlan
} satisfies PreviewSubscribeResponse
}),
template: `${shell}<SubscriptionSuccessWorkspace tier-key="creator" :preview-data="previewData" /></div>`
})
}

View File

@@ -0,0 +1,166 @@
<template>
<div
:class="
cn(
'relative flex h-full flex-col gap-4 overflow-y-auto p-4 pt-6',
checkoutStep === 'pricing' &&
'xl:min-h-[min(740px,90vh)] xl:w-[min(1280px,95vw)]'
)
"
>
<Button
v-if="checkoutStep === 'preview'"
size="icon"
variant="muted-textonly"
class="absolute top-2.5 left-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10"
:aria-label="$t('g.back')"
@click="handleBackToPricing"
>
<i class="pi pi-arrow-left text-xl" />
</Button>
<Button
size="icon"
variant="muted-textonly"
class="absolute top-2.5 right-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10"
:aria-label="$t('g.close')"
@click="onClose"
>
<i class="pi pi-times text-xl" />
</Button>
<div class="flex flex-col items-center gap-3">
<h2 class="m-0 font-inter text-2xl font-semibold text-base-foreground">
{{ $t('subscription.descriptionWorkspace') }}
</h2>
</div>
<div v-if="reason === 'out_of_credits'" class="text-center">
<h2 class="m-0 text-xl text-muted-foreground lg:text-2xl">
{{ $t('credits.topUp.insufficientTitle') }}
</h2>
<p class="m-0 mt-2 text-sm text-text-secondary">
{{ $t('credits.topUp.insufficientMessage') }}
</p>
</div>
<!-- Pricing Table Step (unified: personal/team plan toggle) -->
<UnifiedPricingTable
v-if="checkoutStep === 'pricing'"
class="xl:flex-1"
:initial-plan-mode="initialPlanMode"
:is-loading="isLoadingPreview || isResubscribing"
:loading-tier="loadingTier"
@subscribe="handleSubscribeClick"
@resubscribe="handleResubscribe"
@subscribe-team="handleSubscribeTeamClick"
/>
<!-- Subscription Preview Step - New Subscription -->
<SubscriptionAddPaymentPreviewWorkspace
v-else-if="
checkoutStep === 'preview' &&
previewData &&
previewData.transition_type === 'new_subscription'
"
:preview-data="previewData"
:tier-key="selectedTierKey!"
:billing-cycle="selectedBillingCycle"
:is-loading="isSubscribing || isPolling"
@add-credit-card="handleAddCreditCard"
@back="handleBackToPricing"
/>
<!-- Subscription Preview Step - Plan Transition -->
<SubscriptionTransitionPreviewWorkspace
v-else-if="
checkoutStep === 'preview' &&
previewData &&
previewData.transition_type !== 'new_subscription'
"
:preview-data="previewData"
:is-loading="isSubscribing || isPolling"
@confirm="handleConfirmTransition"
@back="handleBackToPricing"
/>
<!-- Subscription Preview Step - Team (display-only until the BE slider
contract lands; the confirm CTA is stubbed below) -->
<SubscriptionAddPaymentPreviewWorkspace
v-else-if="checkoutStep === 'preview' && selectedTeamStop"
:team-plan="selectedTeamStop"
@add-credit-card="handleTeamSubscribe"
@back="handleBackToPricing"
/>
<!-- Success Step - "You're all set" -->
<SubscriptionSuccessWorkspace
v-else-if="checkoutStep === 'success' && selectedTierKey"
:tier-key="selectedTierKey"
:preview-data="previewData"
@close="handleSuccessClose"
/>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
import SubscriptionSuccessWorkspace from './SubscriptionSuccessWorkspace.vue'
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
import UnifiedPricingTable from './UnifiedPricingTable.vue'
const { onClose, reason, initialPlanMode } = defineProps<{
onClose: () => void
reason?: SubscriptionDialogReason
initialPlanMode?: 'personal' | 'team'
}>()
const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { t } = useI18n()
const toast = useToast()
const {
checkoutStep,
isLoadingPreview,
loadingTier,
isSubscribing,
isResubscribing,
previewData,
selectedTierKey,
selectedTeamStop,
selectedBillingCycle,
isPolling,
handleSubscribeClick,
handleSubscribeTeamClick,
handleBackToPricing,
handleSuccessClose,
handleAddCreditCard,
handleConfirmTransition,
handleResubscribe
} = useSubscriptionCheckout(emit)
// Personal-tier checkout reuses the full useSubscriptionCheckout flow above.
// Team-plan checkout renders the confirm step from the selected slider stop,
// but the final subscribe is blocked on the BE discount-breakpoint contract
// (FE-934 / doc Open Q#2: the slider stop -> plan-slug / subscribe-request shape
// is undefined), so the confirm CTA is stubbed until that lands.
function handleTeamSubscribe() {
toast.add({
severity: 'info',
summary: t('subscription.teamPlan.name'),
detail: t('subscription.teamPlan.checkoutComingSoon'),
life: 4000
})
}
</script>

View File

@@ -14,7 +14,8 @@ const mockHandleBackToPricing = vi.fn()
const mockHandleAddCreditCard = vi.fn()
const mockHandleConfirmTransition = vi.fn()
const mockHandleResubscribe = vi.fn()
const mockCheckoutStep = ref<'pricing' | 'preview'>('pricing')
const mockHandleSuccessClose = vi.fn()
const mockCheckoutStep = ref<'pricing' | 'preview' | 'success'>('pricing')
const mockPreviewData = ref<{ transition_type: string } | null>(null)
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
@@ -32,7 +33,8 @@ vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
handleBackToPricing: mockHandleBackToPricing,
handleAddCreditCard: mockHandleAddCreditCard,
handleConfirmTransition: mockHandleConfirmTransition,
handleResubscribe: mockHandleResubscribe
handleResubscribe: mockHandleResubscribe,
handleSuccessClose: mockHandleSuccessClose
})
}))
@@ -78,6 +80,13 @@ const TransitionPreviewStub = {
</div>`
}
const SuccessStub = {
name: 'SubscriptionSuccessWorkspace',
template: `<div data-testid="success">
<button data-testid="success-close-btn" @click="$emit('close')">Done</button>
</div>`
}
function renderComponent(
props: { onClose?: () => void; reason?: SubscriptionDialogReason } = {}
) {
@@ -94,7 +103,8 @@ function renderComponent(
stubs: {
PricingTableWorkspace: PricingTableStub,
SubscriptionAddPaymentPreviewWorkspace: AddPaymentPreviewStub,
SubscriptionTransitionPreviewWorkspace: TransitionPreviewStub
SubscriptionTransitionPreviewWorkspace: TransitionPreviewStub,
SubscriptionSuccessWorkspace: SuccessStub
}
}
})
@@ -195,4 +205,21 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => {
expect(mockHandleBackToPricing).toHaveBeenCalled()
})
it('shows the success screen on the success step', () => {
mockCheckoutStep.value = 'success'
renderComponent()
expect(screen.getByTestId('success')).toBeInTheDocument()
expect(screen.queryByTestId('pricing-table')).not.toBeInTheDocument()
})
it('wires the success close event to handleSuccessClose', async () => {
const user = userEvent.setup()
mockCheckoutStep.value = 'success'
renderComponent()
await user.click(screen.getByTestId('success-close-btn'))
expect(mockHandleSuccessClose).toHaveBeenCalled()
})
})

View File

@@ -90,6 +90,14 @@
@confirm="handleConfirmTransition"
@back="handleBackToPricing"
/>
<!-- Success Step - subscribe/change-plan confirmation -->
<SubscriptionSuccessWorkspace
v-else-if="checkoutStep === 'success' && selectedTierKey"
:tier-key="selectedTierKey"
:preview-data="previewData"
@close="handleSuccessClose"
/>
</div>
</template>
@@ -100,6 +108,7 @@ import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSub
import PricingTableWorkspace from './PricingTableWorkspace.vue'
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
import SubscriptionSuccessWorkspace from './SubscriptionSuccessWorkspace.vue'
import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue'
const { onClose, reason } = defineProps<{
@@ -125,7 +134,8 @@ const {
handleBackToPricing,
handleAddCreditCard,
handleConfirmTransition,
handleResubscribe
handleResubscribe,
handleSuccessClose
} = useSubscriptionCheckout(emit)
</script>

View File

@@ -0,0 +1,70 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import SubscriptionSuccessWorkspace from './SubscriptionSuccessWorkspace.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
n: (value: number) => String(value)
})
}))
function makePreviewData(priceCents: number): PreviewSubscribeResponse {
return {
allowed: true,
transition_type: 'new_subscription',
effective_at: '2026-07-10T00:00:00Z',
is_immediate: true,
cost_today_cents: priceCents,
cost_next_period_cents: priceCents,
credits_today_cents: 0,
credits_next_period_cents: 0,
new_plan: {
slug: 'standard-monthly',
tier: 'STANDARD',
duration: 'MONTHLY',
price_cents: priceCents,
credits_cents: 0,
seat_summary: {
seat_count: 1,
total_cost_cents: priceCents,
total_credits_cents: 0
}
}
}
}
function renderCard() {
return render(SubscriptionSuccessWorkspace, {
props: {
tierKey: 'standard',
previewData: makePreviewData(1600)
},
global: {
mocks: { $t: (key: string) => key },
stubs: {
Button: {
template: '<button @click="$emit(\'click\')"><slot /></button>'
}
}
}
})
}
describe('SubscriptionSuccessWorkspace', () => {
it('renders the all-set heading and plan price', () => {
renderCard()
expect(screen.getByText('subscription.success.allSet')).toBeTruthy()
expect(screen.getByText('$16')).toBeTruthy()
})
it('emits close when the close button is clicked', async () => {
const { emitted } = renderCard()
await userEvent.click(screen.getByRole('button'))
expect(emitted().close).toBeTruthy()
})
})

View File

@@ -0,0 +1,81 @@
<template>
<div
class="mx-auto flex h-full max-w-[400px] flex-col items-stretch justify-between text-sm"
>
<div class="flex flex-col items-center gap-4 pt-8">
<i class="pi pi-check-circle text-success-foreground text-5xl" />
<h2
class="m-0 text-center text-xl font-semibold text-base-foreground lg:text-2xl"
>
{{ $t('subscription.success.allSet') }}
</h2>
<p class="m-0 text-center text-sm text-muted-foreground">
{{ $t('subscription.success.planUpdated') }}
{{ $t('subscription.success.receiptEmailed') }}
</p>
<!-- Plan summary -->
<div
class="mt-4 flex w-full flex-col gap-1 rounded-xl border border-border-default bg-base-background p-4"
>
<span class="text-sm text-base-foreground">{{ tierName }}</span>
<div class="flex items-baseline gap-1">
<span class="text-2xl font-semibold text-base-foreground">
${{ displayPrice }}
</span>
<span class="text-sm text-base-foreground">
{{ $t('subscription.usdPerMonth') }}
</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" />
<span>{{ displayCredits }} {{ $t('subscription.perMonth') }}</span>
</div>
</div>
<!-- Team success "Invite your team" block renders here (FE-965 / DES-394). -->
</div>
<div class="flex flex-col gap-2 pt-8">
<Button
variant="secondary"
size="lg"
class="w-full rounded-lg"
@click="$emit('close')"
>
{{ $t('g.close') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
const { tierKey, previewData = null } = defineProps<{
tierKey: Exclude<TierKey, 'free' | 'founder'>
previewData?: PreviewSubscribeResponse | null
}>()
defineEmits<{
close: []
}>()
const { t, n } = useI18n()
const tierName = computed(() => t(`subscription.tiers.${tierKey}.name`))
const displayPrice = computed(() =>
previewData?.new_plan
? (previewData.new_plan.price_cents / 100).toFixed(0)
: '0'
)
const displayCredits = computed(() => n(getTierCredits(tierKey) ?? 0))
</script>

View File

@@ -0,0 +1,26 @@
<template>
<p class="m-0 text-center text-xs text-muted-foreground">
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
<template #terms>
<a
href="https://www.comfy.org/terms"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.terms') }}
</a>
</template>
<template #privacy>
<a
href="https://www.comfy.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
>
{{ $t('subscription.preview.privacyPolicy') }}
</a>
</template>
</i18n-t>
</p>
</template>

View File

@@ -9,7 +9,7 @@
<!-- Plan Comparison Header -->
<div class="flex items-center gap-4">
<!-- Current Plan -->
<div class="flex w-[250px] flex-col gap-1">
<div class="flex flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ currentTierName }}
</span>
@@ -18,11 +18,11 @@
${{ currentDisplayPrice }}
</span>
<span class="text-sm text-base-foreground">
{{ $t('subscription.usdPerMonthPerMember') }}
{{ $t('subscription.usdPerMonth') }}
</span>
</div>
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<i class="icon-[lucide--component] text-xs text-amber-400" />
<i class="icon-[comfy--credits] size-3.5 shrink-0 bg-amber-400" />
<span
>{{ currentDisplayCredits }}
{{ $t('subscription.perMonth') }}</span
@@ -36,10 +36,10 @@
</div>
<!-- Arrow -->
<i class="pi pi-arrow-right size-8 text-muted-foreground" />
<i class="pi pi-arrow-right size-8 shrink-0 text-muted-foreground" />
<!-- New Plan -->
<div class="flex flex-col gap-1">
<div class="flex flex-1 flex-col gap-1">
<span class="text-sm font-semibold text-base-foreground">
{{ newTierName }}
</span>
@@ -48,11 +48,11 @@
${{ newDisplayPrice }}
</span>
<span class="text-sm text-base-foreground">
{{ $t('subscription.usdPerMonthPerMember') }}
{{ $t('subscription.usdPerMonth') }}
</span>
</div>
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<i class="icon-[lucide--component] text-xs text-amber-400" />
<i class="icon-[comfy--credits] size-3.5 shrink-0 bg-amber-400" />
<span
>{{ newDisplayCredits }} {{ $t('subscription.perMonth') }}</span
>
@@ -63,19 +63,32 @@
</div>
</div>
<!-- Credits Section -->
<!-- Next Cycle Section -->
<div class="flex flex-col gap-3 pt-12 pb-6">
<span class="text-base-foreground">
{{
$t('subscription.preview.everyMonthStarting', {
date: effectiveDate
})
}}
</span>
<div class="flex items-center justify-between">
<span class="text-base-foreground">
{{ $t('subscription.preview.eachMonthCreditsRefill') }}
<span class="text-muted-foreground">
{{ $t('subscription.preview.creditsRefillTo') }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<span class="font-bold text-base-foreground">
{{ newDisplayCredits }}
</span>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">
{{ $t('subscription.preview.youllBeCharged') }}
</span>
<span class="text-base-foreground">${{ newMonthlyCharge }}</span>
</div>
</div>
<!-- Proration Section -->
@@ -131,6 +144,8 @@
<!-- Footer -->
<div class="flex flex-col gap-2 pt-8">
<SubscriptionTermsNote />
<Button
variant="secondary"
size="lg"
@@ -138,7 +153,7 @@
:loading="isLoading"
@click="$emit('confirm')"
>
{{ $t('subscription.preview.confirm') }}
{{ $t('subscription.preview.switchToPlan', { plan: newTierName }) }}
</Button>
<Button
@@ -160,6 +175,8 @@ import Button from '@/components/ui/button/Button.vue'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import SubscriptionTermsNote from './SubscriptionTermsNote.vue'
interface Props {
previewData: PreviewSubscribeResponse
isLoading?: boolean
@@ -202,6 +219,10 @@ const newDisplayPrice = computed(() =>
(previewData.new_plan.price_cents / 100).toFixed(0)
)
const newMonthlyCharge = computed(() =>
(previewData.new_plan.price_cents / 100).toFixed(2)
)
const currentDisplayCredits = computed(() => {
if (!previewData.current_plan) return n(0)
const tierKey = previewData.current_plan.tier.toLowerCase() as

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import UnifiedPricingTable from './UnifiedPricingTable.vue'
/**
* The unified pricing table (B4 / FE-934): one table for the new billing model,
* with a personal/team **plan** toggle on a single workspace (Gamma-style).
*
* Note: the personal/team toggle itself only renders when `teamWorkspacesEnabled`
* is on (a server flag, off in Storybook), so these stories drive the view via
* `initialPlanMode` instead. Personal prices fall back to the static
* `TIER_PRICING` (no API in Storybook); the team column uses the locked DES-197
* credit-slider stops.
*/
const meta: Meta<typeof UnifiedPricingTable> = {
title: 'Components/UnifiedPricingTable',
component: UnifiedPricingTable,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
argTypes: {
initialPlanMode: {
control: 'inline-radio',
options: ['personal', 'team']
}
},
decorators: [
(story) => ({
components: { story },
template:
'<div class="mx-auto max-w-[1328px] bg-base-background p-8"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
/** Personal plans (Standard / Creator / Pro) with the monthly/yearly toggle. */
export const Personal: Story = {
args: { initialPlanMode: 'personal' }
}
/** Team plan: the credit slider + Enterprise card. */
export const TeamPlan: Story = {
args: { initialPlanMode: 'team' }
}

View File

@@ -0,0 +1,233 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import enMessages from '@/locales/en/main.json'
import UnifiedPricingTable from '@/platform/workspace/components/UnifiedPricingTable.vue'
interface MockSubscription {
tier: string
isCancelled?: boolean
duration?: string
}
interface MockTeamStop {
id: string
credits_monthly: number
stop_usd: number
}
const mockSubscription = ref<MockSubscription | null>(null)
const mockCurrentPlanSlug = ref<string | null>(null)
const mockCurrentTeamCreditStop = ref<MockTeamStop | null>(null)
const mockTeamFlag = ref(false)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
plans: ref([]),
currentPlanSlug: computed(() => mockCurrentPlanSlug.value),
fetchPlans: vi.fn(),
subscription: computed(() => mockSubscription.value),
currentTeamCreditStop: computed(() => mockCurrentTeamCreditStop.value)
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: { teamWorkspacesEnabled: mockTeamFlag.value }
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderComponent(props: Record<string, unknown> = {}) {
return render(UnifiedPricingTable, {
props,
global: {
plugins: [i18n],
components: { Button },
stubs: {
SelectButton: { template: '<div />' },
// Clicking emits a change to a different stop ($200) so tests can move
// the selection off the current stop.
CreditSlider: {
template:
'<button data-testid="team-slider" @click="$emit(\'change\', { index: 0, usd: 200, credits: 42200 })" />',
emits: ['change', 'update:modelValue']
}
}
}
})
}
describe('UnifiedPricingTable plan CTA labels', () => {
beforeEach(() => {
mockSubscription.value = null
mockCurrentPlanSlug.value = null
mockCurrentTeamCreditStop.value = null
mockTeamFlag.value = false
})
it('prompts free-tier users to subscribe, never to "change"', () => {
mockSubscription.value = { tier: 'FREE', duration: 'ANNUAL' }
renderComponent()
expect(
screen.getByRole('button', { name: 'Subscribe to Standard Yearly' })
).toBeTruthy()
expect(
screen.getByRole('button', { name: 'Subscribe to Creator Yearly' })
).toBeTruthy()
expect(
screen.getByRole('button', { name: 'Subscribe to Pro Yearly' })
).toBeTruthy()
expect(screen.queryByRole('button', { name: /^Change to/ })).toBeNull()
})
it('offers a plan change to users already on a paid plan', () => {
mockSubscription.value = { tier: 'STANDARD', duration: 'ANNUAL' }
renderComponent()
expect(
screen.getByRole('button', { name: 'Change to Creator Yearly' })
).toBeTruthy()
expect(
screen.getByRole('button', { name: 'Change to Pro Yearly' })
).toBeTruthy()
})
it('keeps personal tier cards actionable for a team subscriber', () => {
mockSubscription.value = { tier: 'TEAM', duration: 'ANNUAL' }
mockCurrentTeamCreditStop.value = {
id: 'team_700',
credits_monthly: 147_700,
stop_usd: 700
}
renderComponent()
expect(screen.queryByRole('button', { name: /Not available/ })).toBeNull()
})
})
describe('UnifiedPricingTable team plan CTA', () => {
const TEAM_STOP = {
id: 'team_2500',
credits_monthly: 527_500,
stop_usd: 2_500
}
beforeEach(() => {
mockSubscription.value = null
mockCurrentPlanSlug.value = null
mockCurrentTeamCreditStop.value = null
mockTeamFlag.value = true
})
it('disables the CTA while sitting on the active current plan', () => {
mockSubscription.value = {
tier: 'TEAM',
duration: 'ANNUAL',
isCancelled: false
}
mockCurrentTeamCreditStop.value = TEAM_STOP
renderComponent({ initialPlanMode: 'team' })
const cta = screen.getByRole('button', { name: 'Current plan' })
expect(cta).toBeDisabled()
})
it('lets an active sub change to a different stop', async () => {
const user = userEvent.setup()
mockSubscription.value = {
tier: 'TEAM',
duration: 'ANNUAL',
isCancelled: false
}
mockCurrentTeamCreditStop.value = TEAM_STOP
const { emitted } = renderComponent({ initialPlanMode: 'team' })
await user.click(screen.getByTestId('team-slider'))
const cta = screen.getByRole('button', { name: 'Change plan' })
expect(cta).toBeEnabled()
await user.click(cta)
expect(emitted().subscribeTeam).toBeTruthy()
})
it('lets an active sub change billing cycle at the current stop', async () => {
const user = userEvent.setup()
mockSubscription.value = {
tier: 'TEAM',
duration: 'MONTHLY',
isCancelled: false
}
mockCurrentTeamCreditStop.value = TEAM_STOP
const { emitted } = renderComponent({ initialPlanMode: 'team' })
// The subscription is monthly; the default view is yearly, so the same stop
// on the other cycle is a change, not the current plan.
const cta = screen.getByRole('button', { name: 'Change plan' })
expect(cta).toBeEnabled()
await user.click(cta)
expect(emitted().subscribeTeam).toBeTruthy()
expect(emitted().resubscribe).toBeFalsy()
})
it('re-subscribes (not change) for a cancelled team subscription', async () => {
const user = userEvent.setup()
mockSubscription.value = {
tier: 'TEAM',
duration: 'ANNUAL',
isCancelled: true
}
mockCurrentTeamCreditStop.value = TEAM_STOP
const { emitted } = renderComponent({ initialPlanMode: 'team' })
const cta = screen.getByRole('button', { name: 'Resubscribe' })
expect(cta).toBeEnabled()
await user.click(cta)
expect(emitted().resubscribe).toBeTruthy()
})
it('lets a cancelled sub change to a different stop (not re-subscribe)', async () => {
const user = userEvent.setup()
mockSubscription.value = {
tier: 'TEAM',
duration: 'ANNUAL',
isCancelled: true
}
mockCurrentTeamCreditStop.value = TEAM_STOP
const { emitted } = renderComponent({ initialPlanMode: 'team' })
await user.click(screen.getByTestId('team-slider'))
const cta = screen.getByRole('button', { name: 'Change plan' })
expect(cta).toBeEnabled()
await user.click(cta)
expect(emitted().subscribeTeam).toBeTruthy()
expect(emitted().resubscribe).toBeFalsy()
})
it('prompts a fresh subscribe when on no team plan', () => {
renderComponent({ initialPlanMode: 'team' })
expect(
screen.getByRole('button', { name: 'Subscribe to Team Yearly' })
).toBeTruthy()
})
})

View File

@@ -0,0 +1,833 @@
<template>
<div class="flex flex-col xl:h-full">
<!-- Plan-scope toggle (personal vs team PLAN on one workspace): sits directly
on top of the content area outside it, attached with no gap (DES QA).
Only shown when team plans are available (teamWorkspacesEnabled). -->
<div v-if="showTeam" class="flex justify-center">
<SelectButton
v-model="planMode"
:options="planScopeOptions"
option-label="label"
option-value="value"
:allow-empty="false"
unstyled
:pt="planScopeButtonPt"
/>
</div>
<!-- Content well: a borderless base-background area (DES-197 "Personal Plan,
Yearly" 2951:584251 bg base-background, rounded-2xl, NO border, 32px
padding) holding the description, billing toggle and plan cards on one
uniform surface. "Remove the outline" = drop the border, not the area.
Grows to fill the dialog so its height stays constant across the
personal/team toggle. -->
<div
class="flex min-h-0 flex-col gap-6 rounded-2xl bg-base-background p-8 xl:flex-1"
>
<!-- Plan-scope description, above the billing toggle (DES-197). -->
<I18nT
v-if="planMode === 'personal'"
keypath="subscription.personalHeader"
tag="p"
class="m-0 text-center text-sm text-muted-foreground"
>
<template #action>
<button
type="button"
class="cursor-pointer border-none bg-transparent p-0 text-sm text-base-foreground hover:text-muted-foreground"
@click="planMode = 'team'"
>
{{ t('subscription.personalHeaderAction') }}
</button>
</template>
</I18nT>
<I18nT
v-else
keypath="subscription.teamHeader"
tag="p"
class="m-0 text-center text-sm text-muted-foreground"
>
<template #learnMore>
<button
type="button"
class="cursor-pointer border-none bg-transparent p-0 text-sm text-base-foreground hover:text-muted-foreground"
@click="handleViewEnterprise"
>
{{ t('subscription.teamHeaderLearnMore') }}
</button>
</template>
</I18nT>
<!-- Billing-cycle toggle: drives both the personal tier cards and the
team credit slider (team monthly halves the yearly discount). -->
<div class="flex justify-center">
<SelectButton
v-model="currentBillingCycle"
:options="billingCycleOptions"
option-label="label"
option-value="value"
:allow-empty="false"
unstyled
:pt="toggleButtonPt"
>
<template #option="{ option }">
<div class="flex items-center gap-2">
<span>{{ option.label }}</span>
<div
v-if="option.value === 'yearly'"
class="flex items-center rounded-full bg-primary-background px-2 py-0.5 text-2xs font-bold whitespace-nowrap text-white"
>
{{
planMode === 'team'
? t('subscription.saveYearlyUpTo')
: t('subscription.saveYearly')
}}
</div>
</div>
</template>
</SelectButton>
</div>
<!-- PERSONAL PLANS: tier cards (data-driven via the billing facade,
falling back to TIER_PRICING). -->
<div
v-if="planMode === 'personal'"
class="flex flex-col items-stretch gap-6 xl:flex-1 xl:flex-row xl:justify-center"
>
<div
v-for="tier in tiers"
:key="tier.id"
class="flex flex-col rounded-2xl border border-border-default bg-base-background shadow-[0_0_12px_rgba(0,0,0,0.1)] xl:w-80"
>
<div class="flex flex-1 flex-col gap-4 p-6 pb-0">
<div class="flex flex-row items-center justify-between gap-2">
<span
class="font-inter text-base/normal font-bold text-base-foreground"
>
{{ tier.name }}
</span>
<div
v-if="tier.isPopular"
class="flex h-5 items-center rounded-full bg-base-foreground px-1.5 text-2xs font-bold tracking-tight text-base-background uppercase"
>
{{ t('subscription.mostPopular') }}
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-row items-baseline gap-2">
<span
class="font-inter text-[28px] leading-normal font-semibold text-base-foreground tabular-nums"
>
${{ getPrice(tier) }}
<span
v-show="currentBillingCycle === 'yearly'"
class="text-2xl text-muted-foreground line-through"
>
${{ getMonthlyPrice(tier) }}
</span>
</span>
<span class="font-inter text-sm/normal text-base-foreground">
{{ t('subscription.usdPerMonth') }}
</span>
</div>
<span class="text-sm text-muted-foreground">
{{
currentBillingCycle === 'yearly'
? t('subscription.billedYearly', {
total: `$${getAnnualTotal(tier)}`
})
: t('subscription.billedMonthly')
}}
</span>
</div>
<div class="h-px w-full bg-border-default" />
<!-- Progressive feature list: "What's included" then
"Everything in {prev tier}, plus:" (DES-197). -->
<div class="flex flex-col gap-3">
<span class="text-sm text-muted-foreground">
{{ tier.featuresHeader }}
</span>
<div
v-for="feature in tier.features"
:key="feature"
class="flex flex-row items-start gap-2"
>
<i class="pi pi-check mt-0.5 text-xs text-base-foreground" />
<span class="text-sm font-normal text-muted-foreground">
{{ feature }}
</span>
</div>
</div>
<!-- Credit grant + template-based video estimate, pinned to the
card bottom so the figures align across tiers. -->
<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"
aria-hidden="true"
/>
<span
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
>
{{ n(tier.pricing.credits) }}
</span>
<span class="text-sm text-muted-foreground">
{{ t('subscription.monthlyCredits') }}
</span>
</div>
<span class="text-sm text-muted-foreground">
{{
t('subscription.videoEstimate', {
count: n(tier.pricing.videoEstimate)
})
}}
</span>
</div>
</div>
<div class="flex flex-col p-6">
<Button
:variant="getButtonSeverity(tier)"
:disabled="isButtonDisabled(tier)"
:loading="loadingTier === tier.key"
:class="
cn(
'h-10 w-full',
getButtonTextClass(tier),
tier.key === 'creator'
? 'border-transparent bg-base-foreground hover:bg-inverted-background-hover'
: 'border-transparent bg-secondary-background hover:bg-secondary-background-hover focus:bg-secondary-background-selected'
)
"
@click="() => handleSubscribe(tier.key)"
>
{{ getButtonLabel(tier) }}
</Button>
</div>
</div>
</div>
<!-- TEAM PLAN: [Team Plan | Details] card + Enterprise card -->
<div v-else class="flex min-h-0 flex-col gap-6 xl:flex-1">
<div
class="flex flex-col items-stretch gap-6 xl:flex-1 xl:flex-row xl:justify-center"
>
<!-- Team Plan + Details share one card, split by a divider. -->
<div
class="flex flex-[2.6] flex-col rounded-2xl border border-border-default bg-base-background shadow-[0_0_12px_rgba(0,0,0,0.1)] xl:flex-row xl:overflow-hidden"
>
<!-- Team Plan column -->
<div class="flex flex-[1.6] flex-col gap-6 p-6">
<div class="flex flex-col gap-1">
<span
class="font-inter text-base/normal font-bold text-base-foreground"
>
{{ t('subscription.teamPlan.name') }}
</span>
<span class="text-sm text-muted-foreground">
{{ t('subscription.teamPlan.tagline') }}
</span>
</div>
<!-- Credit slider owns its price/discount/billed display; the
cycle halves the yearly discount when monthly. -->
<CreditSlider
v-model="teamUsd"
:cycle="currentBillingCycle"
@change="onTeamChange"
/>
<!-- Selected credit grant + template-based video estimate -->
<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"
aria-hidden="true"
/>
<span
class="font-inter text-sm/normal font-bold text-base-foreground tabular-nums"
>
{{ n(teamCredits) }}
</span>
<span class="text-sm text-muted-foreground">
{{ t('subscription.monthlyCredits') }}
</span>
</div>
<span class="text-sm text-muted-foreground">
{{
t('subscription.videoEstimate', {
count: n(teamVideoEstimate)
})
}}
</span>
</div>
<!-- CTA pinned to the card bottom (aligns with Enterprise CTA) -->
<Button
variant="inverted"
:disabled="isTeamButtonDisabled"
class="mt-auto h-10 w-full font-inter text-sm/normal font-bold"
@click="handleSubscribeTeam"
>
{{ teamButtonLabel }}
</Button>
</div>
<!-- Divider: horizontal when stacked, vertical at xl -->
<div
class="h-px w-full shrink-0 self-stretch bg-border-default xl:h-auto xl:w-px"
/>
<!-- Details column -->
<div class="flex flex-1 flex-col gap-4 p-6">
<span
class="font-inter text-base/normal font-bold text-base-foreground"
>
{{ t('subscription.teamPlan.detailsTitle') }}
</span>
<div class="flex flex-col gap-3">
<span class="text-sm text-muted-foreground">
{{
t('subscription.everythingInPlus', {
plan: t('subscription.tiers.pro.name')
})
}}
</span>
<div
v-for="perk in teamDetailPerks"
:key="perk"
class="flex flex-row items-start gap-2"
>
<i class="pi pi-check mt-0.5 text-xs text-base-foreground" />
<span class="text-sm font-normal text-muted-foreground">
{{ perk }}
</span>
</div>
</div>
<div class="flex flex-col gap-3">
<span class="text-sm text-muted-foreground">
{{ t('subscription.teamPlan.comingSoonLabel') }}
</span>
<div class="flex flex-row items-start gap-2">
<i class="pi pi-clock mt-0.5 text-xs text-muted-foreground" />
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.teamPlan.perkProjectAssets') }}
</span>
</div>
</div>
</div>
</div>
<!-- Enterprise card -->
<div
class="flex flex-1 flex-col gap-4 rounded-2xl border border-border-default bg-base-background p-6 shadow-[0_0_12px_rgba(0,0,0,0.1)]"
>
<span
class="font-inter text-base/normal font-bold text-base-foreground"
>
{{ t('subscription.enterprise.name') }}
</span>
<div class="flex flex-col gap-3">
<span class="text-sm/relaxed font-normal text-muted-foreground">
{{ t('subscription.enterprise.needMoreMembers') }}
</span>
<span class="text-sm/relaxed font-normal text-muted-foreground">
{{ t('subscription.enterprise.flexibility') }}
</span>
</div>
<div class="h-px w-full bg-border-default" />
<span class="text-sm/relaxed font-normal text-muted-foreground">
{{ t('subscription.enterprise.reachOut') }}
</span>
<Button
variant="secondary"
class="mt-auto h-10 w-full border-transparent bg-secondary-background font-bold hover:bg-secondary-background-hover"
@click="handleViewEnterprise"
>
{{ t('subscription.enterprise.cta') }}
</Button>
</div>
</div>
</div>
</div>
<!-- Footnote: template caveat + contact / pricing links -->
<I18nT
keypath="subscription.pricingBlurb"
tag="p"
class="m-0 mt-auto pt-4 text-center text-sm text-text-secondary"
>
<template #seeDetails>
<a
:href="VIDEO_TEMPLATE_URL"
target="_blank"
rel="noopener noreferrer"
class="cursor-pointer text-sm text-base-foreground no-underline hover:text-muted-foreground"
>
{{ t('subscription.pricingBlurbSeeDetails') }}
</a>
</template>
<template #questions>
<a
:href="QUESTIONS_URL"
target="_blank"
rel="noopener noreferrer"
class="cursor-pointer text-sm text-base-foreground no-underline hover:text-muted-foreground"
>
{{ t('subscription.pricingBlurbQuestions') }}
</a>
</template>
<template #enterpriseDiscussions>
<a
:href="ENTERPRISE_URL"
target="_blank"
rel="noopener noreferrer"
class="cursor-pointer text-sm text-base-foreground no-underline hover:text-muted-foreground"
>
{{ t('subscription.pricingBlurbEnterprise') }}
</a>
</template>
<template #clickHere>
<a
:href="PRICING_URL"
target="_blank"
rel="noopener noreferrer"
class="cursor-pointer text-sm text-base-foreground no-underline hover:text-muted-foreground"
>
{{ t('subscription.pricingBlurbClickHere') }}
</a>
</template>
</I18nT>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import SelectButton from 'primevue/selectbutton'
import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton'
import { computed, onMounted, ref, watch } from 'vue'
import { I18nT, useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import CreditSlider from '@/components/ui/credit-slider/CreditSlider.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
TIER_PRICING,
TIER_TO_KEY
} from '@/platform/cloud/subscription/constants/tierPricing'
import type {
SubscriptionTier,
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
getDiscountedMonthlyUsd,
TEAM_PLAN_CREDIT_STOPS
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
interface Props {
isLoading?: boolean
loadingTier?: CheckoutTierKey | null
/** Initial plan scope. The toggle to switch is only shown when team plans
* are available (`teamWorkspacesEnabled`). */
initialPlanMode?: 'personal' | 'team'
}
const {
isLoading,
loadingTier = null,
initialPlanMode = 'personal'
} = defineProps<Props>()
const emit = defineEmits<{
subscribe: [payload: { tierKey: CheckoutTierKey; billingCycle: BillingCycle }]
resubscribe: []
// Team-plan checkout. NOTE: the slider stop -> plan-slug mapping is blocked on
// the BE discount-breakpoint contract (FE-934 / doc Open Q#2); the host shows
// the confirm step but stubs the final subscribe until the contract lands.
// TODO(FE-934): once the contract lands, also carry `currentBillingCycle`
// (yearly | monthly) so checkout subscribes to the selected cycle, not just
// the stop. The pricing-table view already toggles cycle; the confirm/checkout
// chain still assumes yearly.
subscribeTeam: [payload: TeamPlanSelection]
}>()
const { t, n } = useI18n()
const { flags } = useFeatureFlags()
/** Team plans only exist behind the flag (mirrors useBillingContext type). */
const showTeam = computed(() => flags.teamWorkspacesEnabled)
const planMode = ref<'personal' | 'team'>(initialPlanMode)
/** The Wan 2.2 i2v template the video estimates are based on. */
const VIDEO_TEMPLATE_URL =
'https://cloud.comfy.org/?template=video_wan2_2_14B_i2v'
/** External footnote destinations — rendered as real links (open in a new tab). */
const QUESTIONS_URL = 'https://portal.usepylon.com/comfy-org/forms/question'
const ENTERPRISE_URL = 'https://www.comfy.org/enterprise'
const PRICING_URL = 'https://www.comfy.org/pricing'
/** Videos-per-credit ratio is constant across tiers; reuse it for the team
* plan's template-based estimate until the BE carries a team figure. */
const VIDEO_PER_CREDIT =
TIER_PRICING.pro.videoEstimate / TIER_PRICING.pro.credits
interface BillingCycleOption {
label: string
value: BillingCycle
}
interface PlanScopeOption {
label: string
value: 'personal' | 'team'
}
interface PricingTierConfig {
id: SubscriptionTier
key: CheckoutTierKey
name: string
pricing: TierPricing
/** "What's included:" (Standard) or "Everything in {prev}, plus:". */
featuresHeader: string
features: string[]
isPopular?: boolean
}
// Billing-cycle toggle: the active option is a solid white pill (DES-197).
const toggleButtonPt = {
root: {
class: 'flex gap-1 bg-secondary-background rounded-lg p-1.5'
},
pcToggleButton: {
root: ({ context }: ToggleButtonPassThroughMethodOptions) => ({
class: [
// min-w keeps Yearly (with its discount badge) and Monthly the same
// width so the active pill doesn't resize when toggling (DES QA).
'h-8 min-w-44 px-5 rounded-md transition-colors cursor-pointer border-none outline-none ring-0 text-sm font-medium flex items-center justify-center',
context.active
? 'bg-base-foreground text-base-background'
: 'bg-transparent text-muted-foreground hover:bg-secondary-background-hover'
]
}),
label: { class: 'flex items-center gap-2 ' }
}
}
// Plan-scope toggle (For Personal / For Teams): active is a subtle raised pill,
// not the solid white of the billing toggle (DES-197 2951:592113).
const planScopeButtonPt = {
// No pill container (DES "Plan Type Tabs" 2812:818371 has no bg) — just the
// tabs, so the active base-background tab sits flush on top of the content area.
root: {
class: 'flex gap-1'
},
pcToggleButton: {
root: ({ context }: ToggleButtonPassThroughMethodOptions) => ({
class: [
'h-8 px-4 rounded-t-md transition cursor-pointer border-none outline-none ring-0 text-sm font-medium flex items-center justify-center',
// Inactive tab is the active tab at half opacity (DES QA) — same fill
// and text, faded as one, not a separate muted colour.
context.active
? 'bg-base-background text-base-foreground'
: 'bg-base-background text-base-foreground opacity-50 hover:opacity-100'
]
}),
label: { class: 'flex items-center gap-2' }
}
}
const planScopeOptions: PlanScopeOption[] = [
{ label: t('subscription.planScope.personal'), value: 'personal' },
{ label: t('subscription.planScope.team'), value: 'team' }
]
const billingCycleOptions: BillingCycleOption[] = [
{ label: t('subscription.yearly'), value: 'yearly' },
{ label: t('subscription.monthly'), value: 'monthly' }
]
/** Team-plan "Details" column perks (DES-197), shown under "Everything in Pro". */
const teamDetailPerks: string[] = [
t('subscription.teamPlan.perkInviteMembers'),
t('subscription.teamPlan.perkConcurrentRuns'),
t('subscription.teamPlan.perkSharedPool'),
t('subscription.teamPlan.perkRolePermissions')
]
const tiers: PricingTierConfig[] = [
{
id: 'STANDARD',
key: 'standard',
name: t('subscription.tiers.standard.name'),
pricing: TIER_PRICING.standard,
featuresHeader: t('subscription.whatsIncluded'),
features: [
t('subscription.tiers.standard.feature1'),
t('subscription.tiers.standard.feature2')
],
isPopular: false
},
{
id: 'CREATOR',
key: 'creator',
name: t('subscription.tiers.creator.name'),
pricing: TIER_PRICING.creator,
featuresHeader: t('subscription.everythingInPlus', {
plan: t('subscription.tiers.standard.name')
}),
features: [t('subscription.tiers.creator.feature1')],
isPopular: true
},
{
id: 'PRO',
key: 'pro',
name: t('subscription.tiers.pro.name'),
pricing: TIER_PRICING.pro,
featuresHeader: t('subscription.everythingInPlus', {
plan: t('subscription.tiers.creator.name')
}),
features: [t('subscription.tiers.pro.feature1')],
isPopular: false
}
]
const {
plans: apiPlans,
currentPlanSlug,
fetchPlans,
subscription,
currentTeamCreditStop
} = useBillingContext()
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
const currentBillingCycle = ref<BillingCycle>('yearly')
// Team plan selection (slider). Stop -> slug mapping is BE-blocked (see emit).
const teamUsd = ref<number>(
TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].usd
)
const teamCredits = ref<number>(
TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].credits
)
const teamVideoEstimate = computed(() =>
Math.round(teamCredits.value * VIDEO_PER_CREDIT)
)
// The team's currently-subscribed stop (null when on no team plan). Matched to
// the slider stops by list price so the current stop can be disabled.
const isTeamSubscribed = computed(() => currentTeamCreditStop.value !== null)
const currentTeamStopIndex = computed(() => {
const usd = currentTeamCreditStop.value?.stop_usd
if (usd == null) return null
const i = TEAM_PLAN_CREDIT_STOPS.findIndex((stop) => stop.usd === usd)
return i === -1 ? null : i
})
// Start the slider on the current stop so an active subscriber sees their plan
// (disabled) and must move off it to change.
watch(
currentTeamCreditStop,
(stop) => {
if (!stop) return
teamUsd.value = stop.stop_usd
teamCredits.value = stop.credits_monthly
},
{ immediate: true }
)
// The CTA — not the slider stop — reflects the current plan: on the active stop
// it reads "Current plan" (disabled); a cancelled plan re-subscribes on its
// stop. Any other stop is locked because the credit stop can't be changed.
const isTeamCurrentStopSelected = computed(
() =>
currentTeamStopIndex.value !== null &&
TEAM_PLAN_CREDIT_STOPS[currentTeamStopIndex.value]?.usd === teamUsd.value
)
// Yearly and monthly at the same credit stop are distinct plans, so toggling
// the cycle is a change, not the current plan.
const subscribedCycle = computed<BillingCycle>(() =>
subscription.value?.duration === 'MONTHLY' ? 'monthly' : 'yearly'
)
const isTeamCurrentPlanSelected = computed(
() =>
isTeamCurrentStopSelected.value &&
currentBillingCycle.value === subscribedCycle.value
)
const teamButtonLabel = computed(() => {
if (!isTeamSubscribed.value) {
return currentBillingCycle.value === 'yearly'
? t('subscription.teamPlan.cta')
: t('subscription.teamPlan.ctaMonthly')
}
// The exact current plan re-subscribes (cancelled) or reads "Current plan"
// (active); any other stop or cycle is a change.
if (isTeamCurrentPlanSelected.value) {
return isCancelled.value
? t('subscription.resubscribe')
: t('subscription.teamPlan.currentPlan')
}
return t('subscription.teamPlan.changePlan')
})
const isTeamButtonDisabled = computed(
() =>
isLoading ||
(isTeamSubscribed.value &&
isTeamCurrentPlanSelected.value &&
!isCancelled.value)
)
onMounted(() => {
void fetchPlans()
})
function getApiPlanForTier(
tierKey: CheckoutTierKey,
duration: BillingCycle
): Plan | undefined {
const apiDuration = duration === 'yearly' ? 'ANNUAL' : 'MONTHLY'
const apiTier = tierKey.toUpperCase() as Plan['tier']
return apiPlans.value.find(
(p) => p.tier === apiTier && p.duration === apiDuration
)
}
function getPriceFromApi(tier: PricingTierConfig): number | null {
const plan = getApiPlanForTier(tier.key, currentBillingCycle.value)
if (!plan) return null
const price = plan.price_cents / 100
return currentBillingCycle.value === 'yearly' ? price / 12 : price
}
const currentTierKey = computed<TierKey | null>(() =>
subscription.value?.tier ? TIER_TO_KEY[subscription.value.tier] : null
)
const isYearlySubscription = computed(
() => subscription.value?.duration === 'ANNUAL'
)
const isCurrentPlan = (tierKey: CheckoutTierKey): boolean => {
if (currentPlanSlug.value) {
const plan = getApiPlanForTier(tierKey, currentBillingCycle.value)
return plan?.slug === currentPlanSlug.value
}
if (!currentTierKey.value) return false
const selectedIsYearly = currentBillingCycle.value === 'yearly'
return (
currentTierKey.value === tierKey &&
isYearlySubscription.value === selectedIsYearly
)
}
const getButtonLabel = (tier: PricingTierConfig): string => {
const planName =
currentBillingCycle.value === 'yearly'
? t('subscription.tierNameYearly', { name: tier.name })
: tier.name
if (isCurrentPlan(tier.key)) {
return isCancelled.value
? t('subscription.resubscribeTo', { plan: planName })
: t('subscription.currentPlan')
}
// Free tier is not a paid plan to "change" from — those users subscribe.
const hasActivePaidPlan =
currentTierKey.value !== null && currentTierKey.value !== 'free'
return hasActivePaidPlan
? t('subscription.changeTo', { plan: planName })
: t('subscription.subscribeTo', { plan: planName })
}
const getButtonSeverity = (
tier: PricingTierConfig
): 'primary' | 'secondary' => {
if (isCurrentPlan(tier.key)) {
return isCancelled.value ? 'primary' : 'secondary'
}
if (tier.key === 'creator') return 'primary'
return 'secondary'
}
const isButtonDisabled = (tier: PricingTierConfig): boolean => {
if (isLoading) return true
if (isCurrentPlan(tier.key)) {
return !isCancelled.value
}
return false
}
const getButtonTextClass = (tier: PricingTierConfig): string =>
tier.key === 'creator'
? 'font-inter text-sm font-bold leading-normal text-base-background'
: 'font-inter text-sm font-bold leading-normal text-primary-foreground'
const getPrice = (tier: PricingTierConfig): number =>
getPriceFromApi(tier) ?? tier.pricing[currentBillingCycle.value]
const getMonthlyPrice = (tier: PricingTierConfig): number => {
const plan = getApiPlanForTier(tier.key, 'monthly')
return plan ? plan.price_cents / 100 : tier.pricing.monthly
}
const getAnnualTotal = (tier: PricingTierConfig): number => {
const plan = getApiPlanForTier(tier.key, 'yearly')
return plan ? plan.price_cents / 100 : tier.pricing.yearly * 12
}
function handleSubscribe(tierKey: CheckoutTierKey) {
if (isLoading) return
if (isCurrentPlan(tierKey)) {
if (isCancelled.value) {
emit('resubscribe')
}
return
}
emit('subscribe', { tierKey, billingCycle: currentBillingCycle.value })
}
function onTeamChange(stop: { index: number; usd: number; credits: number }) {
teamUsd.value = stop.usd
teamCredits.value = stop.credits
}
function handleSubscribeTeam() {
if (isTeamButtonDisabled.value) return
// Re-subscribe only when keeping the exact current plan; any other stop or
// cycle is a change.
if (isCancelled.value && isTeamCurrentPlanSelected.value) {
emit('resubscribe')
return
}
emit('subscribeTeam', {
usd: teamUsd.value,
credits: teamCredits.value,
discountedUsd: getDiscountedMonthlyUsd(
teamUsd.value,
currentBillingCycle.value
)
})
}
function handleViewEnterprise() {
window.open(ENTERPRISE_URL, '_blank')
}
</script>

View File

@@ -52,10 +52,12 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const { isActiveSubscription } = useBillingContext()
const subscriptionDialog = useSubscriptionDialog()
function onDismiss() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
@@ -63,6 +65,6 @@ function onDismiss() {
function onUpgrade() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
showSubscriptionDialog()
subscriptionDialog.show({ planMode: 'team' })
}
</script>

View File

@@ -14,7 +14,7 @@ import type {
const mockHandleCopyInviteLink = vi.fn()
const mockHandleRevokeInvite = vi.fn()
const mockHandleCreateWorkspace = vi.fn()
const mockShowSubscriptionDialog = vi.fn()
const mockShowTeamPlans = vi.fn()
const mockSelectMember = vi.fn()
const mockToggleSort = vi.fn()
@@ -99,7 +99,7 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
m.email.toLowerCase() === 'owner@example.com',
selectMember: mockSelectMember,
toggleSort: mockToggleSort,
showSubscriptionDialog: mockShowSubscriptionDialog,
showTeamPlans: mockShowTeamPlans,
handleCopyInviteLink: mockHandleCopyInviteLink,
handleRevokeInvite: mockHandleRevokeInvite,
handleCreateWorkspace: mockHandleCreateWorkspace,
@@ -320,13 +320,13 @@ describe('MembersPanelContent', () => {
).toBeTruthy()
})
it('opens subscription dialog on view plans click', async () => {
it('opens team plans on view plans click', async () => {
renderComponent()
const viewPlansBtn = screen.getByRole('button', {
name: /workspacePanel\.members\.viewPlans/
})
await userEvent.click(viewPlansBtn)
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
expect(mockShowTeamPlans).toHaveBeenCalled()
})
it('hides search input', () => {

View File

@@ -168,7 +168,7 @@
<MemberUpsellBanner
v-if="isSingleSeatPlan"
:is-active-subscription="isActiveSubscription"
@show-plans="showSubscriptionDialog()"
@show-plans="showTeamPlans()"
/>
<!-- Pending Invites -->
@@ -229,7 +229,7 @@ const {
isCurrentUser,
selectMember,
toggleSort,
showSubscriptionDialog,
showTeamPlans,
handleCopyInviteLink,
handleRevokeInvite,
handleCreateWorkspace

View File

@@ -321,6 +321,13 @@ vi.mock('@/platform/cloud/subscription/constants/tierPricing', () => ({
}
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({ show: vi.fn() })
})
)
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showRemoveMemberDialog: mockShowRemoveMemberDialog,

View File

@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
@@ -84,12 +85,9 @@ export function useMembersPanel() {
} = storeToRefs(workspaceStore)
const { copyInviteLink } = workspaceStore
const { permissions, uiConfig } = useWorkspaceUI()
const {
isActiveSubscription,
subscription,
showSubscriptionDialog,
getMaxSeats
} = useBillingContext()
const { isActiveSubscription, subscription, getMaxSeats } =
useBillingContext()
const subscriptionDialog = useSubscriptionDialog()
const maxSeats = computed(() => {
if (isPersonalWorkspace.value) return 1
@@ -189,6 +187,10 @@ export function useMembersPanel() {
void showRemoveMemberDialog(member.id)
}
function showTeamPlans() {
subscriptionDialog.show({ planMode: 'team' })
}
return {
searchQuery,
activeView,
@@ -211,7 +213,7 @@ export function useMembersPanel() {
isCurrentUser,
selectMember,
toggleSort,
showSubscriptionDialog,
showTeamPlans,
handleCopyInviteLink,
handleRevokeInvite,
handleCreateWorkspace,

View File

@@ -235,6 +235,27 @@ describe('useSubscriptionCheckout', () => {
})
})
describe('handleSubscribeTeamClick', () => {
it('transitions to preview with the selected team stop', async () => {
const checkout = await setup()
checkout.handleSubscribeTeamClick({
usd: 400,
credits: 84_400,
discountedUsd: 380
})
expect(checkout.checkoutStep.value).toBe('preview')
expect(checkout.selectedTeamStop.value).toStrictEqual({
usd: 400,
credits: 84_400,
discountedUsd: 380
})
expect(checkout.previewData.value).toBeNull()
expect(checkout.selectedTierKey.value).toBeNull()
})
})
describe('handleBackToPricing', () => {
it('resets to pricing step and clears preview data', async () => {
const checkout = await setup()
@@ -246,10 +267,24 @@ describe('useSubscriptionCheckout', () => {
expect(checkout.checkoutStep.value).toBe('pricing')
expect(checkout.previewData.value).toBeNull()
})
it('clears the selected team stop', async () => {
const checkout = await setup()
checkout.handleSubscribeTeamClick({
usd: 400,
credits: 84_400,
discountedUsd: 380
})
checkout.handleBackToPricing()
expect(checkout.checkoutStep.value).toBe('pricing')
expect(checkout.selectedTeamStop.value).toBeNull()
})
})
describe('handleAddCreditCard', () => {
it('emits close on subscribed status', async () => {
it('transitions to success step on subscribed status', async () => {
const checkout = await setup()
checkout.selectedTierKey.value = 'standard'
checkout.selectedBillingCycle.value = 'yearly'
@@ -267,7 +302,7 @@ describe('useSubscriptionCheckout', () => {
'https://platform.comfy.org/payment/success',
'https://platform.comfy.org/payment/failed'
)
expect(emit).toHaveBeenCalledWith('close', true)
expect(checkout.checkoutStep.value).toBe('success')
})
it('opens payment URL when needs_payment_method', async () => {
@@ -305,7 +340,7 @@ describe('useSubscriptionCheckout', () => {
})
describe('handleConfirmTransition', () => {
it('emits close on subscribed status', async () => {
it('transitions to success step on subscribed status', async () => {
const checkout = await setup()
checkout.selectedTierKey.value = 'standard'
checkout.selectedBillingCycle.value = 'yearly'
@@ -318,7 +353,7 @@ describe('useSubscriptionCheckout', () => {
await checkout.handleConfirmTransition()
expect(emit).toHaveBeenCalledWith('close', true)
expect(checkout.checkoutStep.value).toBe('success')
})
it('shows error toast on failure', async () => {

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { useTelemetry } from '@/platform/telemetry'
@@ -13,7 +14,7 @@ import type {
} from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
type CheckoutStep = 'pricing' | 'preview'
type CheckoutStep = 'pricing' | 'preview' | 'success'
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
export function findPlanSlug(
@@ -52,6 +53,7 @@ export function useSubscriptionCheckout(emit: {
const isResubscribing = ref(false)
const previewData = ref<PreviewSubscribeResponse | null>(null)
const selectedTierKey = ref<CheckoutTierKey | null>(null)
const selectedTeamStop = ref<TeamPlanSelection | null>(null)
const selectedBillingCycle = ref<BillingCycle>('yearly')
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
@@ -112,9 +114,26 @@ export function useSubscriptionCheckout(emit: {
}
}
/**
* Team-plan checkout entry: show the "Confirm your payment" step rendered
* from the selected slider stop. The final subscribe call stays stubbed in
* the host until the BE slider contract lands (FE-934 / doc Open Q#2).
*/
function handleSubscribeTeamClick(payload: TeamPlanSelection) {
selectedTeamStop.value = payload
selectedTierKey.value = null
previewData.value = null
checkoutStep.value = 'preview'
}
function handleBackToPricing() {
checkoutStep.value = 'pricing'
previewData.value = null
selectedTeamStop.value = null
}
function handleSuccessClose() {
emit('close', true)
}
async function handleSubscription() {
@@ -137,13 +156,8 @@ export function useSubscriptionCheckout(emit: {
if (response.status === 'subscribed') {
telemetry?.trackMonthlySubscriptionSucceeded()
toast.add({
severity: 'success',
summary: t('subscription.required.pollingSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
emit('close', true)
checkoutStep.value = 'success'
} else if (
response.status === 'needs_payment_method' &&
response.payment_method_url
@@ -203,10 +217,13 @@ export function useSubscriptionCheckout(emit: {
isResubscribing,
previewData,
selectedTierKey,
selectedTeamStop,
selectedBillingCycle,
isPolling,
handleSubscribeClick,
handleSubscribeTeamClick,
handleBackToPricing,
handleSuccessClose,
handleAddCreditCard: handleSubscription,
handleConfirmTransition: handleSubscription,
handleResubscribe

View File

@@ -82,6 +82,10 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
const currentPlanSlug = computed(
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
)
const teamCreditStops = computed(() => billingPlans.teamCreditStops.value)
const currentTeamCreditStop = computed(
() => statusData.value?.team_credit_stop ?? null
)
async function initialize(): Promise<void> {
if (isInitialized.value) return
@@ -287,6 +291,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
balance,
plans,
currentPlanSlug,
teamCreditStops,
currentTeamCreditStop,
isLoading,
error,
isActiveSubscription,

View File

@@ -0,0 +1,95 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import { fromPartial } from '@total-typescript/shoehorn'
vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => ({ inputIsWidget: () => true })
}))
// Serializes the nodeData prop so tests can assert on the data contract
// LGraphNodePreview hands to NodeWidgets. How that data renders is covered
// by NodeWidgets.test.ts and browser_tests/tests/sidebar/modelLibrary.spec.ts.
const NodeWidgetsProbe = {
props: ['nodeData'],
template: '<div data-testid="node-data">{{ JSON.stringify(nodeData) }}</div>'
}
interface ProbedWidget {
name: string
value?: unknown
options?: { values?: string[] }
}
const nodeDef = fromPartial<ComfyNodeDefV2>({
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
inputs: {
ckpt_name: { type: 'COMBO', options: ['a.safetensors', 'b.safetensors'] }
},
outputs: []
})
function renderedWidgets(
def: ComfyNodeDefV2,
props: { widgetValues?: Record<string, string> } = {}
) {
render(LGraphNodePreview, {
props: { nodeDef: def, ...props },
global: {
stubs: {
NodeHeader: true,
NodeSlots: true,
NodeWidgets: NodeWidgetsProbe
}
}
})
const nodeData: { widgets?: ProbedWidget[] } = JSON.parse(
screen.getByTestId('node-data').textContent ?? ''
)
return nodeData.widgets ?? []
}
function renderedComboWidget(
props: { widgetValues?: Record<string, string> } = {}
) {
return renderedWidgets(nodeDef, props).find((w) => w.name === 'ckpt_name')
}
describe('LGraphNodePreview', () => {
it('leads the combo options with the provided widget value', () => {
const widget = renderedComboWidget({
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' }
})
expect(widget?.options?.values).toEqual([
'sd_xl_base_1.0.safetensors',
'a.safetensors',
'b.safetensors'
])
})
it('keeps the combo options untouched when no value is provided', () => {
const widget = renderedComboWidget()
expect(widget?.options?.values).toEqual(['a.safetensors', 'b.safetensors'])
})
it('uses the input default when defined and empty string otherwise', () => {
const widgets = renderedWidgets(
fromPartial<ComfyNodeDefV2>({
name: 'TestNode',
inputs: {
steps: { type: 'INT', default: 20 },
text: { type: 'STRING' }
},
outputs: []
})
)
expect(widgets.find((w) => w.name === 'steps')?.value).toBe(20)
expect(widgets.find((w) => w.name === 'text')?.value).toBe('')
})
})

View File

@@ -45,9 +45,14 @@ import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSc
import { useWidgetStore } from '@/stores/widgetStore'
import { cn } from '@comfyorg/tailwind-utils'
const { nodeDef, position = 'absolute' } = defineProps<{
const {
nodeDef,
position = 'absolute',
widgetValues
} = defineProps<{
nodeDef: ComfyNodeDefV2
position?: 'absolute' | 'relative'
widgetValues?: Record<string, string>
}>()
const widgetStore = useWidgetStore()
@@ -56,27 +61,32 @@ const widgetStore = useWidgetStore()
const nodeData = computed<VueNodeData>(() => {
const widgets = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => widgetStore.inputIsWidget(input))
.map(([name, input]) => ({
nodeId: '-1',
name,
type: input.widgetType || input.type,
value:
input.default !== undefined
? input.default
: input.type === 'COMBO' &&
Array.isArray(input.options) &&
input.options.length > 0
? input.options[0]
: '',
options: {
hidden: input.hidden,
advanced: input.advanced,
values:
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
} satisfies IWidgetOptions
}))
.map(([name, input]) => {
const comboValues =
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
// Preview nodes have no widget-value store entry, so combo widgets
// render their first option; lead with the requested value to show it.
const leadValue = widgetValues?.[name]
return {
nodeId: '-1',
name,
type: input.widgetType || input.type,
value:
input.default !== undefined
? input.default
: (comboValues?.[0] ?? ''),
options: {
hidden: input.hidden,
advanced: input.advanced,
values:
leadValue && comboValues
? [leadValue, ...comboValues.filter((o) => o !== leadValue)]
: comboValues
} satisfies IWidgetOptions
}
})
const inputs: INodeInputSlot[] = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => !widgetStore.inputIsWidget(input))

View File

@@ -0,0 +1,50 @@
import { computed, ref } from 'vue'
import type { BillingContext } from '@/composables/billing/types'
/**
* Storybook mock for `useBillingContext`.
*
* The real facade lazily instantiates the legacy billing adapter, which pulls
* in Firebase auth (`setPersistence`) and crashes in the Storybook environment
* (no Firebase). This static stub lets presentational billing components — e.g.
* UnifiedPricingTable — render against their `TIER_PRICING` / DES-197 fallbacks
* without any network or auth.
*
* Typed against `BillingContext` so the stub stays in lockstep with the real
* composable's return shape: drifted or removed keys fail to compile.
*/
export function useBillingContext(): BillingContext {
return {
type: computed(() => 'legacy' as const),
isInitialized: ref(true),
subscription: computed(() => null),
balance: computed(() => null),
plans: computed(() => []),
currentPlanSlug: computed(() => null),
teamCreditStops: computed(() => null),
currentTeamCreditStop: computed(() => null),
isLoading: ref(false),
error: ref<string | null>(null),
isActiveSubscription: computed(() => false),
isFreeTier: computed(() => false),
isLegacyTeamPlan: computed(() => false),
billingStatus: computed(() => null),
subscriptionStatus: computed(() => null),
tier: computed(() => null),
renewalDate: computed(() => null),
getMaxSeats: () => 1,
initialize: async () => {},
fetchStatus: async () => {},
fetchBalance: async () => {},
subscribe: async () => {},
previewSubscribe: async () => null,
manageSubscription: async () => {},
cancelSubscription: async () => {},
resubscribe: async () => {},
topup: async () => {},
fetchPlans: async () => {},
requireActiveSubscription: async () => {},
showSubscriptionDialog: () => {}
}
}