Skip to content

feat: add Free subscription tier support - #8864

Merged
huntcsg merged 21 commits into
mainfrom
free-tier-initial
Feb 25, 2026
Merged

feat: add Free subscription tier support#8864
huntcsg merged 21 commits into
mainfrom
free-tier-initial

Conversation

@huntcsg

@huntcsg huntcsg commented Feb 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Add frontend support for a Free subscription tier — login/signup page restructuring, telemetry instrumentation, and tier-aware billing gating.

Changes

  • What:
    • Restructure login/signup pages: OAuth buttons promoted as primary sign-in method, email login available via progressive disclosure
    • Add Free tier badge on Google sign-up button with dynamic credit count from remote config
    • Add FREE subscription tier to type system (tier pricing, tier rank, registry types)
    • Add isFreeTier computed to useSubscription()
    • Disable credit top-up for Free tier users (dialogService, purchaseCredits, popover CTA)
    • Show subscription/upgrade dialog instead of top-up dialog when Free tier user hits out-of-credits
    • Add funnel telemetry: trackLoginOpened, enrich trackSignupOpened with free_tier_badge_shown, track email toggle clicks

Review Focus

  • Tier gating logic: Free tier users should see "Upgrade" instead of "Add Credits" and never reach the top-up flow
  • Telemetry event design for Mixpanel funnel analysis
  • Progressive disclosure UX on login/signup pages

┆Issue is synchronized with this Notion page by Unito

@github-actions

github-actions Bot commented Feb 14, 2026

Copy link
Copy Markdown

🎭 Playwright: ✅ 543 passed, 0 failed · 7 flaky

📊 Browser Reports
  • chromium: View Report (✅ 530 / ❌ 0 / ⚠️ 7 / ⏭️ 10)
  • chromium-2x: View Report (✅ 2 / ❌ 0 / ⚠️ 0 / ⏭️ 0)
  • chromium-0.5x: View Report (✅ 1 / ❌ 0 / ⚠️ 0 / ⏭️ 0)
  • mobile-chrome: View Report (✅ 10 / ❌ 0 / ⚠️ 0 / ⏭️ 0)

@github-actions

github-actions Bot commented Feb 14, 2026

Copy link
Copy Markdown

🎨 Storybook: ✅ Built — View Storybook

Details

⏰ Completed at: 02/25/2026, 04:04:29 AM UTC

Links

@coderabbitai

coderabbitai Bot commented Feb 14, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request introduces support for a FREE subscription tier across the platform. Changes add 'FREE' to subscription tier type definitions, update subscription-related composables to expose a new isFreeTier computed property, modify login and signup views to conditionally display free tier badging and UI flows, extend tier configuration constants, add localization strings for free tier features, enhance subscription dialogs to support multiple failure reasons, integrate free tier telemetry tracking, and add localStorage account detection.

Changes

Cohort / File(s) Summary
Type Definitions & Tier Configuration
packages/registry-types/src/comfyRegistryTypes.ts, src/platform/workspace/api/workspaceApi.ts, src/platform/cloud/subscription/constants/tierPricing.ts
Added 'FREE' to SubscriptionTier union types; expanded TierKey to include 'free'; added mappings (TIER_TO_KEY, KEY_TO_TIER); introduced TIER_FEATURES and DEFAULT_TIER_KEY for free tier with 0 credits and no custom LoRAs.
Subscription Composition & State
src/platform/cloud/subscription/composables/useSubscription.ts, src/stores/firebaseAuthStore.ts
Added computed isFreeTier property exported from useSubscription; added HAS_ACCOUNT_KEY localStorage tracking on auth state changes.
Subscription Utilities & Helpers
src/platform/cloud/subscription/utils/subscriptionTierRank.ts, src/platform/cloud/subscription/components/PricingTable.vue, src/platform/remoteConfig/types.ts
Updated RankedTierKey to exclude 'free' tier from rankings; excluded 'free' from CheckoutTierKey; added optional free_tier_credits field to RemoteConfig configuration.
Cloud Onboarding UI
src/platform/cloud/onboarding/CloudLoginView.vue, src/platform/cloud/onboarding/CloudSignupView.vue, src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue
Implemented two-state UI (social OAuth vs email form) controlled by showEmailForm state; added free tier badge and dynamic description based on freeTierCredits; added telemetry tracking for mode toggles; updated tierDisplayName mapping to include 'free' tier.
Subscription UI Components
src/components/topbar/CurrentUserPopoverLegacy.vue, src/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue, src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue
Added conditional upgrade button for free tier users in popover; introduced optional reason prop to dialog components to handle 'out_of_credits' scenario with distinct messaging.
Subscription Dialog Management
src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
Extended show() method signature to accept optional reason parameter; propagates reason to dialog props for conditional rendering.
Localization Strings
src/locales/en/main.json
Added 30+ new keys for free tier support: login flow (useEmailInstead, freeTierBadge, freeTierDescription), signup flow (emailNotEligibleForFreeTier), enterprise view (freeTier block), subscription tier names and durations, and benefits descriptions.
Telemetry Integration
src/platform/telemetry/types.ts, src/platform/telemetry/TelemetryRegistry.ts, src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts
Added free_tier_badge_shown field to AuthMetadata; introduced SignupPageMetadata interface; added new trackLoginOpened() method; updated trackSignupOpened() to accept optional metadata parameter; updated provider implementations.
Tests
src/components/topbar/CurrentUserPopoverLegacy.test.ts
Updated mock to assert showPricingTable invocation instead of show for Plans & Pricing action.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A free tier hops into the system with glee,
New badges and buttons for all users to see,
Login flows flourish with choices so bright,
While telemetry tracks every free-tier delight,
The tier configurations multiply just right!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.48% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding Free subscription tier support.
Description check ✅ Passed The description covers all required template sections: Summary, Changes (with What and no breaking changes), and Review Focus. All critical information is present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch free-tier-initial

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Feb 14, 2026

Copy link
Copy Markdown

📦 Bundle: 4.41 MB gzip 🔴 +4.64 kB

Details

Summary

  • Raw size: 20.7 MB baseline 20.7 MB — 🔴 +15.4 kB
  • Gzip: 4.41 MB baseline 4.4 MB — 🔴 +4.64 kB
  • Brotli: 3.4 MB baseline 3.4 MB — 🔴 +4.21 kB
  • Bundles: 227 current • 225 baseline • 130 added / 128 removed

Category Glance
Other 🔴 +13 kB (7.69 MB) · Views & Navigation 🔴 +3.4 kB (72.1 kB) · Utilities & Hooks 🟢 -2.75 kB (55.5 kB) · Data & Services 🔴 +2.11 kB (2.54 MB) · Panels & Settings 🟢 -489 B (435 kB) · UI Components 🔴 +130 B (47.1 kB) · + 5 more

App Entry Points — 17.9 kB (baseline 17.9 kB) • ⚪ 0 B

Main entry bundles and manifests

File Before After Δ Raw Δ Gzip Δ Brotli
assets/index-CxhrFmYg.js (removed) 17.9 kB 🟢 -17.9 kB 🟢 -6.34 kB 🟢 -5.5 kB
assets/index-fMJC8KC7.js (new) 17.9 kB 🔴 +17.9 kB 🔴 +6.34 kB 🔴 +5.51 kB

Status: 1 added / 1 removed

Graph Workspace — 970 kB (baseline 970 kB) • 🔴 +31 B

Graph editor runtime, canvas, workflow orchestration

File Before After Δ Raw Δ Gzip Δ Brotli
assets/GraphView-DScGK6_w.js (new) 970 kB 🔴 +970 kB 🔴 +209 kB 🔴 +158 kB
assets/GraphView-hxb19EFm.js (removed) 970 kB 🟢 -970 kB 🟢 -209 kB 🟢 -158 kB

Status: 1 added / 1 removed

Views & Navigation — 72.1 kB (baseline 68.8 kB) • 🔴 +3.4 kB

Top-level views, pages, and routed surfaces

File Before After Δ Raw Δ Gzip Δ Brotli
assets/CloudSurveyView-B-e9B_x-.js (removed) 15.5 kB 🟢 -15.5 kB 🟢 -3.32 kB 🟢 -2.82 kB
assets/CloudSurveyView-BO7Pkb72.js (new) 15.5 kB 🔴 +15.5 kB 🔴 +3.32 kB 🔴 +2.82 kB
assets/CloudLoginView-CBtM2orc.js (new) 11.4 kB 🔴 +11.4 kB 🔴 +3.19 kB 🔴 +2.83 kB
assets/CloudLoginView-Dw3k8D4Z.js (removed) 10 kB 🟢 -10 kB 🟢 -2.93 kB 🟢 -2.58 kB
assets/CloudSignupView-BcfsvrGs.js (new) 9.37 kB 🔴 +9.37 kB 🔴 +2.69 kB 🔴 +2.37 kB
assets/UserCheckView-BQRPOiAi.js (removed) 8.41 kB 🟢 -8.41 kB 🟢 -2.23 kB 🟢 -1.94 kB
assets/UserCheckView-EIXojJ5T.js (new) 8.41 kB 🔴 +8.41 kB 🔴 +2.23 kB 🔴 +1.94 kB
assets/CloudSignupView-tV0f9Yys.js (removed) 7.41 kB 🟢 -7.41 kB 🟢 -2.32 kB 🟢 -2.04 kB
assets/CloudLayoutView-C1C2JdBA.js (removed) 6.43 kB 🟢 -6.43 kB 🟢 -2.1 kB 🟢 -1.82 kB
assets/CloudLayoutView-DP5CnxbE.js (new) 6.43 kB 🔴 +6.43 kB 🔴 +2.1 kB 🔴 +1.82 kB
assets/CloudForgotPasswordView-DnToTTOC.js (removed) 5.56 kB 🟢 -5.56 kB 🟢 -1.93 kB 🟢 -1.7 kB
assets/CloudForgotPasswordView-sqheZUzP.js (new) 5.56 kB 🔴 +5.56 kB 🔴 +1.93 kB 🔴 +1.7 kB
assets/CloudAuthTimeoutView-BCZz8E90.js (removed) 4.91 kB 🟢 -4.91 kB 🟢 -1.77 kB 🟢 -1.55 kB
assets/CloudAuthTimeoutView-D74Qn2LF.js (new) 4.91 kB 🔴 +4.91 kB 🔴 +1.77 kB 🔴 +1.55 kB
assets/CloudSubscriptionRedirectView-Cdrta-IJ.js (new) 4.75 kB 🔴 +4.75 kB 🔴 +1.79 kB 🔴 +1.58 kB
assets/CloudSubscriptionRedirectView-x_Mzgm8X.js (removed) 4.71 kB 🟢 -4.71 kB 🟢 -1.78 kB 🟢 -1.57 kB
assets/UserSelectView-B6oT28lO.js (new) 4.5 kB 🔴 +4.5 kB 🔴 +1.64 kB 🔴 +1.47 kB
assets/UserSelectView-mHGy6TLV.js (removed) 4.5 kB 🟢 -4.5 kB 🟢 -1.64 kB 🟢 -1.47 kB
assets/layout-B9Qr_k9X.js (removed) 296 B 🟢 -296 B 🟢 -224 B 🟢 -189 B
assets/layout-De5xw-yi.js (new) 296 B 🔴 +296 B 🔴 +223 B 🔴 +188 B
assets/CloudSorryContactSupportView-CY9LHUzS.js 1.02 kB 1.02 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B

Status: 10 added / 10 removed

Panels & Settings — 435 kB (baseline 436 kB) • 🟢 -489 B

Configuration panels, inspectors, and settings screens

File Before After Δ Raw Δ Gzip Δ Brotli
assets/SecretsPanel-C-aGR-il.js (new) 21.5 kB 🔴 +21.5 kB 🔴 +5.3 kB 🔴 +4.65 kB
assets/SecretsPanel-DwRrFpRM.js (removed) 21.5 kB 🟢 -21.5 kB 🟢 -5.31 kB 🟢 -4.66 kB
assets/LegacyCreditsPanel-Cbj-zvX3.js (removed) 20.6 kB 🟢 -20.6 kB 🟢 -5.56 kB 🟢 -4.9 kB
assets/LegacyCreditsPanel-nEm6artU.js (new) 20.6 kB 🔴 +20.6 kB 🔴 +5.56 kB 🔴 +4.9 kB
assets/SubscriptionPanel-KKUqD-2M.js (removed) 18.6 kB 🟢 -18.6 kB 🟢 -4.73 kB 🟢 -4.18 kB
assets/SubscriptionPanel-Dp8iKMQJ.js (new) 18.1 kB 🔴 +18.1 kB 🔴 +4.65 kB 🔴 +4.08 kB
assets/KeybindingPanel-C5B6hLgo.js (removed) 12.3 kB 🟢 -12.3 kB 🟢 -3.57 kB 🟢 -3.17 kB
assets/KeybindingPanel-D0SicBQt.js (new) 12.3 kB 🔴 +12.3 kB 🔴 +3.57 kB 🔴 +3.17 kB
assets/AboutPanel-C8iVMaHR.js (removed) 9.79 kB 🟢 -9.79 kB 🟢 -2.73 kB 🟢 -2.46 kB
assets/AboutPanel-D3W6mkr_.js (new) 9.79 kB 🔴 +9.79 kB 🔴 +2.73 kB 🔴 +2.47 kB
assets/ExtensionPanel-BTy3m1ug.js (removed) 9.38 kB 🟢 -9.38 kB 🟢 -2.65 kB 🟢 -2.35 kB
assets/ExtensionPanel-DAbvtk0p.js (new) 9.38 kB 🔴 +9.38 kB 🔴 +2.65 kB 🔴 +2.37 kB
assets/ServerConfigPanel-DGcV8hZe.js (removed) 6.44 kB 🟢 -6.44 kB 🟢 -2.12 kB 🟢 -1.9 kB
assets/ServerConfigPanel-K2enKiaX.js (new) 6.44 kB 🔴 +6.44 kB 🔴 +2.12 kB 🔴 +1.9 kB
assets/UserPanel-1_C36Sku.js (removed) 6.16 kB 🟢 -6.16 kB 🟢 -1.99 kB 🟢 -1.75 kB
assets/UserPanel-CKcX3M0U.js (new) 6.16 kB 🔴 +6.16 kB 🔴 +1.99 kB 🔴 +1.74 kB
assets/cloudRemoteConfig-DOcxvxEW.js (new) 1.44 kB 🔴 +1.44 kB 🔴 +705 B 🔴 +611 B
assets/cloudRemoteConfig-pUikggd8.js (removed) 1.44 kB 🟢 -1.44 kB 🟢 -705 B 🟢 -611 B
assets/refreshRemoteConfig-acPhNau5.js (new) 1.14 kB 🔴 +1.14 kB 🔴 +517 B 🔴 +471 B
assets/refreshRemoteConfig-CASTXH6S.js (removed) 1.14 kB 🟢 -1.14 kB 🟢 -522 B 🟢 -455 B
assets/config-Tm3IpjoS.js 996 B 996 B ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-axAeoUrZ.js 28.8 kB 28.8 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-B2VYgS-a.js 29.9 kB 29.9 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-BAN61mfQ.js 30.5 kB 30.5 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-BNn9zn5v.js 38.5 kB 38.5 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-CIIZ9BHS.js 28.7 kB 28.7 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-CRqB5n8m.js 34.2 kB 34.2 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-Cs_NVkbc.js 32.4 kB 32.4 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-CYbBf6ct.js 27.9 kB 27.9 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-DhTbqRj2.js 23.9 kB 23.9 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-DW8FtJHC.js 24.5 kB 24.5 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-FzjdIfFm.js 27.8 kB 27.8 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B

Status: 10 added / 10 removed

User & Accounts — 16 kB (baseline 16 kB) • ⚪ 0 B

Authentication, profile, and account management bundles

File Before After Δ Raw Δ Gzip Δ Brotli
assets/auth-BNNo8Ygr.js (removed) 3.4 kB 🟢 -3.4 kB 🟢 -1.18 kB 🟢 -986 B
assets/auth-Uo3zhZSx.js (new) 3.4 kB 🔴 +3.4 kB 🔴 +1.18 kB 🔴 +988 B
assets/SignUpForm-C6qpKmqR.js (new) 3.01 kB 🔴 +3.01 kB 🔴 +1.23 kB 🔴 +1.09 kB
assets/SignUpForm-CQOdlJgd.js (removed) 3.01 kB 🟢 -3.01 kB 🟢 -1.23 kB 🟢 -1.1 kB
assets/UpdatePasswordContent-BCYWUKTY.js (new) 2.37 kB 🔴 +2.37 kB 🔴 +1.07 kB 🔴 +942 B
assets/UpdatePasswordContent-BrUqBzIt.js (removed) 2.37 kB 🟢 -2.37 kB 🟢 -1.07 kB 🟢 -943 B
assets/WorkspaceProfilePic-CK1MFS2B.js (new) 1.57 kB 🔴 +1.57 kB 🔴 +821 B 🔴 +728 B
assets/WorkspaceProfilePic-DLDXZ3rv.js (removed) 1.57 kB 🟢 -1.57 kB 🟢 -822 B 🟢 -730 B
assets/firebaseAuthStore-a5IodHdU.js (removed) 788 B 🟢 -788 B 🟢 -383 B 🟢 -344 B
assets/firebaseAuthStore-C0QaSNrA.js (new) 788 B 🔴 +788 B 🔴 +383 B 🔴 +345 B
assets/auth-B2wPEYWL.js (removed) 357 B 🟢 -357 B 🟢 -225 B 🟢 -218 B
assets/auth-DUnhddvv.js (new) 357 B 🔴 +357 B 🔴 +223 B 🔴 +210 B
assets/PasswordFields-DLbVLg8O.js 4.51 kB 4.51 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B

Status: 6 added / 6 removed

Editors & Dialogs — 736 B (baseline 736 B) • ⚪ 0 B

Modals, dialogs, drawers, and in-app editors

File Before After Δ Raw Δ Gzip Δ Brotli
assets/useSubscriptionDialog-BKJzsRWI.js (removed) 736 B 🟢 -736 B 🟢 -380 B 🟢 -325 B
assets/useSubscriptionDialog-DjB_lluE.js (new) 736 B 🔴 +736 B 🔴 +382 B 🔴 +329 B

Status: 1 added / 1 removed

UI Components — 47.1 kB (baseline 46.9 kB) • 🔴 +130 B

Reusable component library chunks

File Before After Δ Raw Δ Gzip Δ Brotli
assets/useTerminalTabs-ByGyQW8X.js (new) 9.84 kB 🔴 +9.84 kB 🔴 +3.4 kB 🔴 +2.99 kB
assets/useTerminalTabs-RZ3uwP4x.js (removed) 9.84 kB 🟢 -9.84 kB 🟢 -3.4 kB 🟢 -3 kB
assets/ComfyQueueButton-C5ovswhX.js (new) 8.02 kB 🔴 +8.02 kB 🔴 +2.49 kB 🔴 +2.23 kB
assets/ComfyQueueButton-uFhaijOt.js (removed) 8.02 kB 🟢 -8.02 kB 🟢 -2.49 kB 🟢 -2.23 kB
assets/TopbarBadge-BvjkNHUw.js (removed) 7.44 kB 🟢 -7.44 kB 🟢 -1.81 kB 🟢 -1.6 kB
assets/TopbarBadge-DI3pRSdp.js (new) 7.44 kB 🔴 +7.44 kB 🔴 +1.81 kB 🔴 +1.6 kB
assets/FormSearchInput-BMvJlldl.js (new) 3.73 kB 🔴 +3.73 kB 🔴 +1.54 kB 🔴 +1.35 kB
assets/FormSearchInput-BNeIXugj.js (removed) 3.73 kB 🟢 -3.73 kB 🟢 -1.54 kB 🟢 -1.35 kB
assets/SubscribeButton-B2sg_ihP.js (new) 2.48 kB 🔴 +2.48 kB 🔴 +1.07 kB 🔴 +943 B
assets/SubscribeButton-vQ8YNola.js (removed) 2.35 kB 🟢 -2.35 kB 🟢 -1.02 kB 🟢 -889 B
assets/WidgetButton-C7_gkZBQ.js (new) 1.84 kB 🔴 +1.84 kB 🔴 +877 B 🔴 +779 B
assets/WidgetButton-DIN5zdNw.js (removed) 1.84 kB 🟢 -1.84 kB 🟢 -876 B 🟢 -790 B
assets/cloudFeedbackTopbarButton-Cna16B8o.js (removed) 1.59 kB 🟢 -1.59 kB 🟢 -852 B 🟢 -766 B
assets/cloudFeedbackTopbarButton-COCh22ge.js (new) 1.59 kB 🔴 +1.59 kB 🔴 +851 B 🔴 +733 B
assets/CloudBadge-3Sx8hN5O.js (new) 1.24 kB 🔴 +1.24 kB 🔴 +607 B 🔴 +526 B
assets/CloudBadge-B4ILPfa0.js (removed) 1.24 kB 🟢 -1.24 kB 🟢 -608 B 🟢 -525 B
assets/ComfyQueueButton-DkNhID7E.js (new) 793 B 🔴 +793 B 🔴 +393 B 🔴 +352 B
assets/ComfyQueueButton-LSKOj9Vc.js (removed) 793 B 🟢 -793 B 🟢 -392 B 🟢 -352 B
assets/Button-_rljyWPY.js 2.98 kB 2.98 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/ScrubableNumberInput-CVilsRHA.js 5.94 kB 5.94 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/UserAvatar-CnQQLXB-.js 1.17 kB 1.17 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B

Status: 9 added / 9 removed

Data & Services — 2.54 MB (baseline 2.54 MB) • 🔴 +2.11 kB

Stores, services, APIs, and repositories

File Before After Δ Raw Δ Gzip Δ Brotli
assets/dialogService-ZwnmTbxW.js (new) 1.73 MB 🔴 +1.73 MB 🔴 +390 kB 🔴 +294 kB
assets/dialogService-Mzys1WFC.js (removed) 1.73 MB 🟢 -1.73 MB 🟢 -390 kB 🟢 -294 kB
assets/api-CFuJoGpd.js (new) 676 kB 🔴 +676 kB 🔴 +153 kB 🔴 +121 kB
assets/api-DZ7QgD8K.js (removed) 676 kB 🟢 -676 kB 🟢 -153 kB 🟢 -121 kB
assets/load3dService-20pEURMM.js (new) 91 kB 🔴 +91 kB 🔴 +19.1 kB 🔴 +16.4 kB
assets/load3dService-Dt3DNigH.js (removed) 91 kB 🟢 -91 kB 🟢 -19.1 kB 🟢 -16.4 kB
assets/extensionStore-BVWfu_Ey.js (removed) 12.1 kB 🟢 -12.1 kB 🟢 -4.21 kB 🟢 -3.7 kB
assets/extensionStore-I83SQUN4.js (new) 12.1 kB 🔴 +12.1 kB 🔴 +4.21 kB 🔴 +3.7 kB
assets/releaseStore-BS20M4l-.js (new) 7.96 kB 🔴 +7.96 kB 🔴 +2.22 kB 🔴 +1.95 kB
assets/releaseStore-CKdF9-ow.js (removed) 7.96 kB 🟢 -7.96 kB 🟢 -2.22 kB 🟢 -1.95 kB
assets/keybindingService-DQbmeJZZ.js (removed) 6.52 kB 🟢 -6.52 kB 🟢 -1.71 kB 🟢 -1.47 kB
assets/keybindingService-DtkPixTM.js (new) 6.52 kB 🔴 +6.52 kB 🔴 +1.71 kB 🔴 +1.47 kB
assets/serverConfigStore-CkFMhmxK.js (new) 2.32 kB 🔴 +2.32 kB 🔴 +790 B 🔴 +693 B
assets/serverConfigStore-y5yc8Qaf.js (removed) 2.32 kB 🟢 -2.32 kB 🟢 -790 B 🟢 -692 B
assets/bootstrapStore-Bto52cVe.js (removed) 2.08 kB 🟢 -2.08 kB 🟢 -875 B 🟢 -795 B
assets/bootstrapStore-UIBj4Dk8.js (new) 2.08 kB 🔴 +2.08 kB 🔴 +875 B 🔴 +797 B
assets/userStore-CesgUH0K.js (new) 1.85 kB 🔴 +1.85 kB 🔴 +718 B 🔴 +676 B
assets/userStore-CWjQdqJY.js (removed) 1.85 kB 🟢 -1.85 kB 🟢 -723 B 🟢 -638 B
assets/audioService-C2dWnY2k.js (removed) 1.73 kB 🟢 -1.73 kB 🟢 -851 B 🟢 -728 B
assets/audioService-DCFzRTUM.js (new) 1.73 kB 🔴 +1.73 kB 🔴 +849 B 🔴 +729 B
assets/releaseStore-Chd9xECf.js (removed) 760 B 🟢 -760 B 🟢 -382 B 🟢 -336 B
assets/releaseStore-D8fpTUx7.js (new) 760 B 🔴 +760 B 🔴 +382 B 🔴 +342 B
assets/settingStore-C34Z1CVi.js (removed) 744 B 🟢 -744 B 🟢 -387 B 🟢 -339 B
assets/settingStore-CUJzx3Ps.js (new) 744 B 🔴 +744 B 🔴 +389 B 🔴 +342 B
assets/workflowDraftStore-BCNabFpH.js (removed) 736 B 🟢 -736 B 🟢 -379 B 🟢 -334 B
assets/workflowDraftStore-CL-GBk2S.js (new) 736 B 🔴 +736 B 🔴 +379 B 🔴 +332 B
assets/dialogService-CPH_2qho.js (new) 725 B 🔴 +725 B 🔴 +367 B 🔴 +328 B
assets/dialogService-CTaJJAJG.js (removed) 725 B 🟢 -725 B 🟢 -367 B 🟢 -325 B

Status: 14 added / 14 removed

Utilities & Hooks — 55.5 kB (baseline 58.3 kB) • 🟢 -2.75 kB

Helpers, composables, and utility bundles

File Before After Δ Raw Δ Gzip Δ Brotli
assets/useLoad3d-D3mqyc1x.js (new) 14.6 kB 🔴 +14.6 kB 🔴 +3.63 kB 🔴 +3.21 kB
assets/useLoad3d-fZtpPYXb.js (removed) 14.6 kB 🟢 -14.6 kB 🟢 -3.63 kB 🟢 -3.2 kB
assets/useLoad3dViewer-D5ES-moP.js (removed) 14.1 kB 🟢 -14.1 kB 🟢 -3.15 kB 🟢 -2.79 kB
assets/useLoad3dViewer-rZv7gx0h.js (new) 14.1 kB 🔴 +14.1 kB 🔴 +3.15 kB 🔴 +2.79 kB
assets/useFeatureFlags-BhVsqHka.js (new) 4.14 kB 🔴 +4.14 kB 🔴 +1.24 kB 🔴 +1.06 kB
assets/useFeatureFlags-Cb5SCHrS.js (removed) 4.14 kB 🟢 -4.14 kB 🟢 -1.24 kB 🟢 -1.06 kB
assets/useWorkspaceUI-CtWc_nlV.js (new) 3 kB 🔴 +3 kB 🔴 +820 B 🔴 +705 B
assets/useWorkspaceUI-CX3oDiuo.js (removed) 3 kB 🟢 -3 kB 🟢 -821 B 🟢 -738 B
assets/useSubscriptionCredits-e7ydJVHp.js (removed) 2.75 kB 🟢 -2.75 kB 🟢 -1.04 kB 🟢 -904 B
assets/subscriptionCheckoutUtil-CkSEK7nu.js (removed) 2.53 kB 🟢 -2.53 kB 🟢 -1.06 kB 🟢 -958 B
assets/subscriptionCheckoutUtil-gn9QN1Oi.js (new) 2.53 kB 🔴 +2.53 kB 🔴 +1.06 kB 🔴 +951 B
assets/useExternalLink-B0v_ntQg.js (removed) 1.66 kB 🟢 -1.66 kB 🟢 -769 B 🟢 -677 B
assets/useExternalLink-pV2s_dGH.js (new) 1.66 kB 🔴 +1.66 kB 🔴 +771 B 🔴 +679 B
assets/useErrorHandling-BRaT1ZD7.js (removed) 1.5 kB 🟢 -1.5 kB 🟢 -630 B 🟢 -535 B
assets/useErrorHandling-DLlebW7s.js (new) 1.5 kB 🔴 +1.5 kB 🔴 +629 B 🔴 +531 B
assets/useWorkspaceSwitch-BYlw8pfw.js (new) 1.25 kB 🔴 +1.25 kB 🔴 +546 B 🔴 +476 B
assets/useWorkspaceSwitch-C80n6rlq.js (removed) 1.25 kB 🟢 -1.25 kB 🟢 -545 B 🟢 -483 B
assets/useLoad3d-BOt1s6t5.js (new) 859 B 🔴 +859 B 🔴 +425 B 🔴 +386 B
assets/useLoad3d-DIr32sYG.js (removed) 859 B 🟢 -859 B 🟢 -427 B 🟢 -383 B
assets/audioUtils-CGSp4TGV.js (removed) 858 B 🟢 -858 B 🟢 -500 B 🟢 -404 B
assets/audioUtils-MEKea2eq.js (new) 858 B 🔴 +858 B 🔴 +500 B 🔴 +402 B
assets/useLoad3dViewer-BsBo66Et.js (new) 838 B 🔴 +838 B 🔴 +410 B 🔴 +374 B
assets/useLoad3dViewer-DjMdR1Fw.js (removed) 838 B 🟢 -838 B 🟢 -411 B 🟢 -369 B
assets/useCurrentUser-EDCHc5Yz.js (new) 722 B 🔴 +722 B 🔴 +371 B 🔴 +327 B
assets/useCurrentUser-o9BghzcH.js (removed) 722 B 🟢 -722 B 🟢 -371 B 🟢 -325 B
assets/_plugin-vue_export-helper-c0As6Sx3.js 315 B 315 B ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/colorUtil-C2D2Hl5T.js 7 kB 7 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/envUtil-Clzmwvt4.js 466 B 466 B ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/markdownRendererUtil-Cddas8Zl.js 1.56 kB 1.56 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/SkeletonUtils-BputJAFn.js 133 B 133 B ⚪ 0 B ⚪ 0 B ⚪ 0 B

Status: 12 added / 13 removed

Vendor & Third-Party — 8.84 MB (baseline 8.84 MB) • ⚪ 0 B

External libraries and shared vendor chunks

File Before After Δ Raw Δ Gzip Δ Brotli
assets/vendor-axios-Cp6hch1I.js 70.7 kB 70.7 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-chart-BxkFiWzp.js 399 kB 399 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-firebase-BvMr43CG.js 836 kB 836 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-i18n-DNX73mqE.js 133 kB 133 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-markdown-D5S6AC80.js 103 kB 103 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-other-Cj6OpvOm.js 1.52 MB 1.52 MB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-primevue-BnCPTL0g.js 1.73 MB 1.73 MB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-reka-ui-DVmi2O2Z.js 388 kB 388 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-sentry-SQwstEKc.js 182 kB 182 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-three-LBLOE6BD.js 1.8 MB 1.8 MB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-tiptap-BnYkbQDM.js 634 kB 634 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-vue-core-DtiQ1dr9.js 311 kB 311 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-vueuse-D2jVNnmE.js 113 kB 113 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-xterm-DK6Ygydn.js 374 kB 374 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-yjs-CP_4YO8u.js 143 kB 143 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-zod-DcCUUPIi.js 109 kB 109 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
Other — 7.69 MB (baseline 7.68 MB) • 🔴 +13 kB

Bundles that do not match a named category

File Before After Δ Raw Δ Gzip Δ Brotli
assets/i18n-B9XpFejT.js (new) 524 kB 🔴 +524 kB 🔴 +101 kB 🔴 +78.7 kB
assets/i18n-CTXAfdlv.js (removed) 523 kB 🟢 -523 kB 🟢 -101 kB 🟢 -78.4 kB
assets/core-Bv0nnQcA.js (new) 72.7 kB 🔴 +72.7 kB 🔴 +18.8 kB 🔴 +16.1 kB
assets/core-D-wJccTq.js (removed) 72.7 kB 🟢 -72.7 kB 🟢 -18.8 kB 🟢 -16.1 kB
assets/groupNode-CMG6WLi5.js (new) 71.8 kB 🔴 +71.8 kB 🔴 +17.7 kB 🔴 +15.5 kB
assets/groupNode-CSeTCrg-.js (removed) 71.8 kB 🟢 -71.8 kB 🟢 -17.7 kB 🟢 -15.5 kB
assets/WidgetSelect-B5-AJlm2.js (removed) 58.1 kB 🟢 -58.1 kB 🟢 -12.4 kB 🟢 -10.7 kB
assets/WidgetSelect-C7H--6LC.js (new) 58.1 kB 🔴 +58.1 kB 🔴 +12.4 kB 🔴 +10.7 kB
assets/SubscriptionRequiredDialogContentWorkspace-BCRn7BXV.js (new) 46.3 kB 🔴 +46.3 kB 🔴 +8.65 kB 🔴 +7.52 kB
assets/SubscriptionRequiredDialogContentWorkspace-SzjgQXuV.js (removed) 45.8 kB 🟢 -45.8 kB 🟢 -8.56 kB 🟢 -7.43 kB
assets/Load3DControls-BRC4Juvu.js (removed) 30.9 kB 🟢 -30.9 kB 🟢 -5.34 kB 🟢 -4.65 kB
assets/Load3DControls-CYzmmIq-.js (new) 30.9 kB 🔴 +30.9 kB 🔴 +5.34 kB 🔴 +4.65 kB
assets/WorkspacePanelContent-Dr8sc4nu.js (removed) 29.3 kB 🟢 -29.3 kB 🟢 -6.14 kB 🟢 -5.39 kB
assets/WorkspacePanelContent-BQQwog2o.js (new) 29.3 kB 🔴 +29.3 kB 🔴 +6.14 kB 🔴 +5.38 kB
assets/SubscriptionRequiredDialogContent-BzWyxc7K.js (removed) 26.2 kB 🟢 -26.2 kB 🟢 -6.56 kB 🟢 -5.78 kB
assets/SubscriptionRequiredDialogContent-LCxd5cfN.js (new) 25.7 kB 🔴 +25.7 kB 🔴 +6.57 kB 🔴 +5.78 kB
assets/Load3dViewerContent-0bNo3F0N.js (new) 23 kB 🔴 +23 kB 🔴 +5.18 kB 🔴 +4.49 kB
assets/Load3dViewerContent-9XFpDo_6.js (removed) 23 kB 🟢 -23 kB 🟢 -5.18 kB 🟢 -4.5 kB
assets/WidgetImageCrop-DtQEkvZ0.js (removed) 22.1 kB 🟢 -22.1 kB 🟢 -5.5 kB 🟢 -4.85 kB
assets/WidgetImageCrop-SrTinSdH.js (new) 22.1 kB 🔴 +22.1 kB 🔴 +5.5 kB 🔴 +4.85 kB
assets/SubscriptionPanelContentWorkspace-DU-lr1WC.js (removed) 21.6 kB 🟢 -21.6 kB 🟢 -5.02 kB 🟢 -4.43 kB
assets/SubscriptionPanelContentWorkspace-DzTho0N0.js (new) 21.5 kB 🔴 +21.5 kB 🔴 +5.04 kB 🔴 +4.45 kB
assets/CurrentUserPopoverWorkspace-DHmHcmQE.js (new) 19.9 kB 🔴 +19.9 kB 🔴 +4.87 kB 🔴 +4.35 kB
assets/CurrentUserPopoverWorkspace-COfp3rEe.js (removed) 19.8 kB 🟢 -19.8 kB 🟢 -4.86 kB 🟢 -4.32 kB
assets/SignInContent-DLE8mkVa.js (new) 18.9 kB 🔴 +18.9 kB 🔴 +4.79 kB 🔴 +4.18 kB
assets/SignInContent-PEk8XS0w.js (removed) 18.9 kB 🟢 -18.9 kB 🟢 -4.79 kB 🟢 -4.18 kB
assets/WidgetInputNumber-Dnq8E9n2.js (removed) 18.7 kB 🟢 -18.7 kB 🟢 -4.75 kB 🟢 -4.22 kB
assets/WidgetInputNumber-UroJggZb.js (new) 18.7 kB 🔴 +18.7 kB 🔴 +4.75 kB 🔴 +4.21 kB
assets/WidgetRecordAudio-CUaUS-2e.js (new) 17.3 kB 🔴 +17.3 kB 🔴 +4.94 kB 🔴 +4.42 kB
assets/WidgetRecordAudio-sVG-sjhv.js (removed) 17.3 kB 🟢 -17.3 kB 🟢 -4.94 kB 🟢 -4.43 kB
assets/Load3D-Hdx_X82z.js (removed) 16.2 kB 🟢 -16.2 kB 🟢 -4.03 kB 🟢 -3.51 kB
assets/Load3D-RLeVLk24.js (new) 16.2 kB 🔴 +16.2 kB 🔴 +4.03 kB 🔴 +3.51 kB
assets/load3d-CG4F3Lih.js (removed) 14.7 kB 🟢 -14.7 kB 🟢 -4.19 kB 🟢 -3.63 kB
assets/load3d-kDI3hBy4.js (new) 14.7 kB 🔴 +14.7 kB 🔴 +4.19 kB 🔴 +3.63 kB
assets/AudioPreviewPlayer-DjhrcAf1.js (removed) 10.9 kB 🟢 -10.9 kB 🟢 -3.2 kB 🟢 -2.85 kB
assets/AudioPreviewPlayer-ZV6FLgSJ.js (new) 10.9 kB 🔴 +10.9 kB 🔴 +3.19 kB 🔴 +2.86 kB
assets/changeTracker-BM8j7CCN.js (removed) 9.38 kB 🟢 -9.38 kB 🟢 -2.9 kB 🟢 -2.55 kB
assets/changeTracker-D-zAhsb7.js (new) 9.38 kB 🔴 +9.38 kB 🔴 +2.89 kB 🔴 +2.54 kB
assets/nodeTemplates-sbE-XfzZ.js (new) 9.29 kB 🔴 +9.29 kB 🔴 +3.26 kB 🔴 +2.86 kB
assets/nodeTemplates-WHkWKyA5.js (removed) 9.29 kB 🟢 -9.29 kB 🟢 -3.25 kB 🟢 -2.86 kB
assets/InviteMemberDialogContent-BKqRM8xO.js (removed) 7.38 kB 🟢 -7.38 kB 🟢 -2.29 kB 🟢 -2 kB
assets/InviteMemberDialogContent-Cki4ulX2.js (new) 7.38 kB 🔴 +7.38 kB 🔴 +2.29 kB 🔴 +2.02 kB
assets/WidgetImageCompare-CZz83z3_.js (new) 6.95 kB 🔴 +6.95 kB 🔴 +2.05 kB 🔴 +1.79 kB
assets/WidgetImageCompare-Dd1in709.js (removed) 6.95 kB 🟢 -6.95 kB 🟢 -2.05 kB 🟢 -1.79 kB
assets/WidgetToggleSwitch-Du7wrnv6.js (new) 6.8 kB 🔴 +6.8 kB 🔴 +2.19 kB 🔴 +1.94 kB
assets/WidgetToggleSwitch-qnKKuKel.js (removed) 6.8 kB 🟢 -6.8 kB 🟢 -2.19 kB 🟢 -1.94 kB
assets/Load3DConfiguration-Bixcu664.js (removed) 6.27 kB 🟢 -6.27 kB 🟢 -1.92 kB 🟢 -1.68 kB
assets/Load3DConfiguration-IHDxPvxz.js (new) 6.27 kB 🔴 +6.27 kB 🔴 +1.91 kB 🔴 +1.68 kB
assets/CreateWorkspaceDialogContent-CkA7Rpx3.js (removed) 5.53 kB 🟢 -5.53 kB 🟢 -1.99 kB 🟢 -1.73 kB
assets/CreateWorkspaceDialogContent-nQQ2HDVQ.js (new) 5.53 kB 🔴 +5.53 kB 🔴 +1.99 kB 🔴 +1.73 kB
assets/onboardingCloudRoutes-B5FURNYR.js (removed) 5.41 kB 🟢 -5.41 kB 🟢 -1.84 kB 🟢 -1.6 kB
assets/onboardingCloudRoutes-DxPN7AK5.js (new) 5.41 kB 🔴 +5.41 kB 🔴 +1.84 kB 🔴 +1.6 kB
assets/FreeTierDialogContent-BDHR8HNL.js (new) 5.39 kB 🔴 +5.39 kB 🔴 +1.9 kB 🔴 +1.67 kB
assets/EditWorkspaceDialogContent-ClvjLVZ4.js (new) 5.33 kB 🔴 +5.33 kB 🔴 +1.95 kB 🔴 +1.69 kB
assets/EditWorkspaceDialogContent-suZbpZ_C.js (removed) 5.33 kB 🟢 -5.33 kB 🟢 -1.94 kB 🟢 -1.7 kB
assets/ValueControlPopover-_ekYw4_g.js (removed) 4.92 kB 🟢 -4.92 kB 🟢 -1.76 kB 🟢 -1.57 kB
assets/ValueControlPopover-CmkwM7gM.js (new) 4.92 kB 🔴 +4.92 kB 🔴 +1.76 kB 🔴 +1.57 kB
assets/Preview3d-Bs6PT6aN.js (removed) 4.81 kB 🟢 -4.81 kB 🟢 -1.57 kB 🟢 -1.37 kB
assets/Preview3d-Cye0e86q.js (new) 4.81 kB 🔴 +4.81 kB 🔴 +1.57 kB 🔴 +1.36 kB
assets/CancelSubscriptionDialogContent-c8mjYrje.js (removed) 4.79 kB 🟢 -4.79 kB 🟢 -1.78 kB 🟢 -1.56 kB
assets/CancelSubscriptionDialogContent-hQ_Yv_EK.js (new) 4.79 kB 🔴 +4.79 kB 🔴 +1.78 kB 🔴 +1.55 kB
assets/DeleteWorkspaceDialogContent-Cw5ftgJ1.js (removed) 4.23 kB 🟢 -4.23 kB 🟢 -1.63 kB 🟢 -1.42 kB
assets/DeleteWorkspaceDialogContent-DycatPOr.js (new) 4.23 kB 🔴 +4.23 kB 🔴 +1.63 kB 🔴 +1.42 kB
assets/WidgetWithControl-Bt09Lscr.js (new) 4.1 kB 🔴 +4.1 kB 🔴 +1.77 kB 🔴 +1.6 kB
assets/WidgetWithControl-i8b7hflj.js (removed) 4.1 kB 🟢 -4.1 kB 🟢 -1.77 kB 🟢 -1.6 kB
assets/LeaveWorkspaceDialogContent-Bmd6_y2u.js (new) 4.06 kB 🔴 +4.06 kB 🔴 +1.58 kB 🔴 +1.37 kB
assets/LeaveWorkspaceDialogContent-CobbzM0K.js (removed) 4.06 kB 🟢 -4.06 kB 🟢 -1.58 kB 🟢 -1.37 kB
assets/RemoveMemberDialogContent-01fdd8dk.js (removed) 4.04 kB 🟢 -4.04 kB 🟢 -1.52 kB 🟢 -1.33 kB
assets/RemoveMemberDialogContent-BLGVLCdO.js (new) 4.04 kB 🔴 +4.04 kB 🔴 +1.52 kB 🔴 +1.33 kB
assets/WidgetTextarea-CyuPe5ty.js (new) 3.96 kB 🔴 +3.96 kB 🔴 +1.49 kB 🔴 +1.3 kB
assets/WidgetTextarea-Q-qdfbqq.js (removed) 3.96 kB 🟢 -3.96 kB 🟢 -1.49 kB 🟢 -1.3 kB
assets/RevokeInviteDialogContent-C1VdOcXr.js (new) 3.95 kB 🔴 +3.95 kB 🔴 +1.54 kB 🔴 +1.35 kB
assets/RevokeInviteDialogContent-UCj_FtNC.js (removed) 3.95 kB 🟢 -3.95 kB 🟢 -1.54 kB 🟢 -1.35 kB
assets/InviteMemberUpsellDialogContent-Bu_crqbG.js (new) 3.82 kB 🔴 +3.82 kB 🔴 +1.4 kB 🔴 +1.23 kB
assets/InviteMemberUpsellDialogContent-CIylHfgn.js (removed) 3.82 kB 🟢 -3.82 kB 🟢 -1.4 kB 🟢 -1.23 kB
assets/tierBenefits-D7Glqaxc.js (new) 3.66 kB 🔴 +3.66 kB 🔴 +1.3 kB 🔴 +1.17 kB
assets/WidgetGalleria-C6rgr8eT.js (new) 3.61 kB 🔴 +3.61 kB 🔴 +1.39 kB 🔴 +1.25 kB
assets/WidgetGalleria-qz-Lgq9M.js (removed) 3.61 kB 🟢 -3.61 kB 🟢 -1.39 kB 🟢 -1.25 kB
assets/saveMesh-CAivmMmw.js (removed) 3.38 kB 🟢 -3.38 kB 🟢 -1.46 kB 🟢 -1.29 kB
assets/saveMesh-DtUHAScV.js (new) 3.38 kB 🔴 +3.38 kB 🔴 +1.46 kB 🔴 +1.29 kB
assets/cloudSessionCookie-C8uCjObo.js (new) 3.1 kB 🔴 +3.1 kB 🔴 +1.09 kB 🔴 +948 B
assets/cloudSessionCookie-DTSFq9tF.js (removed) 3.1 kB 🟢 -3.1 kB 🟢 -1.08 kB 🟢 -965 B
assets/WidgetMarkdown-BryTE-VI.js (new) 2.93 kB 🔴 +2.93 kB 🔴 +1.23 kB 🔴 +1.07 kB
assets/WidgetMarkdown-Ga2NK5Cp.js (removed) 2.93 kB 🟢 -2.93 kB 🟢 -1.23 kB 🟢 -1.07 kB
assets/GlobalToast-CtatuDW-.js (removed) 2.91 kB 🟢 -2.91 kB 🟢 -1.21 kB 🟢 -1.03 kB
assets/GlobalToast-DDIqz53P.js (new) 2.91 kB 🔴 +2.91 kB 🔴 +1.21 kB 🔴 +1.03 kB
assets/WidgetColorPicker-DJsye3-P.js (new) 2.9 kB 🔴 +2.9 kB 🔴 +1.23 kB 🔴 +1.11 kB
assets/WidgetColorPicker-DXopNw5s.js (removed) 2.9 kB 🟢 -2.9 kB 🟢 -1.23 kB 🟢 -1.11 kB
assets/ApiNodesSignInContent-Dq094TF1.js (new) 2.69 kB 🔴 +2.69 kB 🔴 +1.05 kB 🔴 +961 B
assets/ApiNodesSignInContent-nX61wDJm.js (removed) 2.69 kB 🟢 -2.69 kB 🟢 -1.05 kB 🟢 -920 B
assets/WidgetChart-bB0Z4sSA.js (removed) 2.21 kB 🟢 -2.21 kB 🟢 -952 B 🟢 -823 B
assets/WidgetChart-DhNMsSAU.js (new) 2.21 kB 🔴 +2.21 kB 🔴 +952 B 🔴 +821 B
assets/SubscribeToRun-7pZnQVX9.js (removed) 2.2 kB 🟢 -2.2 kB 🟢 -1.01 kB 🟢 -870 B
assets/SubscribeToRun-DcKW5W7j.js (new) 2.2 kB 🔴 +2.2 kB 🔴 +1.01 kB 🔴 +869 B
assets/SubscriptionBenefits-BYIJyGB1.js (new) 2.01 kB 🔴 +2.01 kB 🔴 +702 B 🔴 +600 B
assets/WidgetLayoutField-BDX3pgBi.js (new) 1.98 kB 🔴 +1.98 kB 🔴 +891 B 🔴 +779 B
assets/WidgetLayoutField-DNiJ_Scb.js (removed) 1.98 kB 🟢 -1.98 kB 🟢 -891 B 🟢 -778 B
assets/WidgetInputText-BMkSjXu_.js (removed) 1.86 kB 🟢 -1.86 kB 🟢 -872 B 🟢 -791 B
assets/WidgetInputText-BxRXwRGF.js (new) 1.86 kB 🔴 +1.86 kB 🔴 +872 B 🔴 +789 B
assets/CloudRunButtonWrapper-D6piTBRQ.js (new) 1.68 kB 🔴 +1.68 kB 🔴 +786 B 🔴 +716 B
assets/CloudRunButtonWrapper-DnhZ-1x_.js (removed) 1.68 kB 🟢 -1.68 kB 🟢 -785 B 🟢 -720 B
assets/signInSchema-CtKSrNoL.js (removed) 1.53 kB 🟢 -1.53 kB 🟢 -563 B 🟢 -519 B
assets/signInSchema-DKVR1opA.js (new) 1.53 kB 🔴 +1.53 kB 🔴 +563 B 🔴 +524 B
assets/previousFullPath-IEEKKjaw.js (new) 1.39 kB 🔴 +1.39 kB 🔴 +649 B 🔴 +574 B
assets/cloudBadges-COpC5ao8.js (new) 1.37 kB 🔴 +1.37 kB 🔴 +700 B 🔴 +616 B
assets/cloudBadges-DLePKVcj.js (removed) 1.37 kB 🟢 -1.37 kB 🟢 -702 B 🟢 -615 B
assets/cloudSubscription-BqliusXp.js (new) 1.33 kB 🔴 +1.33 kB 🔴 +656 B 🔴 +573 B
assets/cloudSubscription-C-MNFsTV.js (removed) 1.33 kB 🟢 -1.33 kB 🟢 -656 B 🟢 -570 B
assets/widgetPropFilter-_HM7alVA.js (new) 1.1 kB 🔴 +1.1 kB 🔴 +510 B 🔴 +456 B
assets/widgetPropFilter-C9FcIgiN.js (removed) 1.1 kB 🟢 -1.1 kB 🟢 -509 B 🟢 -431 B
assets/Load3D-Cikrhk3d.js (removed) 1.07 kB 🟢 -1.07 kB 🟢 -502 B 🟢 -441 B
assets/Load3D-D9jUaGuo.js (new) 1.07 kB 🔴 +1.07 kB 🔴 +500 B 🔴 +444 B
assets/nightlyBadges-aLkuasQi.js (removed) 1 kB 🟢 -1 kB 🟢 -530 B 🟢 -462 B
assets/nightlyBadges-hCT6KQrt.js (new) 1 kB 🔴 +1 kB 🔴 +529 B 🔴 +476 B
assets/Load3dViewerContent-C-eKpS5r.js (removed) 993 B 🟢 -993 B 🟢 -470 B 🟢 -413 B
assets/Load3dViewerContent-JXb5q4vd.js (new) 993 B 🔴 +993 B 🔴 +470 B 🔴 +418 B
assets/SubscriptionPanelContentWorkspace-CZTXfMbd.js (removed) 930 B 🟢 -930 B 🟢 -436 B 🟢 -381 B
assets/SubscriptionPanelContentWorkspace-FqbgIgDq.js (new) 920 B 🔴 +920 B 🔴 +440 B 🔴 +383 B
assets/graphHasMissingNodes-BWU-9TbM.js (new) 761 B 🔴 +761 B 🔴 +373 B 🔴 +327 B
assets/graphHasMissingNodes-Dul3mg5A.js (removed) 761 B 🟢 -761 B 🟢 -374 B 🟢 -329 B
assets/changeTracker-AH8zTVdF.js (new) 757 B 🔴 +757 B 🔴 +382 B 🔴 +338 B
assets/changeTracker-BZkYV1jy.js (removed) 757 B 🟢 -757 B 🟢 -385 B 🟢 -335 B
assets/WidgetLegacy-BoLA9AhA.js (removed) 745 B 🟢 -745 B 🟢 -380 B 🟢 -332 B
assets/WidgetLegacy-BZOr39sd.js (new) 745 B 🔴 +745 B 🔴 +381 B 🔴 +336 B
assets/previousFullPath-Bpf5sR83.js (removed) 665 B 🟢 -665 B 🟢 -368 B 🟢 -335 B
assets/WidgetInputNumber-CNEm7IEK.js (removed) 469 B 🟢 -469 B 🟢 -264 B 🟢 -230 B
assets/WidgetInputNumber-kJ7HQN0P.js (new) 469 B 🔴 +469 B 🔴 +262 B 🔴 +226 B
assets/i18n-BvfsCUCf.js (new) 199 B 🔴 +199 B 🔴 +161 B 🔴 +139 B
assets/i18n-EfrmJNmF.js (removed) 199 B 🟢 -199 B 🟢 -160 B 🟢 -138 B
assets/AnimationControls-CS8RJ8KB.js 4.61 kB 4.61 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/auto-BTnZwrs2.js 1.7 kB 1.7 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/BaseViewTemplate-C7TdQsa4.js 1.78 kB 1.78 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/comfy-logo-single-D9MrYETV.js 198 B 198 B ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/ComfyOrgHeader-hCSVqQLq.js 910 B 910 B ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-Bdca9E4N.js 16.7 kB 16.7 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-Boqbp4FE.js 16.9 kB 16.9 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-ClFTZZY8.js 18.8 kB 18.8 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-CnE4PBoq.js 16.1 kB 16.1 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-CNvwc2P-.js 17.5 kB 17.5 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-CsOV8HcO.js 16.1 kB 16.1 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-DBggpNhX.js 15.2 kB 15.2 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-DC4TJj5_.js 15.1 kB 15.1 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-DPVHiFlP.js 16.1 kB 16.1 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-eBbhcNBX.js 17.5 kB 17.5 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-oGOH5erQ.js 15.9 kB 15.9 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/constants-htt0vt7m.js 579 B 579 B ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-BB7auBht.js 165 kB 165 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-BdKSBqqg.js 143 kB 143 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-BfvfK7e6.js 146 kB 146 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-BLRdLf2Q.js 151 kB 151 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-BSpv8hSM.js 127 kB 127 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-BvwH6XIB.js 200 kB 200 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-C2HyY-c4.js 148 kB 148 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-CqXYEjL8.js 126 kB 126 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-DDNas9AN.js 143 kB 143 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-pkudWRzN.js 179 kB 179 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-WdFMJ_Ke.js 172 kB 172 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/Media3DTop-CouvCIvd.js 1.82 kB 1.82 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/MediaAudioTop-CJNtJ1Vf.js 1.43 kB 1.43 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/MediaImageTop-CsrUK1I8.js 1.75 kB 1.75 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/MediaOtherTop-DT9GM-Zv.js 1.02 kB 1.02 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/MediaTextTop-vLt-EA1h.js 1.01 kB 1.01 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/MediaVideoTop-pufIlXJs.js 2.77 kB 2.77 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-AqEiOGKG.js 482 kB 482 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-B02cd_Zo.js 389 kB 389 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-BD8FRoYB.js 357 kB 357 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-BKYIrO-a.js 441 kB 441 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-bLo1B4mz.js 392 kB 392 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-BM1ECzIc.js 384 kB 384 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-C9rWPNkn.js 442 kB 442 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-CJBrUtym.js 408 kB 408 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-DAD_I_yB.js 396 kB 396 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-DrvABguj.js 393 kB 393 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-vABsyTzF.js 361 kB 361 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/OBJLoader2WorkerModule-DTMpvldF.js 109 kB 109 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/Popover-BRmRgwNy.js 3.65 kB 3.65 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/rolldown-runtime-DLICfi3-.js 1.97 kB 1.97 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/SelectValue-D-D55K_n.js 8.94 kB 8.94 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/Slider-C6m7fecr.js 3.52 kB 3.52 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/src-PwE2kea4.js 251 B 251 B ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/telemetry-zZf2dHJ2.js 226 B 226 B ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/types-DT3N7am7.js 204 B 204 B ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/VideoPlayOverlay-CMR8YZxL.js 1.35 kB 1.35 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/widget-NeEr3XWN.js 586 B 586 B ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/WidgetBoundingBox-3ihy3W8s.js 283 B 283 B ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/WidgetBoundingBox-B1dc4Mvs.js 3.19 kB 3.19 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/WidgetCurve-CNAR8Elf.js 9.35 kB 9.35 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/widgetTypes-DjwSopYV.js 393 B 393 B ⚪ 0 B ⚪ 0 B ⚪ 0 B

Status: 66 added / 63 removed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/platform/cloud/onboarding/CloudLoginView.vue`:
- Around line 135-149: Replace the arrow-function expressions with function
declarations for the two handlers: change the const onShowEmailForm = () => {
... } to function onShowEmailForm() { ... } and const onBackToSocialLogin = ()
=> { ... } to function onBackToSocialLogin() { ... }; keep the same body
(setting showEmailForm.value and calling telemetry?.trackUiButtonClicked) and
ensure no other references rely on these being consts (they remain available in
the same scope).

In `@src/platform/cloud/onboarding/CloudSignupView.vue`:
- Around line 152-167: Replace the arrow function expressions for
onShowEmailForm and onBackToSocialLogin with plain function declarations named
onShowEmailForm and onBackToSocialLogin respectively; keep the body logic intact
(toggling showEmailForm.value and calling telemetry?.trackUiButtonClicked with
the same button_id values), and ensure their definitions remain in the same
scope where showEmailForm, telemetry, and other refs are available so behavior
doesn't change.
🧹 Nitpick comments (2)
src/platform/cloud/onboarding/CloudSignupView.vue (1)

25-93: Use $t directly in the template and remove the useI18n import.

The t function is only used in template interpolations, never in script logic. Using Vue's auto-injected $t eliminates the redundant useI18n import and keeps the script cleaner.

Suggested update
-import { useI18n } from 'vue-i18n'
 import { computed, onMounted, ref } from 'vue'
 import { useRoute, useRouter } from 'vue-router'

 ...
-const { t } = useI18n()
 const router = useRouter()

Then replace all {{ t(...) }} with {{ $t(...) }} in the template.

src/platform/cloud/onboarding/CloudLoginView.vue (1)

23-88: Use $t directly in the template and remove the useI18n import.

Since the t function is only used in the template, use the auto-injected $t instead. This eliminates the unused import and the destructuring statement.

Suggested update
-import { useI18n } from 'vue-i18n'
 import { useRoute, useRouter } from 'vue-router'
-const { t } = useI18n()
 const router = useRouter()

Replace all t('key') with $t('key') in the template.

Comment on lines +135 to +149
const showEmailForm = ref(false)
const freeTierCredits = computed(() => remoteConfig.value.free_tier_credits)
const showFreeTierBadge = !localStorage.getItem('comfy:hasAccount')
const toastStore = useToastStore()
const telemetry = useTelemetry()

const onShowEmailForm = () => {
showEmailForm.value = true
telemetry?.trackUiButtonClicked({ button_id: 'login_use_email_instead' })
}

const onBackToSocialLogin = () => {
showEmailForm.value = false
telemetry?.trackUiButtonClicked({ button_id: 'login_back_to_social_login' })
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use function declarations for onShowEmailForm / onBackToSocialLogin.

Repository guidance prefers function declarations when possible.

♻️ Suggested update
-const onShowEmailForm = () => {
+function onShowEmailForm() {
   showEmailForm.value = true
   telemetry?.trackUiButtonClicked({ button_id: 'login_use_email_instead' })
 }
 
-const onBackToSocialLogin = () => {
+function onBackToSocialLogin() {
   showEmailForm.value = false
   telemetry?.trackUiButtonClicked({ button_id: 'login_back_to_social_login' })
 }

Based on learnings "Prefer pure function declarations over function expressions (e.g., use function foo() { ... } instead of const foo = () => { ... }) for pure functions in the repository."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const showEmailForm = ref(false)
const freeTierCredits = computed(() => remoteConfig.value.free_tier_credits)
const showFreeTierBadge = !localStorage.getItem('comfy:hasAccount')
const toastStore = useToastStore()
const telemetry = useTelemetry()
const onShowEmailForm = () => {
showEmailForm.value = true
telemetry?.trackUiButtonClicked({ button_id: 'login_use_email_instead' })
}
const onBackToSocialLogin = () => {
showEmailForm.value = false
telemetry?.trackUiButtonClicked({ button_id: 'login_back_to_social_login' })
}
const showEmailForm = ref(false)
const freeTierCredits = computed(() => remoteConfig.value.free_tier_credits)
const showFreeTierBadge = !localStorage.getItem('comfy:hasAccount')
const toastStore = useToastStore()
const telemetry = useTelemetry()
function onShowEmailForm() {
showEmailForm.value = true
telemetry?.trackUiButtonClicked({ button_id: 'login_use_email_instead' })
}
function onBackToSocialLogin() {
showEmailForm.value = false
telemetry?.trackUiButtonClicked({ button_id: 'login_back_to_social_login' })
}
🤖 Prompt for AI Agents
In `@src/platform/cloud/onboarding/CloudLoginView.vue` around lines 135 - 149,
Replace the arrow-function expressions with function declarations for the two
handlers: change the const onShowEmailForm = () => { ... } to function
onShowEmailForm() { ... } and const onBackToSocialLogin = () => { ... } to
function onBackToSocialLogin() { ... }; keep the same body (setting
showEmailForm.value and calling telemetry?.trackUiButtonClicked) and ensure no
other references rely on these being consts (they remain available in the same
scope).

Comment on lines +152 to +167
const showEmailForm = ref(false)
const freeTierCredits = computed(() => remoteConfig.value.free_tier_credits)
const showFreeTierBadge = !localStorage.getItem('comfy:hasAccount')
const userIsInChina = ref(false)
const toastStore = useToastStore()
const telemetry = useTelemetry()

const onShowEmailForm = () => {
showEmailForm.value = true
telemetry?.trackUiButtonClicked({ button_id: 'signup_use_email_instead' })
}

const onBackToSocialLogin = () => {
showEmailForm.value = false
telemetry?.trackUiButtonClicked({ button_id: 'signup_back_to_social_login' })
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use function declarations for onShowEmailForm / onBackToSocialLogin.

Repository guidance prefers function declarations when possible.

♻️ Suggested update
-const onShowEmailForm = () => {
+function onShowEmailForm() {
   showEmailForm.value = true
   telemetry?.trackUiButtonClicked({ button_id: 'signup_use_email_instead' })
 }
 
-const onBackToSocialLogin = () => {
+function onBackToSocialLogin() {
   showEmailForm.value = false
   telemetry?.trackUiButtonClicked({ button_id: 'signup_back_to_social_login' })
 }

Based on learnings "Prefer pure function declarations over function expressions (e.g., use function foo() { ... } instead of const foo = () => { ... }) for pure functions in the repository."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const showEmailForm = ref(false)
const freeTierCredits = computed(() => remoteConfig.value.free_tier_credits)
const showFreeTierBadge = !localStorage.getItem('comfy:hasAccount')
const userIsInChina = ref(false)
const toastStore = useToastStore()
const telemetry = useTelemetry()
const onShowEmailForm = () => {
showEmailForm.value = true
telemetry?.trackUiButtonClicked({ button_id: 'signup_use_email_instead' })
}
const onBackToSocialLogin = () => {
showEmailForm.value = false
telemetry?.trackUiButtonClicked({ button_id: 'signup_back_to_social_login' })
}
const showEmailForm = ref(false)
const freeTierCredits = computed(() => remoteConfig.value.free_tier_credits)
const showFreeTierBadge = !localStorage.getItem('comfy:hasAccount')
const userIsInChina = ref(false)
const toastStore = useToastStore()
const telemetry = useTelemetry()
function onShowEmailForm() {
showEmailForm.value = true
telemetry?.trackUiButtonClicked({ button_id: 'signup_use_email_instead' })
}
function onBackToSocialLogin() {
showEmailForm.value = false
telemetry?.trackUiButtonClicked({ button_id: 'signup_back_to_social_login' })
}
🤖 Prompt for AI Agents
In `@src/platform/cloud/onboarding/CloudSignupView.vue` around lines 152 - 167,
Replace the arrow function expressions for onShowEmailForm and
onBackToSocialLogin with plain function declarations named onShowEmailForm and
onBackToSocialLogin respectively; keep the body logic intact (toggling
showEmailForm.value and calling telemetry?.trackUiButtonClicked with the same
button_id values), and ensure their definitions remain in the same scope where
showEmailForm, telemetry, and other refs are available so behavior doesn't
change.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/platform/cloud/onboarding/CloudLoginView.vue`:
- Around line 141-149: Replace the arrow function expressions for
onShowEmailForm and onBackToSocialLogin with plain function declarations:
implement function onShowEmailForm() { showEmailForm.value = true;
telemetry?.trackUiButtonClicked({ button_id: 'login_use_email_instead' }) } and
function onBackToSocialLogin() { showEmailForm.value = false;
telemetry?.trackUiButtonClicked({ button_id: 'login_back_to_social_login' }) },
preserving the exact side effects and telemetry calls and keeping the same
symbol names so existing references still work.

In `@src/platform/cloud/onboarding/CloudSignupView.vue`:
- Around line 159-167: Convert the two handler function expressions
onShowEmailForm and onBackToSocialLogin into function declarations (replace the
const arrow functions with function onShowEmailForm() { ... } and function
onBackToSocialLogin() { ... }) while preserving their bodies: set
showEmailForm.value appropriately and keep the telemetry?.trackUiButtonClicked
calls unchanged; ensure the functions remain in the same module scope so
existing callers and reactivity (showEmailForm ref) continue to work and no
other references need updating.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/lib/litegraph/src/LGraphCanvas.ts (1)

3612-3626: ⚠️ Potential issue | 🟡 Minor

Keep selection-change notifications consistent on cancel.

On cancel, the selection changes via deselect(node) but listeners won’t get onSelectionChange, while the confirm branch now does. That inconsistency can leave UI in a stale selection state. Consider firing the callback (or moving it after the branch to cover both).

💡 Suggested fix
     if (cancelled) {
       this.deselect(node)
+      this.onSelectionChange?.(this.selected_nodes)
       this.graph?.remove(node)
     } else {
       delete node.flags.ghost
       this.graph?.trigger('node:property:changed', {
         nodeId: node.id,
         property: 'flags.ghost',
         oldValue: true,
         newValue: false
       })

       this.state.selectionChanged = true
       this.onSelectionChange?.(this.selected_nodes)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/litegraph/src/LGraphCanvas.ts` around lines 3612 - 3626, The cancel
branch calls deselect(node) and updates the graph but does not notify listeners,
causing inconsistent selection-change notifications compared to the confirm
branch; update the logic so selection notifications always fire by either moving
the state.selectionChanged = true and onSelectionChange?.(this.selected_nodes)
lines after the if/else or by adding those two lines into the cancelled branch
(ensure you reference deselect(node), state.selectionChanged, onSelectionChange,
and selected_nodes when making the change) so both confirm and cancel paths
notify listeners consistently.
src/extensions/core/uploadImage.ts (1)

1-6: ⚠️ Potential issue | 🟡 Minor

Split type-only imports from value imports.

This mixed import violates the repo rule that type-only imports must be in a separate import type statement.

♻️ Proposed fix
-import {
-  type ComfyNodeDef,
-  type InputSpec,
-  isComboInputSpecV1
-} from '@/schemas/nodeDefSchema'
+import type { ComfyNodeDef, InputSpec } from '@/schemas/nodeDefSchema'
+import { isComboInputSpecV1 } from '@/schemas/nodeDefSchema'

As per coding guidelines, use separate import type statements instead of inline type keyword in mixed imports.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/extensions/core/uploadImage.ts` around lines 1 - 6, The mixed import
combines type-only and value imports; split them so type-only symbols
(LGraphNode, ComfyNodeDef, InputSpec) are imported with "import type" and value
symbols (isComboInputSpecV1) are imported with a regular import. Update the top
of src/extensions/core/uploadImage.ts to use one import type line for
LGraphNode, ComfyNodeDef, InputSpec and a separate non-type import for
isComboInputSpecV1 so type-only imports are not mixed with runtime values.
src/extensions/core/groupNode.ts (1)

2000-2009: ⚠️ Potential issue | 🟠 Major

Store group nodes under the canonical name, not node.title.

node.title can diverge from the registered group name (e.g., user renames an instance), which can mis-key groupNodes and break re-registration on reload. Use the group definition’s name (or derive from node.type) as the storage key.

Suggested fix
-      if (node.title && handler?.groupData?.nodeData) {
-        Workflow.storeGroupNode(node.title, handler.groupData.nodeData)
-      }
+      const groupName = handler?.groupData?.name
+      if (groupName && handler?.groupData?.nodeData) {
+        Workflow.storeGroupNode(groupName, handler.groupData.nodeData)
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/extensions/core/groupNode.ts` around lines 2000 - 2009, The code in
nodeCreated uses node.title as the storage key which can differ from the
canonical group name; change the store key to the group definition's canonical
name (from GroupNodeHandler or derived from node.type) instead of node.title: in
nodeCreated, after creating the GroupNodeHandler and obtaining
handler.groupData.nodeData, call Workflow.storeGroupNode using
handler.groupData.name (or handler.getDefinitionName()) or fallback to node.type
if the handler does not expose a name; keep storing the same nodeData
(handler.groupData.nodeData) but replace node.title with the canonical name to
ensure consistent re-registration.
src/components/dialog/content/ConfirmationDialogContent.vue (1)

33-50: ⚠️ Potential issue | 🟡 Minor

Use $t in the template and drop useI18n here.

useI18n is only supplying t for the template, so $t keeps the setup leaner.

🛠️ Proposed fix
-          <label for="doNotAskAgain">{{
-            t('missingModelsDialog.doNotAskAgain')
-          }}</label>
+          <label for="doNotAskAgain">{{
+            $t('missingModelsDialog.doNotAskAgain')
+          }}</label>
-              {{ t('missingModelsDialog.reEnableInSettingsLink') }}
+              {{ $t('missingModelsDialog.reEnableInSettingsLink') }}
-import { useI18n } from 'vue-i18n'
-const { t } = useI18n()

Based on learnings: In Vue single-file components where the i18n t function is only used within the template, prefer using the built-in $t in the template instead of importing useI18n and destructuring t in the script.

Also applies to: 118-135

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/dialog/content/ConfirmationDialogContent.vue` around lines 33
- 50, The template is using the injected t from useI18n unnecessarily; remove
the useI18n import/destructuring and any script-level t usage, then replace
template calls like t('missingModelsDialog.doNotAskAgain') and
t('missingModelsDialog.reEnableInSettingsLink') with the global $t('...') in the
template; keep event handlers like openBlueprintOverwriteSetting unchanged and
ensure no remaining references to the t variable or useI18n remain (also apply
the same replacement for the other occurrences around the component where t is
only used in the template).
🧹 Nitpick comments (25)
src/platform/assets/utils/createAssetWidget.ts (1)

85-91: Consider applying fromZodError() consistently to filename validation as well.

The asset item validation (line 71) now uses fromZodError(), but the filename validation error at lines 86-91 still logs the raw error.errors array. For consistency and improved log readability, consider updating this block too.

♻️ Proposed fix for consistency
         if (!validatedFilename.success) {
           console.error(
             'Invalid asset filename:',
-            validatedFilename.error.errors,
+            fromZodError(validatedFilename.error).message,
             'for asset:',
             validatedAsset.data.id
           )

Based on learnings: "In assetService.ts, prefer using safeParse() ... and use fromZodError(result.error) to format error messages for logging."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/platform/assets/utils/createAssetWidget.ts` around lines 85 - 91, The
filename validation branch logs raw Zod errors; change it to format errors with
fromZodError for consistency: when validatedFilename.success is false, call
fromZodError(validatedFilename.error) and include that formatted message in the
console.error (mirroring how validatedAsset is handled) so the log uses
fromZodError instead of raw validatedFilename.error.errors; update the
console.error invocation around validatedFilename and validatedAsset in
createAssetWidget.ts accordingly.
src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue (1)

205-210: Consider replacing the scoped style with Tailwind utilities.

Per coding guidelines, Vue components should use Tailwind 4 for styling and avoid <style> blocks. The :deep() selector targeting PrimeVue's SplitButton dropdown could potentially be replaced with Tailwind utilities or handled via the component's class props.

However, since this is styling a PrimeVue internal element that may not expose direct class customization, this override may be necessary for now.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue` around lines
205 - 210, The component currently uses a scoped style block in
ComfyQueueButton.vue to target PrimeVue's internal dropdown via
.comfyui-queue-button :deep(.p-splitbutton-dropdown) and set
border-top-right-radius/bottom-right-radius to 0; replace this by removing the
<style> block and, if possible, apply Tailwind utilities to the SplitButton
wrapper (e.g., add classes on the ComfyQueueButton root or to the PrimeVue
SplitButton via its class/style props) to achieve the same zero right-radius,
otherwise keep the override but move it into a global CSS file (or a theme
layer) with the same selector so the project avoids component-scoped <style>
blocks; ensure you reference the .comfyui-queue-button wrapper and the
:deep(.p-splitbutton-dropdown) selector when making the change.
src/components/actionbar/ComfyRunButton/ComfyQueueButton.test.ts (1)

161-175: Remove redundant duplicate assertions.

Lines 171-175 duplicate the assertions already made in lines 161-167. After verifying the mode transition and UI state once, re-asserting the same values adds noise without testing additional behavior.

♻️ Proposed simplification
     expect(splitButtonWhileStopping.attributes('data-severity')).toBe('primary')
     expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)

     expect(commandStore.execute).not.toHaveBeenCalled()
-
-    const splitButton = wrapper.get('[data-testid="queue-button"]')
-    expect(queueSettingsStore.mode).toBe('instant-idle')
-    expect(splitButton.attributes('data-label')).toBe('Run (Instant)')
-    expect(splitButton.attributes('data-severity')).toBe('primary')
-    expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/actionbar/ComfyRunButton/ComfyQueueButton.test.ts` around
lines 161 - 175, Remove the redundant duplicate assertions that repeat the same
checks for queueSettingsStore.mode, the queue-button attributes, and the
fast-forward icon: after the initial checks using splitButtonWhileStopping,
delete the second block that re-assigns splitButton and re-checks
queueSettingsStore.mode, splitButton.attributes('data-label'),
splitButton.attributes('data-severity'), and
wrapper.find('.icon-\\[lucide--fast-forward\\]').exists(); keep the first
assertions and the expect(commandStore.execute).not.toHaveBeenCalled() check
only.
src/components/ui/toggle-group/index.ts (1)

1-2: Consider whether this barrel file is needed.

Per coding guidelines, barrel files are discouraged. The UI component guideline also states to "Import siblings directly (./Component.vue), not from barrel ('.')". If this is intended for external package consumption, it may be acceptable; otherwise, consumers should import directly from the component files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ui/toggle-group/index.ts` around lines 1 - 2, This barrel file
exports ToggleGroup and ToggleGroupItem but barrels are discouraged; remove
src/components/ui/toggle-group/index.ts and update all internal imports that
reference the directory (e.g., import { ToggleGroup } from '.../toggle-group')
to import the components directly from their sibling files (import ToggleGroup
from './ToggleGroup.vue' and import ToggleGroupItem from
'./ToggleGroupItem.vue'); if the barrel is intentionally kept for external
package consumers, instead add a clear comment above the exports explaining that
this file exists only for package public API and leave it as-is.
src/components/common/TextTicker.test.ts (1)

14-16: Consider using a more specific type for the wrapper variable.

The current type ReturnType<typeof mount> loses the component type information. Using a more specific type would improve IntelliSense and type safety.

♻️ Suggested improvement
+import type { VueWrapper } from '@vue/test-utils'
+
 describe(TextTicker, () => {
   let rafCallbacks: ((time: number) => void)[]
-  let wrapper: ReturnType<typeof mount>
+  let wrapper: VueWrapper<InstanceType<typeof TextTicker>>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/TextTicker.test.ts` around lines 14 - 16, The wrapper
variable is currently typed as ReturnType<typeof mount>, which loses the
component's type info; update wrapper to a more specific wrapper type tied to
TextTicker (e.g., use the test-utils wrapper generic or the framework-specific
wrapper type) by changing its declaration to a typed wrapper such as
ReactWrapper<TextTickerProps, TextTickerState, TextTicker> or
VueWrapper<ComponentPublicInstance> (or by using mount<typeof TextTicker>() if
mount supports generics) so IntelliSense and type safety reference the
TextTicker component directly; adjust imports to pull in ReactWrapper/VueWrapper
or the appropriate generic helper and update any usages accordingly.
src/composables/sidebarTabs/useAssetsSidebarTab.test.ts (1)

26-26: Use function reference in describe for Vitest rule alignment.

♻️ Suggested change
-describe('useAssetsSidebarTab', () => {
+describe(useAssetsSidebarTab, () => {

Based on learnings: “In test files under src/**/*.test.ts, follow the vitest/prefer-describe-function-title rule by using describe(ComponentOrFunction, ...) instead of a string literal description when naming test suites.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/composables/sidebarTabs/useAssetsSidebarTab.test.ts` at line 26, Replace
the string literal test-suite title with the actual function reference to
satisfy vitest/prefer-describe-function-title: change
describe('useAssetsSidebarTab', ...) to describe(useAssetsSidebarTab, ...) and
ensure the symbol useAssetsSidebarTab is imported from its module at the top of
the test file so the reference resolves.
src/composables/sidebarTabs/useAssetsSidebarTab.ts (1)

8-9: Prefer a function declaration for the composable.

This is a pure function and can be expressed as a declaration for consistency with repo conventions.

♻️ Suggested refactor
-export const useAssetsSidebarTab = (): SidebarTabExtension => {
+export function useAssetsSidebarTab(): SidebarTabExtension {
   return {
     id: 'assets',
     icon: 'icon-[comfy--image-ai-edit]',
     title: 'sideToolbar.assets',
     tooltip: 'sideToolbar.assets',
     label: 'sideToolbar.labels.assets',
     component: markRaw(AssetsSidebarTab),
     type: 'vue',
     iconBadge: () => {
       const settingStore = useSettingStore()

       if (!settingStore.get('Comfy.Queue.QPOV2')) {
         return null
       }

       const queueStore = useQueueStore()
       const count = queueStore.activeJobsCount
       return count > 0 ? count.toString() : null
     }
   }
 }

Based on learnings: “Prefer pure function declarations over function expressions (e.g., use function foo() { ... } instead of const foo = () => { ... }) for pure functions in the repository.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/composables/sidebarTabs/useAssetsSidebarTab.ts` around lines 8 - 9,
Convert the exported const arrow function useAssetsSidebarTab into a named
function declaration to match repository conventions for pure composables:
change "export const useAssetsSidebarTab = (): SidebarTabExtension => { ... }"
to a function declaration "export function useAssetsSidebarTab():
SidebarTabExtension { ... }" and keep the same body and return value so all
callers and the SidebarTabExtension return type remain unchanged.
src/components/common/SearchBoxV2.vue (1)

110-116: Consider initial emission behavior.

watchDebounced will emit on initial value changes. If the component mounts with an existing searchTerm value, it will emit a search event after the debounce delay. Verify this is the intended behavior, or add { immediate: false } if initial emission should be suppressed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/SearchBoxV2.vue` around lines 110 - 116, The current
watchDebounced on searchTerm will trigger an initial debounced emit of the
'search' event when the component mounts with a pre-filled value; if that
initial emission is undesired, update the watchDebounced call (the watcher using
searchTerm, emit, and debounceTime) to include immediate: false in the options
(e.g., { debounce: debounceTime, immediate: false }) so it does not emit on
initial mount, otherwise leave as-is if initial emission is intended.
src/composables/node/useNodePreviewAndDrag.ts (1)

100-129: Potential timing issue with drag image cleanup.

The drag image is appended to the document body and removed in a requestAnimationFrame callback:

document.body.appendChild(dragImage)
e.dataTransfer.setDragImage(dragImage, 0, 0)

requestAnimationFrame(() => {
  document.body.removeChild(dragImage)
})

While this pattern is commonly used, requestAnimationFrame executes before the next paint, which should be sufficient for setDragImage to capture the element. However, if the drag operation is cancelled very quickly, there's a small chance the element is removed before the browser fully processes the drag image.

Consider using a slightly longer delay or keeping the element until dragend:

♻️ Safer cleanup approach
 function handleDragStart(e: DragEvent) {
   if (!nodeDef.value) return

   isDragging.value = true
   isHovered.value = false

   startDrag(nodeDef.value, 'native')

   if (e.dataTransfer) {
     e.dataTransfer.effectAllowed = 'copy'
     e.dataTransfer.setData('application/x-comfy-node', nodeDef.value.name)

     const dragImage = createEmptyDragImage()
     document.body.appendChild(dragImage)
     e.dataTransfer.setDragImage(dragImage, 0, 0)

-    requestAnimationFrame(() => {
-      document.body.removeChild(dragImage)
-    })
+    // Use setTimeout to ensure the drag image is captured
+    setTimeout(() => {
+      dragImage.remove()
+    }, 0)
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/composables/node/useNodePreviewAndDrag.ts` around lines 100 - 129, The
temporary drag image may be removed too early by requestAnimationFrame in
handleDragStart, risking the browser missing it; instead, append the drag image
created by createEmptyDragImage and defer removal until the drag operation
completes by storing the created element and removing it in a dragend listener
(or as a fallback use a small timeout), i.e., attach a one-time 'dragend'
handler that removes the dragImage from document.body and cleans up the listener
so the element persists for the full drag lifecycle.
src/components/searchbox/v2/NodeSearchInput.vue (1)

16-24: Consider using the shared Button component for the cancel filter button.

Per coding guidelines, raw <button> elements should be replaced with the shared Button component for consistent styling. However, this is a small icon-only button embedded within a tag chip, where inline styling may be acceptable for this compact use case.

The aria-label is correctly provided for accessibility since this is an icon-only button.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/searchbox/v2/NodeSearchInput.vue` around lines 16 - 24,
Replace the raw <button> in NodeSearchInput.vue with the shared Button component
to enforce consistent styling: import and use the shared Button, preserve
attributes data-testid="cancel-filter", type="button" and
:aria-label="$t('g.remove')", and wire its click to emit('cancelFilter'); ensure
the Button uses an icon-only/ghost/icon-size variant (or equivalent props) and
renders the same <i class="pi pi-times text-xs" /> child so visual appearance
remains unchanged.
src/components/sidebar/tabs/nodeLibrary/NodeDragPreview.vue (1)

58-68: Use VueUse useEventListener for drag/dragend listeners.

It simplifies lifecycle cleanup and aligns with the repo’s VueUse guidance.

As per coding guidelines: src/**/*.{vue,ts}: Leverage VueUse functions for performance-enhancing styles.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/sidebar/tabs/nodeLibrary/NodeDragPreview.vue` around lines 58
- 68, Replace the manual document.addEventListener / removeEventListener pattern
with VueUse's useEventListener to auto-handle lifecycle and align with repo
guidance: inside the setup where onMounted currently calls setupGlobalListeners
and adds listeners, call useEventListener(document, 'drag', handleDrag) and
useEventListener(document, 'dragend', handleDragEnd) (imported from
'@vueuse/core'), then remove the explicit document.removeEventListener calls and
the manual cleanup in onUnmounted (you can still call cleanupGlobalListeners if
it does other work). Ensure you keep references to the existing handler
functions handleDrag and handleDragEnd and remove the add/removeEventListener
lines in NodeDragPreview.vue.
src/components/searchbox/v2/NodeSearchInput.test.ts (2)

56-73: Type createWrapper props with ComponentProps<typeof NodeSearchInput>.

This keeps the helper aligned with the component’s real props and matches the repo’s test helper typing convention.

🔧 Suggested change
 import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ComponentProps } from 'vue-component-type-helpers'
@@
-function createWrapper(
-  props: Partial<{
-    filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
-    activeFilter: FilterChip | null
-    searchQuery: string
-    filterQuery: string
-  }> = {}
-) {
+type NodeSearchInputProps = ComponentProps<typeof NodeSearchInput>
+
+function createWrapper(props: Partial<NodeSearchInputProps> = {}) {
   return mount(NodeSearchInput, {
     props: {
       filters: [],
       activeFilter: null,
       searchQuery: '',
       filterQuery: '',
-      ...props
-    },
+      ...props
+    } as NodeSearchInputProps,
     global: { plugins: [testI18n] }
   })
 }

Based on learnings and coding guidelines: In test files, type helper props as Partial<ComponentProps>; **/*.ts: Derive component types using vue-component-type-helpers (ComponentProps, ComponentSlots).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/searchbox/v2/NodeSearchInput.test.ts` around lines 56 - 73,
The createWrapper helper in NodeSearchInput.test.ts currently types its props
inline; change the function signature to type props as
Partial<ComponentProps<typeof NodeSearchInput>> (using the ComponentProps
helper) so the test helper matches the component's actual props; update the
import to bring in ComponentProps from the repo's vue-component-type-helpers (or
the existing helper module) and leave the rest of createWrapper (props spreading
into mount) unchanged, referencing the createWrapper function and
NodeSearchInput component names when making the change.

111-135: Prefer accessible queries over data-testid for chip/cancel assertions.

If the chip label/cancel button has visible text or role, query by accessible name instead of test IDs to keep tests closer to user behavior.

Based on learnings: In test files, prefer selecting or asserting on accessible properties (text content, aria-label, role, accessible name) over data-testid attributes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/searchbox/v2/NodeSearchInput.test.ts` around lines 111 - 135,
Tests currently rely on data-testid selectors ('[data-testid="filter-chip"]' and
'[data-testid="cancel-filter"]'); update the three specs to use accessible
queries instead — e.g., in the "should hide/show filter chips" tests use
createWrapper(...) then locate chips by their visible label/text or by role
(findAll('button') and filter by text content or use a testing-library
getByRole/getAllByRole with accessible name matching the chip label created via
createFilter), and in "should emit cancelFilter" locate the cancel control by
its visible text or aria-label/role instead of '[data-testid="cancel-filter"]'
and trigger click; keep using createWrapper, createFilter, createActiveFilter
and assert emitted events the same way.
src/composables/node/useNodeDragToCanvas.ts (1)

67-83: Consider VueUse useEventListener for global pointer/keyboard listeners.

Using VueUse’s listener helpers simplifies cleanup and aligns with the repo’s composable guidelines.

As per coding guidelines: /composables//*.ts : Leverage VueUse functions for performance-enhancing composables.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/composables/node/useNodeDragToCanvas.ts` around lines 67 - 83, Replace
the manual add/removeEventListener pattern in
setupGlobalListeners/cleanupGlobalListeners with VueUse's useEventListener:
import useEventListener from '@vueuse/core', call useEventListener(document,
'pointermove', updatePosition), useEventListener(document, 'pointerup', endDrag,
{ capture: true }), and useEventListener(document, 'keydown', handleKeydown)
inside setupGlobalListeners, capture the returned stop functions (or disposers)
into a local array or object keyed by listener, and then call those stop
functions in cleanupGlobalListeners instead of removeEventListener; keep the
listenersSetup flag and retain references to updatePosition, endDrag, and
handleKeydown to ensure correct teardown.
src/composables/node/useNodePreviewAndDrag.test.ts (1)

8-16: Avoid module-scope mutable mocks; use vi.hoisted.

Hoisting keeps mocks contained and prevents leakage across tests.

🔧 Suggested change
-const mockStartDrag = vi.fn()
-const mockHandleNativeDrop = vi.fn()
+const { mockStartDrag, mockHandleNativeDrop } = vi.hoisted(() => ({
+  mockStartDrag: vi.fn(),
+  mockHandleNativeDrop: vi.fn()
+}))

Based on learnings: Keep module mocks contained; do not use global mutable state within test files; use vi.hoisted() if necessary for per-test Arrange phase manipulation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/composables/node/useNodePreviewAndDrag.test.ts` around lines 8 - 16, The
module-scope mutable mocks mockStartDrag and mockHandleNativeDrop leak across
tests; replace their top-level declarations with vi.hoisted(() => vi.fn()) so
each test can arrange/reset them safely and use them inside the vi.mock for
useNodeDragToCanvas (so startDrag and handleNativeDrop reference the hoisted
fns), and ensure you reset or reassign those hoisted mocks in
beforeEach/afterEach as needed to avoid cross-test state.
src/components/queue/job/JobContextMenu.test.ts (1)

24-36: Prefer function declarations for test helpers.

Suggested refactor
-const createEntries = (): MenuEntry[] => [
-  { key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
-  {
-    key: 'disabled',
-    label: 'Disabled action',
-    disabled: true,
-    onClick: vi.fn()
-  },
-  { kind: 'divider', key: 'divider-1' }
-]
+function createEntries(): MenuEntry[] {
+  return [
+    { key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
+    {
+      key: 'disabled',
+      label: 'Disabled action',
+      disabled: true,
+      onClick: vi.fn()
+    },
+    { kind: 'divider', key: 'divider-1' }
+  ]
+}
 
-const mountComponent = (entries: MenuEntry[]) =>
-  mount(JobContextMenu, {
-    props: { entries },
-    global: {
-      stubs: {
-        Popover: {
-          template: '<div class="popover-stub"><slot /></div>'
-        },
-        Button: buttonStub
-      }
-    }
-  })
+function mountComponent(entries: MenuEntry[]) {
+  return mount(JobContextMenu, {
+    props: { entries },
+    global: {
+      stubs: {
+        Popover: {
+          template: '<div class="popover-stub"><slot /></div>'
+        },
+        Button: buttonStub
+      }
+    }
+  })
+}

As per coding guidelines: Do not use function expressions if it's possible to use function declarations instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/queue/job/JobContextMenu.test.ts` around lines 24 - 36, The
test file uses arrow function expressions for helper utilities; replace the
createEntries and mountComponent arrow functions with equivalent function
declarations (e.g., function createEntries(): MenuEntry[] { ... } and function
mountComponent(entries: MenuEntry[]) { ... }) so they follow the coding
guideline against function expressions; update any local references to these
helpers but keep their names (createEntries, mountComponent) and usage with the
JobContextMenu mount unchanged.
src/components/sidebar/tabs/nodeLibrary/EssentialNodesPanel.test.ts (1)

136-146: Multiple tick flushes may be fragile.

The sequence await nextTick(); await flushPromises(); await nextTick() on lines 138-140 suggests complex async timing requirements. While this works, it may be fragile if the component's internal timing changes. Consider documenting why this sequence is needed or exploring if the component can settle with fewer flushes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/sidebar/tabs/nodeLibrary/EssentialNodesPanel.test.ts` around
lines 136 - 146, The test "should expand all folders by default when
expandedKeys is empty" relies on a fragile sequence of awaits (nextTick,
flushPromises, nextTick); either simplify to a single stable await (prefer using
flushPromises() once or await wrapper.vm.$nextTick()) so the component settles
deterministically, or add a short comment above the sequence explaining why both
nextTick and flushPromises are required; update the test around
mountComponent(createMockRoot(), []), nextTick, and flushPromises usage
accordingly and keep assertions against wrapper.findAll('.collapsible-root')
unchanged.
src/components/common/TreeExplorerV2Node.test.ts (1)

33-53: Consider using satisfies for type-safe mock construction.

The createMockItem helper uses as RenderedTreeExplorerNode<ComfyNodeDefImpl> type assertion. Per codebase conventions, consider using satisfies for better type safety when constructing mock objects, though this works correctly for the current tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/TreeExplorerV2Node.test.ts` around lines 33 - 53,
createMockItem currently uses a type assertion ("as
RenderedTreeExplorerNode<ComfyNodeDefImpl>") which bypasses excess property
checks; replace that assertion with the TypeScript satisfies operator to ensure
the object literal is checked against RenderedTreeExplorerNode<ComfyNodeDefImpl>
while preserving its inferred narrower type (e.g. change the value declaration
to use "satisfies RenderedTreeExplorerNode<ComfyNodeDefImpl>" instead of "as
..."); keep the returned FlattenedItem shape and other fields intact and ensure
the file compiles with the project's TS version that supports satisfies.
src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue (2)

97-97: Use cn from @/utils/tailwindUtil per repo guideline.

The repo standardizes on the local cn helper to ensure consistent tailwind-merge behavior. As per coding guidelines “Use cn() utility from @/utils/tailwindUtil to merge class names; never use :class="[]" syntax.”

♻️ Suggested refactor
-import { cn } from '@comfyorg/tailwind-utils'
+import { cn } from '@/utils/tailwindUtil'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue` at line 97, Replace
the external cn import with the repo-standard local helper: change the import of
cn in NodeLibrarySidebarTabV2.vue from '@comfyorg/tailwind-utils' to the local
'@/utils/tailwindUtil' and update any class bindings in the component to use the
cn(...) utility (instead of array-style :class="[...]") so tailwind-merge
behavior is consistent; search for usages like cn(...) or :class="[" in this
file and swap array bindings to cn calls where appropriate.

14-19: Swap the raw icon button for the shared Button component.

Use the shared Button component for consistency with the design system and behavior. Based on learnings “In the ComfyUI_frontend Vue codebase, replace raw HTML elements with the shared Button component located at src/components/ui/button/Button.vue. Import and use it with appropriate variants (e.g., variant="link") to align with the design system. Apply this pattern across Vue components under src/components, ensuring consistent styling and behavior instead of ad-hoc button markup.”

♻️ Suggested refactor
+import Button from '@/components/ui/button/Button.vue'
@@
-              <button
+              <Button
                 :aria-label="$t('g.sort')"
                 class="flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-comfy-input hover:bg-comfy-input-hover border-none"
               >
                 <i class="icon-[lucide--arrow-up-down] size-4" />
-              </button>
+              </Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue` around lines 14 -
19, In NodeLibrarySidebarTabV2.vue replace the raw <button> element (the
aria-label="$t('g.sort')" button with the <i
class="icon-[lucide--arrow-up-down]">) with the shared Button component from
src/components/ui/button/Button.vue: import Button and render it where the
button was, passing aria-label="$t('g.sort')" and set the visual props (e.g.,
variant="link" or the equivalent props used across the codebase) and classes
(size-10, rounded-lg, bg-comfy-input, hover:bg-comfy-input-hover, shrink-0,
items-center, justify-center) so styling/behavior remain identical while
removing the raw <button> markup and keeping the inner icon element intact.
Ensure any event handlers or attributes on the original element are moved to the
Button usage.

src/components/searchbox/v2/NodeSearchCategorySidebar.vue (1)

5-30: Use the shared Button component for category items.

The preset/source category actions should use the shared Button component for design-system consistency instead of raw <button> tags. Based on learnings “In the ComfyUI_frontend Vue codebase, replace raw HTML elements with the shared Button component located at src/components/ui/button/Button.vue. Import and use it with appropriate variants (e.g., variant="link") to align with the design system. Apply this pattern across Vue components under src/components, ensuring consistent styling and behavior instead of ad-hoc button markup.”

♻️ Suggested refactor
<script setup lang="ts">
-import { computed, ref } from 'vue'
+import { computed, ref } from 'vue'
+import Button from '@/components/ui/button/Button.vue'
-      <button
+      <Button
         v-for="preset in topCategories"
         :key="preset.id"
         type="button"
         :data-testid="`category-${preset.id}`"
         :aria-current="selectedCategory === preset.id || undefined"
         :class="categoryBtnClass(preset.id)"
         `@click`="selectCategory(preset.id)"
       >
         {{ preset.label }}
-      </button>
+      </Button>
-      <button
+      <Button
         v-for="preset in sourceCategories"
         :key="preset.id"
         type="button"
         :data-testid="`category-${preset.id}`"
         :aria-current="selectedCategory === preset.id || undefined"
         :class="categoryBtnClass(preset.id)"
         `@click`="selectCategory(preset.id)"
       >
         {{ preset.label }}
-      </button>
+      </Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/searchbox/v2/NodeSearchCategorySidebar.vue` around lines 5 -
30, Top and source category list items currently render raw <button> elements;
replace them with the shared Button component to ensure design-system
consistency. Import the Button component from
src/components/ui/button/Button.vue into NodeSearchCategorySidebar.vue, replace
the v-for buttons for topCategories and sourceCategories with <Button> using
variant="link" (or appropriate variant), keep the same :key, :data-testid,
:aria-current binding, :class bound to categoryBtnClass(preset.id), and the
`@click`="selectCategory(preset.id)" handler, and ensure the label stays as the
slot/content so selectedCategory, categoryBtnClass, and selectCategory continue
to work unchanged.

src/composables/node/useNodePricing.test.ts (1)

1017-1033: Prefer satisfies ComfyNodeDef over type assertions for mock defs.

This keeps type checking strict without forcing assertions. Based on learnings “In test files matching **/*.test.ts under src, when creating test helper functions that construct mock objects implementing an interface (e.g., AssetItem), prefer using satisfies InterfaceType for shape validation instead of type assertions like as Partial as InterfaceType or as any.”

♻️ Suggested refactor
-  const createMockNodeDef = (
-    overrides: Partial<ComfyNodeDef> = {}
-  ): ComfyNodeDef =>
-    ({
-      name: 'TestNode',
-      display_name: 'Test Node',
-      description: '',
-      category: 'test',
-      input: { required: {}, optional: {} },
-      output: [],
-      output_name: [],
-      output_is_list: [],
-      python_module: 'test',
-      ...overrides
-    }) as ComfyNodeDef
+  const createMockNodeDef = (
+    overrides: Partial<ComfyNodeDef> = {}
+  ): ComfyNodeDef => {
+    const nodeDef = {
+      name: 'TestNode',
+      display_name: 'Test Node',
+      description: '',
+      category: 'test',
+      input: { required: {}, optional: {} },
+      output: [],
+      output_name: [],
+      output_is_list: [],
+      python_module: 'test',
+      ...overrides
+    } satisfies ComfyNodeDef
+    return nodeDef
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/composables/node/useNodePricing.test.ts` around lines 1017 - 1033, The
mock factory createMockNodeDef currently ends with a type assertion ("as
ComfyNodeDef"); replace that assertion with a TypeScript "satisfies
ComfyNodeDef" usage so the object literal is type-checked for the ComfyNodeDef
shape without forcing a cast. Locate the createMockNodeDef function in this test
and change the return expression to use "satisfies ComfyNodeDef" (preserving
overrides: Partial<ComfyNodeDef> param) so invalid or missing properties are
caught by the compiler while still allowing easy overrides in tests.
src/components/sidebar/tabs/nodeLibrary/AllNodesPanel.vue (1)

36-76: Avoid destructuring defineProps to preserve reactivity.

Destructuring fillNodeInfo can break reactivity if the prop ever changes; prefer a props object (or toRefs) and reference props.fillNodeInfo. Based on learnings “In Vue 3 script setup, props defined with defineProps are automatically available by name in the template without destructuring. Destructuring the result of defineProps inside script can break reactivity; prefer accessing props by name in the template. If you need to use props in the script, reference them via the defined props object rather than destructuring, or use toRefs when you intend to destructure while preserving reactivity.”

♻️ Suggested refactor
-const { fillNodeInfo } = defineProps<{
+const props = defineProps<{
   sections: NodeLibrarySection[]
   fillNodeInfo: (node: TreeNode) => RenderedTreeExplorerNode<ComfyNodeDefImpl>
 }>()
@@
-const favoritesRoot = computed(() =>
-  fillNodeInfo(nodeBookmarkStore.bookmarkedRoot)
-)
+const favoritesRoot = computed(() =>
+  props.fillNodeInfo(nodeBookmarkStore.bookmarkedRoot)
+)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/sidebar/tabs/nodeLibrary/AllNodesPanel.vue` around lines 36 -
76, The code destructures defineProps into fillNodeInfo which can break
reactivity; change to capture the props object instead (e.g., const props =
defineProps<...>()) and replace all uses of fillNodeInfo with props.fillNodeInfo
(update favoritesRoot computed and any other references), or use toRefs if you
need to destructure while preserving reactivity; ensure handleAddToFavorites,
hasFavorites, and any other places reference props.fillNodeInfo so the prop
remains reactive.
src/components/common/TreeExplorerV2Node.vue (1)

62-62: Consider extracting the ref callback to avoid inline type assertion.

The inline ref callback with type assertion works but could be cleaner:

:ref="(el) => (previewRef = el as HTMLElement)"

This pattern is functional but the as HTMLElement cast is needed because template refs can be null. Consider if this is intentional when the teleport condition is false.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/TreeExplorerV2Node.vue` at line 62, Replace the inline
ref callback that assigns previewRef with a named method to avoid the inline
type assertion; create a function (e.g., setPreviewRef(el: HTMLElement | null))
that sets previewRef = el and use :ref="setPreviewRef" in the template, and
ensure the function handles null when the teleport condition is false so no
non-null assertion/cast is needed.
src/composables/node/useNodePricing.ts (1)

499-504: Consider logging errors in catch block for debugging.

The error is silently suppressed to avoid retry-spam, but this loses valuable debugging context. Consider adding a debug-level log:

🔧 Suggested improvement
-    .catch(() => {
+    .catch((err) => {
+      if (import.meta.env.DEV) {
+        console.debug('[pricing/jsonata] evaluation failed:', err)
+      }
       // Cache empty to avoid retry-spam for same signature
       if (desiredSig.get(node) === sig) {
         cache.set(node, { sig, label: '' })
       }
     })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/composables/node/useNodePricing.ts` around lines 499 - 504, The catch
block that currently swallows errors when fetching pricing (the block
referencing desiredSig, cache, node, sig) should record the error at debug level
before setting the empty-cache entry to avoid retry spam; update the .catch
handler to accept the error parameter and call the module/logger debug method
(include node and sig in the message for context) and then preserve the existing
behavior of setting cache.set(node, { sig, label: '' }) only when
desiredSig.get(node) === sig.

Comment on lines +44 to +50
if (filled) {
return {
borderColor: borderStyle,
backgroundColor: `${borderStyle}33`,
color: borderStyle
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential issue: Filled state assumes hex color format for borderStyle.

The expression ${borderStyle}33 appends 33 (20% alpha in hex) directly to the borderStyle value. This works for hex colors like #f59e0b#f59e0b33, but will produce invalid CSS for:

  • CSS variables: var(--color)33
  • Named colors: red33
  • RGB/HSL: rgb(255,0,0)33

Consider using CSS color-mix() or a helper function to handle different color formats, or document that borderStyle must be a hex color when filled is true.

💡 Alternative using CSS color-mix (if browser support permits)
  if (filled) {
    return {
      borderColor: borderStyle,
-     backgroundColor: `${borderStyle}33`,
+     backgroundColor: `color-mix(in srgb, ${borderStyle} 20%, transparent)`,
      color: borderStyle
    }
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (filled) {
return {
borderColor: borderStyle,
backgroundColor: `${borderStyle}33`,
color: borderStyle
}
}
if (filled) {
return {
borderColor: borderStyle,
backgroundColor: `color-mix(in srgb, ${borderStyle} 20%, transparent)`,
color: borderStyle
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/BadgePill.vue` around lines 44 - 50, The filled branch
currently constructs backgroundColor by naively appending "33" to borderStyle
(in the block guarded by filled returning borderColor/backgroundColor/color),
which only works for hex colors; change this to detect hex via a simple regex
and append "33" for hex values, otherwise generate a proper translucent variant
using CSS color-mix (e.g., color-mix(in srgb, <borderStyle> 20%, transparent))
or convert rgb()/hsl() to an rgba()/hsla() equivalent as a fallback; update the
backgroundColor assignment in the filled return to use this helper logic so
borderStyle remains the source value for color and borderColor.

Comment on lines +3 to +4
<TreeRoot
:expanded="[...expandedKeys]"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid defensive array copy for TreeRoot expanded binding.

Line 4 uses :expanded="[...expandedKeys]" which creates a defensive copy. Per established patterns for Reka UI Tree components, this breaks the reactivity contract and can cause state desynchronization.

Use v-model:expanded instead to let TreeRoot manage the controlled component pattern correctly.

🔧 Proposed fix
   <ContextMenuRoot>
     <TreeRoot
-      :expanded="[...expandedKeys]"
+      v-model:expanded="expandedKeys"
       :items="root.children ?? []"

Based on learnings: "In TreeExplorerV2.vue and similar Reka UI Tree components, avoid defensive copies in bindings like :expanded='[...expandedKeys]'. Use v-model:expanded='expandedKeys' instead."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<TreeRoot
:expanded="[...expandedKeys]"
<ContextMenuRoot>
<TreeRoot
v-model:expanded="expandedKeys"
:items="root.children ?? []"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/TreeExplorerV2.vue` around lines 3 - 4, The binding for
TreeRoot currently uses a defensive copy (:expanded="[...expandedKeys]") which
breaks the Tree component's reactivity; change the binding to use the controlled
pattern by replacing the defensive copy with v-model:expanded bound to the
original state (use v-model:expanded="expandedKeys" on the TreeRoot component)
so TreeRoot can manage expansion state correctly and keep
reactivity/synchronization intact.

Comment on lines +1 to +5
import { mount } from '@vue/test-utils'
import type { FlattenedItem } from 'reka-ui'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Missing beforeEach import from Vitest.

beforeEach is used on line 232 but is not included in the import statement on line 4.

🐛 Proposed fix
-import { describe, expect, it, vi } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
🧰 Tools
🪛 ESLint

[error] 1-1: Unable to resolve path to module '@vue/test-utils'.

(import-x/no-unresolved)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/TreeExplorerV2Node.test.ts` around lines 1 - 5, The
test file is missing the beforeEach import from Vitest used later; update the
top import from 'vitest' (where describe, expect, it, vi are imported) to also
include beforeEach so the test harness can run the setup block in
TreeExplorerV2Node.test.ts (refer to the beforeEach usage around line 232) — add
beforeEach to the named imports from 'vitest'.

fallbackWarn: false
})

describe('ConfirmationDialogContent', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use component-based describe to satisfy the test rule.

Suggested fix
-describe('ConfirmationDialogContent', () => {
+describe(ConfirmationDialogContent, () => {

Based on learnings: In test files under src/**/*.test.ts, follow the vitest/prefer-describe-function-title rule by using describe(ComponentOrFunction, ...) instead of a string literal description when naming test suites.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/dialog/content/ConfirmationDialogContent.test.ts` at line 20,
The test suite uses a string literal in describe; change it to a component-based
title by replacing describe('ConfirmationDialogContent', ...) with
describe(ConfirmationDialogContent, ...) (or
describe(ConfirmationDialogContent.name, ...) if the symbol is not imported
directly) so the vitest/prefer-describe-function-title rule is satisfied; update
the import to reference ConfirmationDialogContent if needed and keep the inner
tests unchanged.

Comment on lines +25 to +40
watch(
() => nodeDef.name,
(name) => {
if (!nodeDef.api_node) {
priceLabel.value = ''
return
}
const capturedName = name
evaluateNodeDefPricing(nodeDef)
.then((label) => {
if (nodeDef.name === capturedName) priceLabel.value = label
})
.catch((e) => {
console.error('[NodePricingBadge] pricing evaluation failed:', e)
})
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clear stale pricing label before async evaluation.

When switching between API nodes, the old price label can remain visible until the new evaluation resolves. Resetting the label at the start avoids showing stale pricing.

🛠️ Proposed fix
   (name) => {
     if (!nodeDef.api_node) {
       priceLabel.value = ''
       return
     }
+    priceLabel.value = ''
     const capturedName = name
     evaluateNodeDefPricing(nodeDef)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/node/NodePricingBadge.vue` around lines 25 - 40, The watch
handler for nodeDef.name leaves the previous priceLabel visible while
evaluateNodeDefPricing runs; update the watcher (the function watching () =>
nodeDef.name) to immediately clear priceLabel.value (set to empty string) before
calling evaluateNodeDefPricing when nodeDef.api_node is truthy so stale labels
aren't shown, then proceed with the existing
evaluateNodeDefPricing(...).then(...).catch(...) logic that sets
priceLabel.value if nodeDef.name matches the capturedName.

Comment on lines +1 to +19
<template>
<div class="flex items-center gap-2 px-2 py-1.5">
<button
v-for="chip in chips"
:key="chip.key"
type="button"
:aria-pressed="activeChipKey === chip.key"
:class="
cn(
'cursor-pointer rounded-md border px-3 py-1 text-sm transition-colors flex-auto border-secondary-background',
activeChipKey === chip.key
? 'bg-secondary-background text-foreground'
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
)
"
@click="emit('selectChip', chip)"
>
{{ chip.label }}
</button>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the shared Button component instead of raw <button>.

Suggested fix
-  <div class="flex items-center gap-2 px-2 py-1.5">
-    <button
+  <div class="flex items-center gap-2 px-2 py-1.5">
+    <Button
       v-for="chip in chips"
       :key="chip.key"
       type="button"
       :aria-pressed="activeChipKey === chip.key"
       :class="
         cn(
           'cursor-pointer rounded-md border px-3 py-1 text-sm transition-colors flex-auto border-secondary-background',
           activeChipKey === chip.key
             ? 'bg-secondary-background text-foreground'
             : 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
         )
       "
       `@click`="emit('selectChip', chip)"
     >
       {{ chip.label }}
-    </button>
+    </Button>
   </div>
 import { computed } from 'vue'
 import { useI18n } from 'vue-i18n'
 
+import Button from '@/components/ui/button/Button.vue'
 import { useNodeDefStore } from '@/stores/nodeDefStore'
 import { cn } from '@/utils/tailwindUtil'

Based on learnings: In the ComfyUI_frontend Vue codebase, replace raw HTML elements with the shared Button component located at src/components/ui/button/Button.vue.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<template>
<div class="flex items-center gap-2 px-2 py-1.5">
<button
v-for="chip in chips"
:key="chip.key"
type="button"
:aria-pressed="activeChipKey === chip.key"
:class="
cn(
'cursor-pointer rounded-md border px-3 py-1 text-sm transition-colors flex-auto border-secondary-background',
activeChipKey === chip.key
? 'bg-secondary-background text-foreground'
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
)
"
@click="emit('selectChip', chip)"
>
{{ chip.label }}
</button>
<template>
<div class="flex items-center gap-2 px-2 py-1.5">
<Button
v-for="chip in chips"
:key="chip.key"
type="button"
:aria-pressed="activeChipKey === chip.key"
:class="
cn(
'cursor-pointer rounded-md border px-3 py-1 text-sm transition-colors flex-auto border-secondary-background',
activeChipKey === chip.key
? 'bg-secondary-background text-foreground'
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
)
"
`@click`="emit('selectChip', chip)"
>
{{ chip.label }}
</Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/searchbox/v2/NodeSearchFilterBar.vue` around lines 1 - 19,
Replace the raw <button> elements in NodeSearchFilterBar.vue with the shared
Button component: import and register the Button from
src/components/ui/button/Button.vue, then in the template swap the <button
v-for="chip in chips" ...> block to use <Button> (keep the v-for and
:key="chip.key"), forward attributes like type="button",
:aria-pressed="activeChipKey === chip.key", the dynamic :class binding, and the
`@click` handler to emit('selectChip', chip); ensure the Button receives the same
visual classes and accessibility attributes so chips and activeChipKey logic
continue to work.

Comment on lines +6 to +10
<SearchBox
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@search="handleSearch"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd /tmp && find . -name "NodeLibrarySidebarTabV2.vue" -type f 2>/dev/null | head -5

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

cat /tmp/repo-check.txt 2>/dev/null || echo "Repo path needed"
cd /tmp/repo && head -30 src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue 2>/dev/null || echo "File not found at expected path"

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 195


🏁 Script executed:

# First, find the file
find . -type f -name "NodeLibrarySidebarTabV2.vue" 2>/dev/null

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 127


🏁 Script executed:

# Check locale files structure
find . -type f -name "main.json" -o -name "*.json" | grep -i locale | head -10

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 367


🏁 Script executed:

# Search for search-related i18n keys
rg "g\.search" --type json -A 2 -B 2

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Search for placeholder patterns in the codebase
rg "placeholder.*\$t|:\$t.*placeholder" -g "*.vue" -A 1 -B 1 | head -30

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Read the specific file around lines 6-10
head -20 ./src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue | cat -n

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1057


🏁 Script executed:

# Search for g.search in locale files
rg "g\.search" ./src/locales/en/main.json -A 2 -B 2

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Check if g.searchPlaceholder exists
grep -n "searchPlaceholder\|search.*\.\.\." ./src/locales/en/main.json

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 360


🏁 Script executed:

# Look at the structure of g section in main.json
sed -n '/"g":/,/^  },$/p' ./src/locales/en/main.json | head -50

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1891


🏁 Script executed:

# Search for similar placeholder patterns with i18n in Vue files
rg "placeholder.*\+" ./src/components --type vue -B 2 -A 2 | head -40

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 97


🏁 Script executed:

# Check if g.search exists anywhere in main.json
grep -n '"search"' ./src/locales/en/main.json | head -20

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 97


🏁 Script executed:

# Look for the complete 'g' section more precisely
grep -n '"g":' ./src/locales/en/main.json

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 80


🏁 Script executed:

# Get lines around the g section definition
sed -n '1,100p' ./src/locales/en/main.json | tail -80

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 3126


🏁 Script executed:

# Search for where 'g' section ends to see all its keys
awk '/"g": \{/,/^  \}/' ./src/locales/en/main.json | tail -50

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 2084


🏁 Script executed:

# Look at other Vue files using SearchBox or similar patterns to see the common approach
rg "SearchBox" ./src/components --type vue -A 5 | head -50

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 97


🏁 Script executed:

# Check if g.searchPlaceholder is already defined in main.json
grep -n '"searchPlaceholder"' ./src/locales/en/main.json

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 360


🏁 Script executed:

# Count how many keys are in the g section to see if searchPlaceholder is there
awk '/"g": \{/,/^  \}/' ./src/locales/en/main.json | grep -c "search"

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 71


🏁 Script executed:

# See the exact end of the g section
awk '/"g": \{/,/^  \}/' ./src/locales/en/main.json | tail -20

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 645


🏁 Script executed:

# Check if other SearchBox components in the codebase use similar concatenation
find ./src/components -name "*.vue" -exec grep -l "SearchBox" {} \; | head -5 | xargs -I {} grep -A 5 "SearchBox" {}

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 3834


🏁 Script executed:

# Count usage of literal string concatenation with $t in placeholders across Vue files
rg '\$t\([^)]+\)\s*\+' ./src/components -g "*.vue" | head -20

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 177


Use a dedicated i18n key for the search placeholder instead of concatenating a literal.

The placeholder concatenates a literal '...' with $t('g.search'), which violates the guideline that all user-facing strings must come from i18n. This prevents translations from adjusting punctuation and spacing appropriately. Add a g.searchPlaceholder key to src/locales/en/main.json with value "Search..." and reference it directly.

♻️ Suggested refactor
-            :placeholder="$t('g.search') + '...'"
+            :placeholder="$t('g.searchPlaceholder')"

Then add to src/locales/en/main.json in the g section:

"searchPlaceholder": "Search..."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue` around lines 6 - 10,
Replace the concatenated placeholder in the SearchBox component with a dedicated
i18n key: add "searchPlaceholder": "Search..." under the "g" section in
src/locales/en/main.json, then change the placeholder prop on the SearchBox (the
component referenced by ref="searchBoxRef" and bound to searchQuery) to use
$t('g.searchPlaceholder') instead of $t('g.search') + '...'; ensure any other
usages of the concatenation are updated to use the new key.

Comment on lines +871 to +1223
describe('formatPricingResult', () => {
describe('type: usd', () => {
it('should format usd result', () => {
const result = formatPricingResult({ type: 'usd', usd: 0.05 })
expect(result).toBe('10.6 credits/Run')
})

it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'usd', usd: 0.05 },
{ valueOnly: true }
)
expect(result).toBe('10.6')
})

it('should handle approximate prefix in valueOnly mode', () => {
const result = formatPricingResult(
{ type: 'usd', usd: 0.05, format: { approximate: true } },
{ valueOnly: true }
)
expect(result).toBe('~10.6')
})

it('should return empty for null usd', () => {
const result = formatPricingResult({ type: 'usd', usd: null as never })
expect(result).toBe('')
})
})

describe('type: range_usd', () => {
it('should format range result', () => {
const result = formatPricingResult({
type: 'range_usd',
min_usd: 0.05,
max_usd: 0.1
})
expect(result).toBe('10.6-21.1 credits/Run')
})

it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: 0.05, max_usd: 0.1 },
{ valueOnly: true }
)
expect(result).toBe('10.6-21.1')
})

it('should collapse range when min equals max', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: 0.05, max_usd: 0.05 },
{ valueOnly: true }
)
expect(result).toBe('10.6')
})
})

describe('type: list_usd', () => {
it('should format list result', () => {
const result = formatPricingResult({
type: 'list_usd',
usd: [0.05, 0.1, 0.15]
})
expect(result).toMatch(/\d+\.?\d*\/\d+\.?\d*\/\d+\.?\d* credits\/Run/)
})

it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'list_usd', usd: [0.05, 0.1] },
{ valueOnly: true }
)
expect(result).toBe('10.6/21.1')
})
})

describe('type: text', () => {
it('should return text as-is', () => {
const result = formatPricingResult({ type: 'text', text: 'Free' })
expect(result).toBe('Free')
})
})

describe('legacy format', () => {
it('should handle {usd: number} without type field', () => {
const result = formatPricingResult({ usd: 0.05 })
expect(result).toBe('10.6 credits/Run')
})

it('should return valueOnly for legacy format', () => {
const result = formatPricingResult({ usd: 0.05 }, { valueOnly: true })
expect(result).toBe('10.6')
})
})

describe('invalid inputs', () => {
it('should return empty for invalid type', () => {
const result = formatPricingResult({ type: 'invalid' })
expect(result).toBe('')
})

it('should return empty for null', () => {
const result = formatPricingResult(null)
expect(result).toBe('')
})

it('should return empty for undefined', () => {
const result = formatPricingResult(undefined)
expect(result).toBe('')
})
})
})

// -----------------------------------------------------------------------------
// formatCreditsValue / Range / List Tests
// -----------------------------------------------------------------------------

describe('formatCreditsValue', () => {
it('should format USD to credits', () => {
expect(formatCreditsValue(0.05)).toBe('10.6')
expect(formatCreditsValue(1.0)).toBe('211')
})
})

describe('formatCreditsRangeValue', () => {
it('should format min-max range', () => {
expect(formatCreditsRangeValue(0.05, 0.1)).toBe('10.6-21.1')
})

it('should collapse when min equals max', () => {
expect(formatCreditsRangeValue(0.05, 0.05)).toBe('10.6')
})
})

describe('formatCreditsListValue', () => {
it('should join values with separator', () => {
expect(formatCreditsListValue([0.05, 0.1])).toBe('10.6/21.1')
})

it('should use custom separator', () => {
expect(formatCreditsListValue([0.05, 0.1], ' | ')).toBe('10.6 | 21.1')
})
})

// -----------------------------------------------------------------------------
// evaluateNodeDefPricing Tests
// -----------------------------------------------------------------------------

describe('evaluateNodeDefPricing', () => {
const createMockNodeDef = (
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef =>
({
name: 'TestNode',
display_name: 'Test Node',
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
python_module: 'test',
...overrides
}) as ComfyNodeDef

it('should return empty for node without price_badge', async () => {
const nodeDef = createMockNodeDef()
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('')
})

it('should evaluate static expression', async () => {
const nodeDef = createMockNodeDef({
name: 'StaticPriceNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd":0.05}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6')
})

it('should use default value from input spec', async () => {
const nodeDef = createMockNodeDef({
name: 'DefaultValueNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.count * 0.01}',
depends_on: {
widgets: [{ name: 'count', type: 'INT' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
count: ['INT', { default: 10 }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
})

it('should use first option for COMBO without default', async () => {
const nodeDef = createMockNodeDef({
name: 'ComboNode',
price_badge: {
engine: 'jsonata',
expr: '(widgets.mode = "pro") ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}',
depends_on: {
widgets: [{ name: 'mode', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
mode: [['standard', 'pro'], {}]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// First option is "standard", not "pro", so should be 0.05 USD
expect(result).toBe('10.6')
})

it('should use "original" as fallback for dynamic COMBO without input', async () => {
const nodeDef = createMockNodeDef({
name: 'DynamicComboNode',
price_badge: {
engine: 'jsonata',
expr: `(
$prices := {"original": 0.05, "720p": 0.03};
{"type":"usd","usd": $lookup($prices, widgets.resolution)}
)`,
depends_on: {
widgets: [{ name: 'resolution', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
// resolution widget is NOT in inputs (dynamically created)
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// Fallback to "original" = 0.05 USD
expect(result).toBe('10.6')
})

it('should handle dynamic combo with options array', async () => {
const nodeDef = createMockNodeDef({
name: 'DynamicOptionsNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.model = "model_a" ? 0.05 : 0.10}',
depends_on: {
widgets: [{ name: 'model', type: 'COMFY_DYNAMICCOMBO_V3' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
model: [
'COMFY_DYNAMICCOMBO_V3',
{ options: [{ key: 'model_a' }, { key: 'model_b' }] }
]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// First option key is "model_a" = 0.05 USD
expect(result).toBe('10.6')
})

it('should assume inputs disconnected in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputConnectedNode',
price_badge: {
engine: 'jsonata',
expr: 'inputs.image.connected ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}',
depends_on: {
widgets: [],
inputs: ['image'],
input_groups: []
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// In preview, inputs are assumed disconnected
expect(result).toBe('10.6')
})

it('should assume inputGroups have 0 count in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputGroupNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": 0.05 + inputGroups.videos * 0.02}',
depends_on: {
widgets: [],
inputs: [],
input_groups: ['videos']
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// 0.05 + 0 * 0.02 = 0.05 USD
expect(result).toBe('10.6')
})

it('should return empty on JSONata error', async () => {
const nodeDef = createMockNodeDef({
name: 'ErrorNode',
price_badge: {
engine: 'jsonata',
expr: '$lookup(undefined, "key")',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('')
})

it('should handle range_usd result', async () => {
const nodeDef = createMockNodeDef({
name: 'RangeNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"range_usd","min_usd":0.05,"max_usd":0.10}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6-21.1')
})

it('should handle approximate format in valueOnly mode', async () => {
const nodeDef = createMockNodeDef({
name: 'ApproximateNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd":0.05,"format":{"approximate":true}}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('~10.6')
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the actual test file to see imports and function availability
head -100 src/composables/node/useNodePricing.test.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 2718


🏁 Script executed:

# Check if vitest/prefer-describe-function-title is actually configured/enforced
find . -name ".eslintrc*" -o -name "eslint.config.*" -o -name ".oxlintrc*" | head -20

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1580


🏁 Script executed:

# Search for oxlint or ESLint configuration in the repo
cat .oxlintrc.json 2>/dev/null || cat eslint.config.ts 2>/dev/null || echo "No config found"

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 4012


🏁 Script executed:

# Check if other test files in src/ use function references in describe()
rg "describe\([a-zA-Z_][a-zA-Z0-9_]*,\s*\(\)" src --type ts -A 1 | head -30

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 972


Use function references in describe() titles.

The functions are already imported and available in scope. Use describe(formatPricingResult, ...), describe(formatCreditsValue, ...), and similar for the other functions to follow the repo's vitest convention and align with the pattern used elsewhere in the codebase.

♻️ Suggested refactor
-describe('formatPricingResult', () => {
+describe(formatPricingResult, () => {
@@
-describe('formatCreditsValue', () => {
+describe(formatCreditsValue, () => {
@@
-describe('formatCreditsRangeValue', () => {
+describe(formatCreditsRangeValue, () => {
@@
-describe('formatCreditsListValue', () => {
+describe(formatCreditsListValue, () => {
@@
-describe('evaluateNodeDefPricing', () => {
+describe(evaluateNodeDefPricing, () => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
describe('formatPricingResult', () => {
describe('type: usd', () => {
it('should format usd result', () => {
const result = formatPricingResult({ type: 'usd', usd: 0.05 })
expect(result).toBe('10.6 credits/Run')
})
it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'usd', usd: 0.05 },
{ valueOnly: true }
)
expect(result).toBe('10.6')
})
it('should handle approximate prefix in valueOnly mode', () => {
const result = formatPricingResult(
{ type: 'usd', usd: 0.05, format: { approximate: true } },
{ valueOnly: true }
)
expect(result).toBe('~10.6')
})
it('should return empty for null usd', () => {
const result = formatPricingResult({ type: 'usd', usd: null as never })
expect(result).toBe('')
})
})
describe('type: range_usd', () => {
it('should format range result', () => {
const result = formatPricingResult({
type: 'range_usd',
min_usd: 0.05,
max_usd: 0.1
})
expect(result).toBe('10.6-21.1 credits/Run')
})
it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: 0.05, max_usd: 0.1 },
{ valueOnly: true }
)
expect(result).toBe('10.6-21.1')
})
it('should collapse range when min equals max', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: 0.05, max_usd: 0.05 },
{ valueOnly: true }
)
expect(result).toBe('10.6')
})
})
describe('type: list_usd', () => {
it('should format list result', () => {
const result = formatPricingResult({
type: 'list_usd',
usd: [0.05, 0.1, 0.15]
})
expect(result).toMatch(/\d+\.?\d*\/\d+\.?\d*\/\d+\.?\d* credits\/Run/)
})
it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'list_usd', usd: [0.05, 0.1] },
{ valueOnly: true }
)
expect(result).toBe('10.6/21.1')
})
})
describe('type: text', () => {
it('should return text as-is', () => {
const result = formatPricingResult({ type: 'text', text: 'Free' })
expect(result).toBe('Free')
})
})
describe('legacy format', () => {
it('should handle {usd: number} without type field', () => {
const result = formatPricingResult({ usd: 0.05 })
expect(result).toBe('10.6 credits/Run')
})
it('should return valueOnly for legacy format', () => {
const result = formatPricingResult({ usd: 0.05 }, { valueOnly: true })
expect(result).toBe('10.6')
})
})
describe('invalid inputs', () => {
it('should return empty for invalid type', () => {
const result = formatPricingResult({ type: 'invalid' })
expect(result).toBe('')
})
it('should return empty for null', () => {
const result = formatPricingResult(null)
expect(result).toBe('')
})
it('should return empty for undefined', () => {
const result = formatPricingResult(undefined)
expect(result).toBe('')
})
})
})
// -----------------------------------------------------------------------------
// formatCreditsValue / Range / List Tests
// -----------------------------------------------------------------------------
describe('formatCreditsValue', () => {
it('should format USD to credits', () => {
expect(formatCreditsValue(0.05)).toBe('10.6')
expect(formatCreditsValue(1.0)).toBe('211')
})
})
describe('formatCreditsRangeValue', () => {
it('should format min-max range', () => {
expect(formatCreditsRangeValue(0.05, 0.1)).toBe('10.6-21.1')
})
it('should collapse when min equals max', () => {
expect(formatCreditsRangeValue(0.05, 0.05)).toBe('10.6')
})
})
describe('formatCreditsListValue', () => {
it('should join values with separator', () => {
expect(formatCreditsListValue([0.05, 0.1])).toBe('10.6/21.1')
})
it('should use custom separator', () => {
expect(formatCreditsListValue([0.05, 0.1], ' | ')).toBe('10.6 | 21.1')
})
})
// -----------------------------------------------------------------------------
// evaluateNodeDefPricing Tests
// -----------------------------------------------------------------------------
describe('evaluateNodeDefPricing', () => {
const createMockNodeDef = (
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef =>
({
name: 'TestNode',
display_name: 'Test Node',
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
python_module: 'test',
...overrides
}) as ComfyNodeDef
it('should return empty for node without price_badge', async () => {
const nodeDef = createMockNodeDef()
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('')
})
it('should evaluate static expression', async () => {
const nodeDef = createMockNodeDef({
name: 'StaticPriceNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd":0.05}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6')
})
it('should use default value from input spec', async () => {
const nodeDef = createMockNodeDef({
name: 'DefaultValueNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.count * 0.01}',
depends_on: {
widgets: [{ name: 'count', type: 'INT' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
count: ['INT', { default: 10 }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
})
it('should use first option for COMBO without default', async () => {
const nodeDef = createMockNodeDef({
name: 'ComboNode',
price_badge: {
engine: 'jsonata',
expr: '(widgets.mode = "pro") ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}',
depends_on: {
widgets: [{ name: 'mode', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
mode: [['standard', 'pro'], {}]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// First option is "standard", not "pro", so should be 0.05 USD
expect(result).toBe('10.6')
})
it('should use "original" as fallback for dynamic COMBO without input', async () => {
const nodeDef = createMockNodeDef({
name: 'DynamicComboNode',
price_badge: {
engine: 'jsonata',
expr: `(
$prices := {"original": 0.05, "720p": 0.03};
{"type":"usd","usd": $lookup($prices, widgets.resolution)}
)`,
depends_on: {
widgets: [{ name: 'resolution', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
// resolution widget is NOT in inputs (dynamically created)
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// Fallback to "original" = 0.05 USD
expect(result).toBe('10.6')
})
it('should handle dynamic combo with options array', async () => {
const nodeDef = createMockNodeDef({
name: 'DynamicOptionsNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.model = "model_a" ? 0.05 : 0.10}',
depends_on: {
widgets: [{ name: 'model', type: 'COMFY_DYNAMICCOMBO_V3' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
model: [
'COMFY_DYNAMICCOMBO_V3',
{ options: [{ key: 'model_a' }, { key: 'model_b' }] }
]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// First option key is "model_a" = 0.05 USD
expect(result).toBe('10.6')
})
it('should assume inputs disconnected in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputConnectedNode',
price_badge: {
engine: 'jsonata',
expr: 'inputs.image.connected ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}',
depends_on: {
widgets: [],
inputs: ['image'],
input_groups: []
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// In preview, inputs are assumed disconnected
expect(result).toBe('10.6')
})
it('should assume inputGroups have 0 count in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputGroupNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": 0.05 + inputGroups.videos * 0.02}',
depends_on: {
widgets: [],
inputs: [],
input_groups: ['videos']
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// 0.05 + 0 * 0.02 = 0.05 USD
expect(result).toBe('10.6')
})
it('should return empty on JSONata error', async () => {
const nodeDef = createMockNodeDef({
name: 'ErrorNode',
price_badge: {
engine: 'jsonata',
expr: '$lookup(undefined, "key")',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('')
})
it('should handle range_usd result', async () => {
const nodeDef = createMockNodeDef({
name: 'RangeNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"range_usd","min_usd":0.05,"max_usd":0.10}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6-21.1')
})
it('should handle approximate format in valueOnly mode', async () => {
const nodeDef = createMockNodeDef({
name: 'ApproximateNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd":0.05,"format":{"approximate":true}}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('~10.6')
})
describe(formatPricingResult, () => {
describe('type: usd', () => {
it('should format usd result', () => {
const result = formatPricingResult({ type: 'usd', usd: 0.05 })
expect(result).toBe('10.6 credits/Run')
})
it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'usd', usd: 0.05 },
{ valueOnly: true }
)
expect(result).toBe('10.6')
})
it('should handle approximate prefix in valueOnly mode', () => {
const result = formatPricingResult(
{ type: 'usd', usd: 0.05, format: { approximate: true } },
{ valueOnly: true }
)
expect(result).toBe('~10.6')
})
it('should return empty for null usd', () => {
const result = formatPricingResult({ type: 'usd', usd: null as never })
expect(result).toBe('')
})
})
describe('type: range_usd', () => {
it('should format range result', () => {
const result = formatPricingResult({
type: 'range_usd',
min_usd: 0.05,
max_usd: 0.1
})
expect(result).toBe('10.6-21.1 credits/Run')
})
it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: 0.05, max_usd: 0.1 },
{ valueOnly: true }
)
expect(result).toBe('10.6-21.1')
})
it('should collapse range when min equals max', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: 0.05, max_usd: 0.05 },
{ valueOnly: true }
)
expect(result).toBe('10.6')
})
})
describe('type: list_usd', () => {
it('should format list result', () => {
const result = formatPricingResult({
type: 'list_usd',
usd: [0.05, 0.1, 0.15]
})
expect(result).toMatch(/\d+\.?\d*\/\d+\.?\d*\/\d+\.?\d* credits\/Run/)
})
it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'list_usd', usd: [0.05, 0.1] },
{ valueOnly: true }
)
expect(result).toBe('10.6/21.1')
})
})
describe('type: text', () => {
it('should return text as-is', () => {
const result = formatPricingResult({ type: 'text', text: 'Free' })
expect(result).toBe('Free')
})
})
describe('legacy format', () => {
it('should handle {usd: number} without type field', () => {
const result = formatPricingResult({ usd: 0.05 })
expect(result).toBe('10.6 credits/Run')
})
it('should return valueOnly for legacy format', () => {
const result = formatPricingResult({ usd: 0.05 }, { valueOnly: true })
expect(result).toBe('10.6')
})
})
describe('invalid inputs', () => {
it('should return empty for invalid type', () => {
const result = formatPricingResult({ type: 'invalid' })
expect(result).toBe('')
})
it('should return empty for null', () => {
const result = formatPricingResult(null)
expect(result).toBe('')
})
it('should return empty for undefined', () => {
const result = formatPricingResult(undefined)
expect(result).toBe('')
})
})
})
// -----------------------------------------------------------------------------
// formatCreditsValue / Range / List Tests
// -----------------------------------------------------------------------------
describe(formatCreditsValue, () => {
it('should format USD to credits', () => {
expect(formatCreditsValue(0.05)).toBe('10.6')
expect(formatCreditsValue(1.0)).toBe('211')
})
})
describe(formatCreditsRangeValue, () => {
it('should format min-max range', () => {
expect(formatCreditsRangeValue(0.05, 0.1)).toBe('10.6-21.1')
})
it('should collapse when min equals max', () => {
expect(formatCreditsRangeValue(0.05, 0.05)).toBe('10.6')
})
})
describe(formatCreditsListValue, () => {
it('should join values with separator', () => {
expect(formatCreditsListValue([0.05, 0.1])).toBe('10.6/21.1')
})
it('should use custom separator', () => {
expect(formatCreditsListValue([0.05, 0.1], ' | ')).toBe('10.6 | 21.1')
})
})
// -----------------------------------------------------------------------------
// evaluateNodeDefPricing Tests
// -----------------------------------------------------------------------------
describe(evaluateNodeDefPricing, () => {
const createMockNodeDef = (
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef =>
({
name: 'TestNode',
display_name: 'Test Node',
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
python_module: 'test',
...overrides
}) as ComfyNodeDef
it('should return empty for node without price_badge', async () => {
const nodeDef = createMockNodeDef()
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('')
})
it('should evaluate static expression', async () => {
const nodeDef = createMockNodeDef({
name: 'StaticPriceNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd":0.05}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6')
})
it('should use default value from input spec', async () => {
const nodeDef = createMockNodeDef({
name: 'DefaultValueNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.count * 0.01}',
depends_on: {
widgets: [{ name: 'count', type: 'INT' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
count: ['INT', { default: 10 }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
})
it('should use first option for COMBO without default', async () => {
const nodeDef = createMockNodeDef({
name: 'ComboNode',
price_badge: {
engine: 'jsonata',
expr: '(widgets.mode = "pro") ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}',
depends_on: {
widgets: [{ name: 'mode', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
mode: [['standard', 'pro'], {}]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// First option is "standard", not "pro", so should be 0.05 USD
expect(result).toBe('10.6')
})
it('should use "original" as fallback for dynamic COMBO without input', async () => {
const nodeDef = createMockNodeDef({
name: 'DynamicComboNode',
price_badge: {
engine: 'jsonata',
expr: `(
$prices := {"original": 0.05, "720p": 0.03};
{"type":"usd","usd": $lookup($prices, widgets.resolution)}
)`,
depends_on: {
widgets: [{ name: 'resolution', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
// resolution widget is NOT in inputs (dynamically created)
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// Fallback to "original" = 0.05 USD
expect(result).toBe('10.6')
})
it('should handle dynamic combo with options array', async () => {
const nodeDef = createMockNodeDef({
name: 'DynamicOptionsNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.model = "model_a" ? 0.05 : 0.10}',
depends_on: {
widgets: [{ name: 'model', type: 'COMFY_DYNAMICCOMBO_V3' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
model: [
'COMFY_DYNAMICCOMBO_V3',
{ options: [{ key: 'model_a' }, { key: 'model_b' }] }
]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// First option key is "model_a" = 0.05 USD
expect(result).toBe('10.6')
})
it('should assume inputs disconnected in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputConnectedNode',
price_badge: {
engine: 'jsonata',
expr: 'inputs.image.connected ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}',
depends_on: {
widgets: [],
inputs: ['image'],
input_groups: []
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// In preview, inputs are assumed disconnected
expect(result).toBe('10.6')
})
it('should assume inputGroups have 0 count in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputGroupNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": 0.05 + inputGroups.videos * 0.02}',
depends_on: {
widgets: [],
inputs: [],
input_groups: ['videos']
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// 0.05 + 0 * 0.02 = 0.05 USD
expect(result).toBe('10.6')
})
it('should return empty on JSONata error', async () => {
const nodeDef = createMockNodeDef({
name: 'ErrorNode',
price_badge: {
engine: 'jsonata',
expr: '$lookup(undefined, "key")',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('')
})
it('should handle range_usd result', async () => {
const nodeDef = createMockNodeDef({
name: 'RangeNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"range_usd","min_usd":0.05,"max_usd":0.10}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6-21.1')
})
it('should handle approximate format in valueOnly mode', async () => {
const nodeDef = createMockNodeDef({
name: 'ApproximateNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd":0.05,"format":{"approximate":true}}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('~10.6')
})
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/composables/node/useNodePricing.test.ts` around lines 871 - 1223, The
describe blocks use string titles instead of function references; update each
top-level describe to pass the actual function identifier (e.g.,
describe(formatPricingResult, ...), describe(formatCreditsValue, ...),
describe(formatCreditsRangeValue, ...), describe(formatCreditsListValue, ...),
and describe(evaluateNodeDefPricing, ...)) so tests follow the repo's vitest
convention—replace the string names with the corresponding imported function
references while keeping the inner test bodies unchanged.

Comment on lines +745 to +749
} else if (dep.type.toUpperCase() === 'COMBO') {
// For dynamic COMBO widgets without input spec, use a common default
// that works with most pricing expressions (e.g., resolution selectors)
rawValue = 'original'
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, examine the file and understand the context around lines 745-749
sed -n '730,760p' src/composables/node/useNodePricing.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1340


🏁 Script executed:

# Search for COMBO-related code and pricing expressions
rg -n "COMBO" src/ --type ts --type vue -B 2 -A 2 | head -100

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 97


🏁 Script executed:

# Search for 'original' references in pricing context
rg -n "'original'" src/ --type ts --type vue -B 2 -A 2 | head -100

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 97


🏁 Script executed:

# Look for pricing widget configurations and COMBO options
rg -n "price_badge\|combo\|rawValue" src/ --type json -B 2 -A 5 | head -150

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Find the normalizeWidgetValue function to understand what happens with 'original'
rg -n "normalizeWidgetValue" src/ --type ts -B 3 -A 10 | head -50

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 3946


🏁 Script executed:

# Search for COMBO widget definitions and valid options
rg -n "type.*COMBO\|COMBO.*type" src/ --type ts -B 2 -A 5 | head -80

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Look for pricing expressions that might depend on COMBO values
rg -n "depends_on\|priceBadge\|price_badge" src/ --type ts -B 2 -A 3 | head -100

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Check if there are test files related to useNodePricing
fd "useNodePricing\|pricing" --type f -e test -e spec | head -20

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Search for 'original' in the entire codebase to understand its usage context
rg -n "'original'" src/ --type ts -B 3 -A 1 | head -80

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 5946


🏁 Script executed:

# Read the complete normalizeWidgetValue function
sed -n '201,240p' src/composables/node/useNodePricing.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1096


🏁 Script executed:

# Look for pricing rule definitions and COMBO widget dependencies
rg -n "depends_on" src/composables/node/useNodePricing.ts -B 5 -A 5

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 5290


🏁 Script executed:

# Search for widget type handling specifically for COMBO
rg -n "COMBO\|combo" src/composables/node/useNodePricing.ts -i -B 3 -A 3

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Look at broader context - find where pricing rules are defined or loaded
rg -n "priceBadge\|price_badge" src/ --type ts -B 3 -A 10 | head -150

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Search for COMBO widget validation or handling anywhere in the codebase
rg -n "COMBO" src/ --type ts -i | head -50

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 6423


🏁 Script executed:

# Find ComboInputSpec definition
rg -n "ComboInputSpec\|type ComboInputSpec" src/ --type ts -B 2 -A 5 | head -80

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Look for COMBO widget options/values in actual implementations
rg -n "values\|options" src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts -B 2 -A 2

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Search for dynamic COMBO widgets (without predefined options)
rg -n "dynamic.*COMBO\|COMBO.*dynamic" src/ --type ts -i -B 3 -A 3

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Check extractDefaultFromSpec to understand how defaults are extracted
rg -n "extractDefaultFromSpec" src/composables/node/useNodePricing.ts -B 3 -A 15

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1632


🏁 Script executed:

# Look for actual dynamic COMBO examples in the codebase
rg -n "\"options\".*\[" src/composables/node/useNodePricing.ts -B 5 -A 10

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Search for nodes that have COMBO dependencies in pricing rules
rg -n "depends_on.*widgets" src/ --type ts -A 5 | grep -A 5 COMBO | head -80

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 487


🏁 Script executed:

# Look for what happens when a COMBO value is not found in options
rg -n "validate\|check\|error" src/composables/node/useNodePricing.ts | head -40

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Search for examples of resolution selector or similar COMBO widgets
rg -n "resolution\|selector" src/ --type ts -i -B 3 -A 3 | head -100

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Let's look at the full context of the pricing preview calculation
sed -n '720,800p' src/composables/node/useNodePricing.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 2218


🏁 Script executed:

# Look at the test file to see if there are test cases for COMBO fallback
cat -n src/composables/node/useNodePricing.test.ts | head -200

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 7309


🏁 Script executed:

# Search for any validation or error cases related to COMBO or pricing
rg -n "test\|describe" src/composables/node/useNodePricing.test.ts -A 10 | head -150

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Check if there are any examples of COMBO widgets with 'original' option
rg -n "'original'" src/ --type ts -B 5 -A 2 | grep -E "COMBO|combo|widget|option" -i -B 3 -A 3

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 3175


🏁 Script executed:

# Check if there are any error cases in tests related to invalid COMBO values
sed -n '200,400p' src/composables/node/useNodePricing.test.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 7050


🏁 Script executed:

# Look for any nodes that actually use dynamic COMBO with pricing
rg -n "price_badge.*depends_on" src/ --type ts -B 3 -A 5 | head -100

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

# Check if 'original' is ever used as a COMBO option in actual node definitions
rg -n "\"original\"" src/ --type ts -B 2 -A 2 | grep -i "combo\|option\|widget" -B 2 -A 2

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 911


🏁 Script executed:

# Read the complete test for dynamic COMBO fallback
sed -n '1098,1120p' src/composables/node/useNodePricing.test.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 808


🏁 Script executed:

# Check if there are any other tests that might contradict or expand on this
sed -n '1120,1150p' src/composables/node/useNodePricing.test.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 983


🏁 Script executed:

# Check if there are any error cases or edge cases in the tests related to this fallback
sed -n '1125,1200p' src/composables/node/useNodePricing.test.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 2323


🏁 Script executed:

# Check the error handling and logging for pricing evaluation failures
sed -n '765,775p' src/composables/node/useNodePricing.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 430


🏁 Script executed:

# Search for nodes that use dynamic COMBO with pricing but might not have 'original' option
rg -n "price_badge" src/ --type ts -B 5 -A 15 | grep -E "COMBO|original|dynamic" -i -B 5 -A 5 | head -100

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 5692


🏁 Script executed:

# Check if there are any comments or documentation about this fallback limitation
rg -n "TODO\|FIXME\|BUG\|HACK" src/composables/node/useNodePricing.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


The 'original' fallback for dynamic COMBO widgets is intentionally designed but could fail silently if pricing expressions don't include it.

While there is a test confirming this pattern works for resolution selectors (lines 1098-1120 of useNodePricing.test.ts), the assumption that 'original' will exist in every pricing expression's lookup table is fragile. If a COMBO widget's pricing expression doesn't include 'original' as a valid key, the JSONata $lookup would fail, triggering the error handler that silently returns an empty string instead of a price.

Consider adding validation to ensure COMBO values exist in the pricing expression before evaluation, or documenting this assumption more explicitly for node definition authors who write pricing expressions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/composables/node/useNodePricing.ts` around lines 745 - 749, The current
COMBO branch sets rawValue = 'original' when dep.type === 'COMBO', which can
silently fail if the pricing expression's lookup table doesn't include
'original'; update the logic that handles dep.type/COMBO (the block assigning
rawValue) to validate the chosen key against the pricing expression lookup
before evaluation: inspect the pricing expression's keys (or pre-evaluate the
lookup table) to confirm 'original' exists, and if it does not, either pick a
safe fallback (e.g., the first available lookup key) or log/error and skip
pricing for that node instead of relying on the try/catch that returns an empty
string; ensure any added branches reference dep.type, rawValue, and the
pricing-evaluation path so tests can be adjusted accordingly.

Comment on lines +2175 to +2180
"outputs": {
"1": {
"name": "ÍNDICE",
"tooltip": null
}
]
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

pt="src/locales/pt-BR/nodeDefs.json"
en="src/locales/en-US/nodeDefs.json"

if [[ ! -f "$pt" || ! -f "$en" ]]; then
  echo "Missing locale file(s): $pt or $en"
  exit 1
fi

python3 - <<'PY'
import json
pt_path = "src/locales/pt-BR/nodeDefs.json"
en_path = "src/locales/en-US/nodeDefs.json"
nodes = ["CustomCombo", "Load3D"]

with open(pt_path, encoding="utf-8") as f:
    pt = json.load(f)
with open(en_path, encoding="utf-8") as f:
    en = json.load(f)

for node in nodes:
    print(f"\n{node}")
    print("pt-BR outputs:", json.dumps(pt.get(node, {}).get("outputs"), indent=2))
    print("en-US outputs:", json.dumps(en.get(node, {}).get("outputs"), indent=2))
PY

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 160


🏁 Script executed:

find . -type f -name "nodeDefs.json" 2>/dev/null | head -20

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 447


🏁 Script executed:

find . -type d -name "locales" 2>/dev/null

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 578


🏁 Script executed:

python3 - <<'PY'
import json

pt_path = "./src/locales/pt-BR/nodeDefs.json"
en_path = "./src/locales/en/nodeDefs.json"
nodes = ["CustomCombo", "Load3D"]

with open(pt_path, encoding="utf-8") as f:
    pt = json.load(f)
with open(en_path, encoding="utf-8") as f:
    en = json.load(f)

for node in nodes:
    print(f"\n{'='*60}")
    print(f"Node: {node}")
    print(f"{'='*60}")
    pt_outputs = pt.get(node, {}).get("outputs", {})
    en_outputs = en.get(node, {}).get("outputs", {})
    
    print(f"\npt-BR outputs keys: {sorted(pt_outputs.keys())}")
    print(f"en outputs keys:    {sorted(en_outputs.keys())}")
    
    print(f"\npt-BR outputs:")
    print(json.dumps(pt_outputs, indent=2))
    print(f"\nen outputs:")
    print(json.dumps(en_outputs, indent=2))
PY

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1173


Add missing output definitions to pt-BR locale for CustomCombo and Load3D.

Sparse output indices in pt-BR do not align with the canonical en locale definitions:

  • CustomCombo: pt-BR defines only output "1", but en defines outputs "0" and "1". The pt-BR locale is missing the definition for output "0".
  • Load3D: pt-BR defines only output "6", but en defines outputs "0" through "6". The pt-BR locale is missing definitions for outputs "0" through "5" (image, mask, mesh_path, normal, camera_info, recording_video).

UI labels for missing outputs will not render correctly in Portuguese (Brazil). Add these output definitions to the pt-BR locale to match the canonical ordering, or verify this sparseness is intentional.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/locales/pt-BR/nodeDefs.json` around lines 2175 - 2180, The pt-BR
nodeDefs.json is missing several output entries compared to the canonical en
locale for the CustomCombo and Load3D nodes; update the "outputs" objects for
those nodes to include the full set of keys that en provides (for CustomCombo
add "0" alongside existing "1"; for Load3D add "0" through "5" alongside
existing "6") using appropriate Portuguese labels/tooltip values (e.g., image,
mask, mesh_path, normal, camera_info, recording_video translated or placeholder
null tooltips) so the output indices and ordering match the canonical en locale.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/locales/en/main.json (1)

2015-2016: Minor inconsistency in "Free Tier" capitalization.

Line 2016 uses "Free Tier" (both words capitalized) while line 1999 uses "Free Tier" in the key name but "free credits" in the description. Consider whether "Free tier" or "Free Tier" should be the standard capitalization across all user-facing strings for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/locales/en/main.json` around lines 2015 - 2016, Standardize the
capitalization of "Free Tier" across user-facing strings: update the value for
the "emailNotEligibleForFreeTier" key (and any other locale entries referencing
the free tier) to use the agreed form ("Free Tier" or "free tier") consistently;
search for keys like "emailNotEligibleForFreeTier" and
"personalDataConsentLabel" to locate occurrences and adjust the string values so
all messages use the same capitalization.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/locales/en/main.json`:
- Around line 2015-2016: Standardize the capitalization of "Free Tier" across
user-facing strings: update the value for the "emailNotEligibleForFreeTier" key
(and any other locale entries referencing the free tier) to use the agreed form
("Free Tier" or "free tier") consistently; search for keys like
"emailNotEligibleForFreeTier" and "personalDataConsentLabel" to locate
occurrences and adjust the string values so all messages use the same
capitalization.

Comment on lines +22 to +24
telemetry?.trackUiButtonClicked({
button_id: `${source}_use_email_instead`
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI this telemetry event is currently disabled due to it making us exceed our Mixpanel quota heavily. It can be re-enabled in the dynamic config by name though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can gather the metrics other ways (just see increase in proportion of signups with Google), and enrich the user with their auth provider (if it doesn't already exist).

I'll drop the additional telemetry I think.

Comment thread src/platform/cloud/subscription/constants/tierPricing.ts Outdated

@christian-byrne christian-byrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The current structure for the 3-way dialog routing feels a bit awkward — show() was the original entry point, but now the PR renames it to showPricingTable, creates a new show that conditionally calls it, and then leaks showPricingTable to callers who re-implement the free-tier branching logic themselves (e.g. line 465 in SubscriptionPanelContentWorkspace.vue: isFreeTierPlan.value ? showPricingTable() : showSubscriptionDialog()). This defeats the purpose of the composable owning the routing.

Suggestion — single show() with a flag:

function show(options?: {
  reason?: SubscriptionDialogReason
  skipFreeTierDialog?: boolean  // for "Upgrade" button in panel
}) {
  if (isFreeTier.value && !options?.skipFreeTierDialog) {
    showFreeTierDialog(options)
    return
  }
  showPricingTable(options)  // private, not exported
}

return { show, hide }  // no showPricingTable leak

Then SubscriptionPanelContentWorkspace.vue line 465 simplifies to show({ skipFreeTierDialog: true }), and all routing logic stays inside the composable.

Comment thread src/platform/cloud/onboarding/CloudLoginView.vue
Comment on lines +11 to +17
const showFreeTierBadge = (() => {
try {
return !localStorage.getItem(HAS_ACCOUNT_KEY)
} catch {
return false
}
})()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider something like:

import { useLocalStorage } from '@vueuse/core'

export function useFreeTierOnboarding(source: 'login' | 'signup') {
  const hasAccount = useLocalStorage(HAS_ACCOUNT_KEY, null)
  const showFreeTierBadge = computed(() => !hasAccount.value)
  // ...
}

Comment thread src/platform/cloud/onboarding/CloudSignupView.vue
Comment thread src/platform/cloud/onboarding/CloudSignupView.vue
Comment thread src/stores/firebaseAuthStore.ts Outdated

@christian-byrne christian-byrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: Free Tier Support

Overall this is a solid PR with clear separation of concerns. The free tier routing, telemetry additions, and pricing table updates are well-structured. A few issues worth addressing below, ranging from dead code to a potential bug in the legacy panel.

Login/auth semantics: Verified — error handling, isInChina gating, and redirect/routing logic are all preserved correctly. The isInChina check now only appears in the email-form branch, which is correct since the restriction only applies to email signup (OAuth was always available to China users even before this PR).

Snake_case usage: Confirmed consistent with existing telemetry conventions — all Mixpanel property names in this codebase use snake_case (is_new_user, user_id, button_id, etc). The new free_tier_badge_shown, SubscriptionDialogReason values, and button_id strings all follow the established pattern correctly.

Telemetry: The new trackLoginOpened follows the exact same dispatch pattern as the existing trackSignupOpened. Implementation is consistent.

Comment thread src/platform/cloud/subscription/constants/tierPricing.ts
} catch {
return false
}
})()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should use useLocalStorage from VueUse instead of raw localStorage.getItem(). The codebase consistently uses useLocalStorage in ~10 other places (apiKeyAuthStore, ComfyActionbar, NodeLibrarySidebarTab, TopMenuSection, etc.).

Current approach has two issues:

  1. Not reactive — if the user logs in during the session (which sets HAS_ACCOUNT_KEY in firebaseAuthStore.ts), showFreeTierBadge remains stale since it's evaluated once at mount via IIFE.
  2. Manual try/catchuseLocalStorage handles this automatically.

Suggested:

import { useLocalStorage } from '@vueuse/core'

const hasAccount = useLocalStorage(HAS_ACCOUNT_KEY, null)
const showFreeTierBadge = computed(() => !hasAccount.value)

This also makes it reactive if the user logs in during the same session.

Comment thread src/platform/telemetry/types.ts Outdated
utm_source?: string
utm_medium?: string
utm_campaign?: string
free_tier_badge_shown?: boolean

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

free_tier_badge_shown was added to AuthMetadata but no trackAuth() call ever passes it. All four trackAuth() calls in firebaseAuthStore.ts only pass method, is_new_user, and user_id. This field is dead code in AuthMetadata — either wire it up in the auth tracking calls or remove it from this interface.

(The same field in AuthPageOpenedMetadata is correctly used by trackSignupOpened and trackLoginOpened.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am adding a commit that re-works the telemetry for this feature.

Basically we want to track:

  • how seeing the badge affects signup
  • free tier -> subscribe

but within free tier -> subscribe there's three flows:

  • ran out of credits -> modal CTA -> subscribe
  • attempted to add credits -> modal CTA -> subscribe
  • was managing plan and clicked upgrade

We'll track those properly now.

import { useTelemetry } from '@/platform/telemetry'
import { HAS_ACCOUNT_KEY } from '@/stores/firebaseAuthStore'

export function useFreeTierOnboarding(source: 'login' | 'signup') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing tests. This new composable contains business logic (badge visibility based on localStorage, form toggling, telemetry tracking) but has no unit tests. Similarly, FreeTierDialogContent.vue is a new component with conditional rendering logic (reason-based title/subtitle switching) and no tests.

At minimum, useFreeTierOnboarding should have tests covering:

  • showFreeTierBadge returns true when HAS_ACCOUNT_KEY is absent
  • showFreeTierBadge returns false when HAS_ACCOUNT_KEY is set
  • switchToEmailForm / switchToSocialLogin toggle showEmailForm and fire telemetry with correct button_id


const { formattedRenewalDate } = useSubscription()

const freeTierCredits = computed(() => remoteConfig.value.free_tier_credits)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: freeTierCredits is computed independently in three places.

  1. Here: computed(() => remoteConfig.value.free_tier_credits)
  2. useFreeTierOnboarding.ts:10: same expression
  3. getTierCredits('free') in tierPricing.ts:69: reads remoteConfig.value.free_tier_credits ?? null

Consider using getTierCredits('free') here (it already exists for this purpose) to avoid the three-way duplication. Or expose it from useSubscription as a shared computed.

Comment thread src/platform/cloud/subscription/components/SubscriptionBenefits.vue
Comment thread src/scripts/app.ts
isInsufficientCredits: true
})
}
useDialogService().showTopUpCreditsDialog({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavioral change worth noting (not a bug): The old code silently swallowed Payment Required errors for users without an active subscription — the if (isActiveSubscription.value) guard meant non-subscribers saw nothing when hitting a payment wall. The new code always calls showTopUpCreditsDialog, which now redirects free-tier and non-subscribed users to the subscription required dialog.

This is better UX since free-tier users who exhaust credits now see a clear upgrade path instead of a silent no-op. Just confirming this behavioral change is intentional.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm running this by Alex, but this is intentional in the new Free Tier context.

@huntcsg
huntcsg marked this pull request as ready for review February 22, 2026 08:41
@huntcsg
huntcsg requested review from a team and pythongosssss as code owners February 22, 2026 08:41
@dosubot dosubot Bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Feb 22, 2026
@dosubot

dosubot Bot commented Feb 22, 2026

Copy link
Copy Markdown

Related Documentation

Checked 0 published document(s) in 1 knowledge base(s). No updates required.

How did I do? Any feedback?  Join Discord

Comment on lines +14 to +21
// users before HAS_ACCOUNT_KEY was introduced)
// Reactive via useLocalStorage so the badge hides if the user signs in
// during the current session.
const hasAccount = useLocalStorage<string | null>(HAS_ACCOUNT_KEY, null)
const previousWorkflow = useLocalStorage<string | null>(
'Comfy.PreviousWorkflow',
null
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have a newUserService for detecting whether this is the first ever session.

@huntcsg
huntcsg force-pushed the free-tier-initial branch 4 times, most recently from 6bb1ff7 to 158e0e4 Compare February 24, 2026 06:37

@christian-byrne christian-byrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

- Restructure login/signup pages: OAuth primary, progressive email disclosure
- Add free tier badge on Google button, dynamic credit count from remote config
- Add FREE subscription tier throughout type system and tier pricing
- Track funnel events: login/signup opened with free_tier_badge_shown,
  email toggle clicks via trackUiButtonClicked
- Disable top-up for free tier users (dialogService, purchaseCredits, popover)
- Show subscription dialog instead of top-up on Payment Required for free tier
- Show Upgrade button instead of Add Credits in user popover for free tier
The only caller (TopUpCreditsDialogContentLegacy) is already gated by
showTopUpCreditsDialog, making the bottom-layer check unreachable.

Also adds a doc comment to HAS_ACCOUNT_KEY explaining its purpose.
- Bump free tier description from text-xs to text-sm to match body text
- Align header spacing (remove extra mt-6 from login)
- Fix signup privacy link to use absolute URL matching login
- Fix nested <p> in <div> — use sibling <p> elements for terms/contact
- Keep badge at text-[10px] as intentional decorative size
Free tier users should use performSubscriptionCheckout (new subscription)
instead of accessBillingPortal (Stripe upgrade), since they don't have
an existing Stripe subscription to modify.
- Remove dead free_tier_badge_shown from AuthMetadata (never sent)
- Remove disabled trackUiButtonClicked calls from useFreeTierOnboarding
- Simplify getTierCredits to single signature (remove unused overloads)
- Add isFreeTier guard to legacy panel Manage Subscription button
- Enrich trackSubscription with current_tier and reason metadata
  to enable free-tier conversion funnel analysis
Replace IIFE with useLocalStorage for HAS_ACCOUNT_KEY and
Comfy.PreviousWorkflow. Badge hides reactively if either key exists,
covering both new deploys (existing users have PreviousWorkflow) and
mid-session sign-ins (onAuthStateChanged sets HAS_ACCOUNT_KEY).

Add unit tests for useFreeTierOnboarding composable.
- Change sign-up link to 'Sign up to try Cloud for free'
- Remove free tier badge and teaser text from login page
- Remove new-user detection (HAS_ACCOUNT_KEY, localStorage checks)
- Make signup page badge/teaser unconditional (always shown)
- Clean up useFreeTierOnboarding composable and telemetry types
New here? Sign up with a Gmail account to get 400 free credits every month.
Only 'Sign up' is a hyperlink, rest is plain text.
…irst login

- Add isFreeTier to BillingState interface and implement in both
  useLegacyBilling and useWorkspaceBilling so it reflects the active
  workspace's tier, not the user's personal tier.
- Update dialogService and SubscriptionPanelContentWorkspace to use
  useBillingContext().isFreeTier instead of useSubscription().isFreeTier.
- Guard FreeTierDialogContent in useSubscriptionDialog to only show in
  personal workspaces (free tier doesn't exist on team workspaces).
- Fix balance race: re-fetch balance after parallel init when free tier
  credits were just lazily granted (balance === 0).
@huntcsg huntcsg added cloud/1.40 needs-backport Fix/change that needs to be cherry-picked to the current feature freeze branch labels Feb 25, 2026
@huntcsg
huntcsg merged commit 8c3738f into main Feb 25, 2026
41 checks passed
@huntcsg
huntcsg deleted the free-tier-initial branch February 25, 2026 04:28
github-actions Bot pushed a commit that referenced this pull request Feb 25, 2026
## Summary

Add frontend support for a Free subscription tier — login/signup page
restructuring, telemetry instrumentation, and tier-aware billing gating.

## Changes

- **What**: 
- Restructure login/signup pages: OAuth buttons promoted as primary
sign-in method, email login available via progressive disclosure
- Add Free tier badge on Google sign-up button with dynamic credit count
from remote config
- Add `FREE` subscription tier to type system (tier pricing, tier rank,
registry types)
  - Add `isFreeTier` computed to `useSubscription()`
- Disable credit top-up for Free tier users (dialogService,
purchaseCredits, popover CTA)
- Show subscription/upgrade dialog instead of top-up dialog when Free
tier user hits out-of-credits
- Add funnel telemetry: `trackLoginOpened`, enrich `trackSignupOpened`
with `free_tier_badge_shown`, track email toggle clicks

## Review Focus

- Tier gating logic: Free tier users should see "Upgrade" instead of
"Add Credits" and never reach the top-up flow
- Telemetry event design for Mixpanel funnel analysis
- Progressive disclosure UX on login/signup pages

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8864-feat-add-Free-subscription-tier-support-3076d73d36508133b84ec5f0a67ccb03)
by [Unito](https://www.unito.io)
@comfy-pr-bot

Copy link
Copy Markdown
Member

@huntcsg Successfully backported to #9190

@github-actions github-actions Bot removed the needs-backport Fix/change that needs to be cherry-picked to the current feature freeze branch label Feb 25, 2026
huntcsg added a commit that referenced this pull request Feb 25, 2026
Backport of #8864 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9190-backport-cloud-1-40-feat-add-Free-subscription-tier-support-3126d73d36508153b889ddc3c5d01f57)
by [Unito](https://www.unito.io)

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
dante01yoon pushed a commit that referenced this pull request Jul 27, 2026
## Summary

Add frontend support for a Free subscription tier — login/signup page
restructuring, telemetry instrumentation, and tier-aware billing gating.

## Changes

- **What**: 
- Restructure login/signup pages: OAuth buttons promoted as primary
sign-in method, email login available via progressive disclosure
- Add Free tier badge on Google sign-up button with dynamic credit count
from remote config
- Add `FREE` subscription tier to type system (tier pricing, tier rank,
registry types)
  - Add `isFreeTier` computed to `useSubscription()`
- Disable credit top-up for Free tier users (dialogService,
purchaseCredits, popover CTA)
- Show subscription/upgrade dialog instead of top-up dialog when Free
tier user hits out-of-credits
- Add funnel telemetry: `trackLoginOpened`, enrich `trackSignupOpened`
with `free_tier_badge_shown`, track email toggle clicks

## Review Focus

- Tier gating logic: Free tier users should see "Upgrade" instead of
"Add Credits" and never reach the top-up flow
- Telemetry event design for Mixpanel funnel analysis
- Progressive disclosure UX on login/signup pages

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8864-feat-add-Free-subscription-tier-support-3076d73d36508133b84ec5f0a67ccb03)
by [Unito](https://www.unito.io)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants