From 3bb8e942470caf72b81a249f828d193636777e0c Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Wed, 14 Jan 2026 10:25:13 -0800 Subject: [PATCH 001/109] Cleanup: Remove i18n CODEOWNERS entries (#8044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary So far has mostly added noise to PRs. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8044-Cleanup-Remove-i18n-CODEOWNERS-entries-2e86d73d365081c7ac05ef7ca8c7f03e) by [Unito](https://www.unito.io) --- CODEOWNERS | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index fcba1e400..2612e3f22 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -37,7 +37,7 @@ /src/components/graph/selectionToolbox/ @Myestery # Minimap -/src/renderer/extensions/minimap/ @jtydhr88 @Myestery +/src/renderer/extensions/minimap/ @jtydhr88 @Myestery # Workflow Templates /src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki @@ -55,8 +55,7 @@ /src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata # Translations -/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs -/src/locales/pt-BR/ @JonatanAtila @Yorha4D @KarryCharon @shinshin86 +/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs # LLM Instructions (blank on purpose) .claude/ From 81c66822f54a68cb6a7b493edc59d5b3247422c1 Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:49:25 +0200 Subject: [PATCH 002/109] [API Nodes] add price badges for Meshy 3D nodes (#7966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Prices were taken from here: https://docs.meshy.ai/en/api/pricing Screenshot From 2026-01-12 19-16-58 Screenshot From 2026-01-12 19-17-18 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7966-API-Nodes-add-price-badges-for-Meshy-3D-nodes-2e66d73d365081b4843bf6a2f816b9e0) by [Unito](https://www.unito.io) --- src/composables/node/useNodePricing.ts | 83 ++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index 643dd676b..6f1184b6a 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -2,6 +2,17 @@ import { formatCreditsFromUsd } from '@/base/credits/comfyCredits' import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph' import type { IComboWidget } from '@/lib/litegraph/src/types/widgets' +/** + * Meshy credit pricing constant. + * 1 Meshy credit = $0.04 USD + * Change this value to update all Meshy node prices. + */ +const MESHY_CREDIT_PRICE_USD = 0.04 + +/** Convert Meshy credits to USD */ +const meshyCreditsToUsd = (credits: number): number => + credits * MESHY_CREDIT_PRICE_USD + const DEFAULT_NUMBER_OPTIONS: Intl.NumberFormatOptions = { minimumFractionDigits: 0, maximumFractionDigits: 0 @@ -525,6 +536,54 @@ const calculateTripo3DGenerationPrice = ( return formatCreditsLabel(dollars) } +/** + * Meshy Image to 3D pricing calculator. + * Pricing based on should_texture widget: + * - Without texture: 20 credits + * - With texture: 30 credits + */ +const calculateMeshyImageToModelPrice = (node: LGraphNode): string => { + const shouldTextureWidget = node.widgets?.find( + (w) => w.name === 'should_texture' + ) as IComboWidget + + if (!shouldTextureWidget) { + return formatCreditsRangeLabel( + meshyCreditsToUsd(20), + meshyCreditsToUsd(30), + { note: '(varies with texture)' } + ) + } + + const shouldTexture = String(shouldTextureWidget.value).toLowerCase() + const credits = shouldTexture === 'true' ? 30 : 20 + return formatCreditsLabel(meshyCreditsToUsd(credits)) +} + +/** + * Meshy Multi-Image to 3D pricing calculator. + * Pricing based on should_texture widget: + * - Without texture: 5 credits + * - With texture: 15 credits + */ +const calculateMeshyMultiImageToModelPrice = (node: LGraphNode): string => { + const shouldTextureWidget = node.widgets?.find( + (w) => w.name === 'should_texture' + ) as IComboWidget + + if (!shouldTextureWidget) { + return formatCreditsRangeLabel( + meshyCreditsToUsd(5), + meshyCreditsToUsd(15), + { note: '(varies with texture)' } + ) + } + + const shouldTexture = String(shouldTextureWidget.value).toLowerCase() + const credits = shouldTexture === 'true' ? 15 : 5 + return formatCreditsLabel(meshyCreditsToUsd(credits)) +} + /** * Static pricing data for API nodes, now supporting both strings and functions */ @@ -1812,6 +1871,27 @@ const apiNodeCosts: Record = TripoRefineNode: { displayPrice: formatCreditsLabel(0.3) }, + MeshyTextToModelNode: { + displayPrice: formatCreditsLabel(meshyCreditsToUsd(20)) + }, + MeshyRefineNode: { + displayPrice: formatCreditsLabel(meshyCreditsToUsd(10)) + }, + MeshyImageToModelNode: { + displayPrice: calculateMeshyImageToModelPrice + }, + MeshyMultiImageToModelNode: { + displayPrice: calculateMeshyMultiImageToModelPrice + }, + MeshyRigModelNode: { + displayPrice: formatCreditsLabel(meshyCreditsToUsd(5)) + }, + MeshyAnimateModelNode: { + displayPrice: formatCreditsLabel(meshyCreditsToUsd(3)) + }, + MeshyTextureNode: { + displayPrice: formatCreditsLabel(meshyCreditsToUsd(10)) + }, // Google/Gemini nodes GeminiNode: { displayPrice: (node: LGraphNode): string => { @@ -2527,6 +2607,9 @@ export const useNodePricing = () => { 'animate_in_place' ], TripoTextureNode: ['texture_quality'], + // Meshy nodes + MeshyImageToModelNode: ['should_texture'], + MeshyMultiImageToModelNode: ['should_texture'], // Google/Gemini nodes GeminiNode: ['model'], GeminiImage2Node: ['resolution'], From 45d95728f385030bed5f4aa1995d1c7d842e215b Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Wed, 14 Jan 2026 15:28:20 -0500 Subject: [PATCH 003/109] fix: preserve image preview when backend returns null output (#8050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When two LoadImage nodes select the same image, the backend's cache mechanism returns null output for the second node (same cache signature). This was overwriting the existing image preview data. Now skip setting outputs when null/undefined to preserve the preview. Root cause chain https://github.com/Comfy-Org/ComfyUI/blob/07f2462eae7fa2daa34971dd1b15fd525686e958/execution.py#L421: 1. When two LoadImage nodes select the same image, they have identical cache signatures (based on IS_CHANGED SHA256 hash + input parameters) 2. First node executes: Actually runs load_image(), caches the result. But LoadImage's ui field is None (it only produces tensors, no UI output) 3. Second node executes: Cache hit, directly returns cached_ui.get("output", None) = null 4. Frontend receives null and overwrites the existing image preview ## Screenshots (if applicable) Before https://github.com/user-attachments/assets/7bd814f6-bf23-42cc-9fc3-fd9fec68b4f6 After https://github.com/user-attachments/assets/b9cc6160-ea70-424e-8a3d-5dc9f244d0d0 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8050-fix-preserve-image-preview-when-backend-returns-null-output-2e86d73d36508156ab32cb12a7f6b307) by [Unito](https://www.unito.io) --- src/stores/imagePreviewStore.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/stores/imagePreviewStore.ts b/src/stores/imagePreviewStore.ts index 346011d45..db2c9abaa 100644 --- a/src/stores/imagePreviewStore.ts +++ b/src/stores/imagePreviewStore.ts @@ -130,6 +130,11 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { outputs: ExecutedWsMessage['output'] | ResultItem, options: SetOutputOptions = {} ) { + // Skip if outputs is null/undefined - preserve existing output + // This can happen when backend returns null for cached/deduplicated nodes + // (e.g., two LoadImage nodes selecting the same image) + if (outputs == null) return + if (options.merge) { const existingOutput = app.nodeOutputs[nodeLocatorId] if (existingOutput && outputs) { From 3d332ff0d79289785a7c675cca43827c6a222195 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Wed, 14 Jan 2026 15:45:46 -0800 Subject: [PATCH 004/109] devex: Silence warning for misused spread in test. (#8055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Silences this warning: ``` ⚠ typescript-eslint(no-misused-spread): Using the spread operator on class instances will lose their class prototype. ╭─[src/composables/maskeditor/useCanvasHistory.test.ts:171:9] 170 │ mockRefs.maskCanvas = { 171 │ ...mockRefs.maskCanvas, · ────────────────────── 172 │ width: 0, ╰──── ``` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8055-devex-Silence-warning-for-misused-spread-in-test-2e86d73d365081659d1fdbc61675a532) by [Unito](https://www.unito.io) --- src/composables/maskeditor/useCanvasHistory.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/composables/maskeditor/useCanvasHistory.test.ts b/src/composables/maskeditor/useCanvasHistory.test.ts index 6281baf39..40985c715 100644 --- a/src/composables/maskeditor/useCanvasHistory.test.ts +++ b/src/composables/maskeditor/useCanvasHistory.test.ts @@ -167,6 +167,7 @@ describe('useCanvasHistory', () => { const rafSpy = vi.spyOn(window, 'requestAnimationFrame') mockRefs.maskCanvas = { + // oxlint-disable-next-line no-misused-spread ...mockRefs.maskCanvas, width: 0, height: 0 From 946429f2ff728c69be88c06877554ffb4f2c1147 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Wed, 14 Jan 2026 16:13:05 -0800 Subject: [PATCH 005/109] fix: use staging platform URL for usage history link (#8056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Uses `getComfyPlatformBaseUrl()` for the usage history link so it correctly points to `stagingplatform.comfy.org` on staging builds instead of being hardcoded to `platform.comfy.org`. ## Changes - Added `usageHistoryUrl` computed that uses `getComfyPlatformBaseUrl()` - Updated template to use dynamic `:href` binding ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8056-fix-use-staging-platform-URL-for-usage-history-link-2e86d73d36508186a845ffd84e5caaf2) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action --- .../cloud/subscription/components/SubscriptionPanel.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/platform/cloud/subscription/components/SubscriptionPanel.vue b/src/platform/cloud/subscription/components/SubscriptionPanel.vue index 9017dee39..7a521b504 100644 --- a/src/platform/cloud/subscription/components/SubscriptionPanel.vue +++ b/src/platform/cloud/subscription/components/SubscriptionPanel.vue @@ -156,7 +156,7 @@
{ const tierPrice = computed(() => getTierPrice(tierKey.value, isYearlySubscription.value) ) +const usageHistoryUrl = computed( + () => `${getComfyPlatformBaseUrl()}/profile/usage` +) const refillsDate = computed(() => { if (!subscriptionStatus.value?.renewal_date) return '' From 84978368114e3f55842ce3c972bbc3e924dda931 Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Thu, 15 Jan 2026 02:14:12 +0200 Subject: [PATCH 006/109] feat(price-badges): add ByteDance SeeDance 1.5 prices (#8046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Added prices for `seedance-1-5-pro` model ## Screenshots (if applicable) Screenshot From 2026-01-14 18-09-18 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8046-feat-price-badges-add-ByteDance-SeeDance-1-5-prices-2e86d73d3650814cb988dc94cfb48993) by [Unito](https://www.unito.io) --- src/composables/node/useNodePricing.ts | 54 ++++++++++++++++++++------ 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index 6f1184b6a..95c432747 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -220,13 +220,24 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => { const resolutionWidget = node.widgets?.find( (w) => w.name === 'resolution' ) as IComboWidget + const generateAudioWidget = node.widgets?.find( + (w) => w.name === 'generate_audio' + ) as IComboWidget | undefined if (!modelWidget || !durationWidget || !resolutionWidget) return 'Token-based' const model = String(modelWidget.value).toLowerCase() const resolution = String(resolutionWidget.value).toLowerCase() const seconds = parseFloat(String(durationWidget.value)) + const generateAudio = + generateAudioWidget && + String(generateAudioWidget.value).toLowerCase() === 'true' const priceByModel: Record> = { + 'seedance-1-5-pro': { + '480p': [0.12, 0.12], + '720p': [0.26, 0.26], + '1080p': [0.58, 0.59] + }, 'seedance-1-0-pro': { '480p': [0.23, 0.24], '720p': [0.51, 0.56], @@ -244,13 +255,15 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => { } } - const modelKey = model.includes('seedance-1-0-pro-fast') - ? 'seedance-1-0-pro-fast' - : model.includes('seedance-1-0-pro') - ? 'seedance-1-0-pro' - : model.includes('seedance-1-0-lite') - ? 'seedance-1-0-lite' - : '' + const modelKey = model.includes('seedance-1-5-pro') + ? 'seedance-1-5-pro' + : model.includes('seedance-1-0-pro-fast') + ? 'seedance-1-0-pro-fast' + : model.includes('seedance-1-0-pro') + ? 'seedance-1-0-pro' + : model.includes('seedance-1-0-lite') + ? 'seedance-1-0-lite' + : '' const resKey = resolution.includes('1080') ? '1080p' @@ -266,8 +279,10 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => { const [min10s, max10s] = baseRange const scale = seconds / 10 - const minCost = min10s * scale - const maxCost = max10s * scale + const audioMultiplier = + modelKey === 'seedance-1-5-pro' && generateAudio ? 2 : 1 + const minCost = min10s * scale * audioMultiplier + const maxCost = max10s * scale * audioMultiplier if (minCost === maxCost) return formatCreditsLabel(minCost) return formatCreditsRangeLabel(minCost, maxCost) @@ -2623,9 +2638,24 @@ export const useNodePricing = () => { 'sequential_image_generation', 'max_images' ], - ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'], - ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'], - ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'], + ByteDanceTextToVideoNode: [ + 'model', + 'duration', + 'resolution', + 'generate_audio' + ], + ByteDanceImageToVideoNode: [ + 'model', + 'duration', + 'resolution', + 'generate_audio' + ], + ByteDanceFirstLastFrameNode: [ + 'model', + 'duration', + 'resolution', + 'generate_audio' + ], ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'], WanTextToVideoApi: ['duration', 'size'], WanImageToVideoApi: ['duration', 'resolution'], From 97b1a48a2505e5b1fe7f7faf74dcd96d8187a5cf Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:51:21 +0000 Subject: [PATCH 007/109] Fix avatar using fallback icon not being square (#8060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When no user avatar image url exists / it fails to load, there is a fallback to a user icon which does not render as a square in the tab bar menu section. ## Changes - set avatar to be square in compact mode ## Screenshots (if applicable) Before: Screen Shot 2026-01-14 at 16 26 28 After: Screen Shot 2026-01-14 at 16 25 57 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8060-Fix-avatar-using-fallback-icon-not-being-square-2e96d73d365081ea802bfbfdafdb1034) by [Unito](https://www.unito.io) --- src/components/topbar/CurrentUserButton.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/topbar/CurrentUserButton.vue b/src/components/topbar/CurrentUserButton.vue index 44233aac5..151dfd405 100644 --- a/src/components/topbar/CurrentUserButton.vue +++ b/src/components/topbar/CurrentUserButton.vue @@ -12,7 +12,7 @@ :class=" cn( 'flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface justify-center', - compact && 'size-full ' + compact && 'size-full aspect-square' ) " > From 3069c24f81b484c4e1033e518dce1e293c03f9ba Mon Sep 17 00:00:00 2001 From: Yourz Date: Thu, 15 Jan 2026 08:57:51 +0800 Subject: [PATCH 008/109] feat: handling subscription tier button link parameter (#7553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Discussion here: https://comfy-organization.slack.com/archives/C0A0XANFJRE/p1764899027465379 Implement: Subscription tier query parameter for direct checkout flow Example button link: `/cloud/subscribe?tier=standard` `tier` could be `standard`, `creator` or `pro` `cycle` could be `monthly` or `yearly`. it is optional, and `monthly` by default. ## Changes - **What**: - Add a landing page called `CloudSubscriptionRedirectView.vue` to handling the subscription tier button link parameter - Extract subscription handling logic from `PriceTable.vue` - **Breaking**: - Code change touched `PriceTable.vue` - **Dependencies**: ## Review Focus - link will redirect to login url, when cloud app not login - after login, the cloud app will redirect to CloudSubscriptionRedirect page - wait for several seconds, the cloud app will be redirected to checkout page ## Screenshots (if applicable) ![Kapture 2025-12-16 at 18 43 28](https://github.com/user-attachments/assets/affbc18f-d45c-4953-b06a-fc797eba6804) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7553-feat-handling-subscription-tier-button-link-parameter-2cb6d73d365081ee9580e89090248300) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action --- lint-staged.config.mjs | 23 +++ .../auth/useFirebaseAuthActions.ts | 10 +- src/locales/en/main.json | 1 + .../cloud/onboarding/CloudLoginView.vue | 14 +- .../cloud/onboarding/CloudSignupView.vue | 16 +- .../CloudSubscriptionRedirectView.test.ts | 162 ++++++++++++++++++ .../CloudSubscriptionRedirectView.vue | 130 ++++++++++++++ .../cloud/onboarding/UserCheckView.vue | 2 +- .../cloud/onboarding/onboardingCloudRoutes.ts | 7 + .../onboarding/utils/previousFullPath.test.ts | 38 ++++ .../onboarding/utils/previousFullPath.ts | 27 +++ .../subscription/components/PricingTable.vue | 66 +------ .../composables/useSubscription.ts | 9 +- .../utils/subscriptionCheckoutUtil.ts | 87 ++++++++++ src/router.ts | 15 +- 15 files changed, 536 insertions(+), 71 deletions(-) create mode 100644 lint-staged.config.mjs create mode 100644 src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts create mode 100644 src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue create mode 100644 src/platform/cloud/onboarding/utils/previousFullPath.test.ts create mode 100644 src/platform/cloud/onboarding/utils/previousFullPath.ts create mode 100644 src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs new file mode 100644 index 000000000..97d22c529 --- /dev/null +++ b/lint-staged.config.mjs @@ -0,0 +1,23 @@ +import path from 'node:path' + +export default { + './**/*.js': (stagedFiles) => formatAndEslint(stagedFiles), + + './**/*.{ts,tsx,vue,mts}': (stagedFiles) => [ + ...formatAndEslint(stagedFiles), + 'pnpm typecheck' + ] +} + +function formatAndEslint(fileNames) { + // Convert absolute paths to relative paths for better ESLint resolution + const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f)) + const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ') + return [ + `pnpm exec prettier --cache --write ${joinedPaths}`, + `pnpm exec oxlint --fix ${joinedPaths}`, + `pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}` + ] +} + + diff --git a/src/composables/auth/useFirebaseAuthActions.ts b/src/composables/auth/useFirebaseAuthActions.ts index da6744c2d..319dfa851 100644 --- a/src/composables/auth/useFirebaseAuthActions.ts +++ b/src/composables/auth/useFirebaseAuthActions.ts @@ -104,9 +104,9 @@ export const useFirebaseAuthActions = () => { }, reportError) const accessBillingPortal = wrapWithErrorHandlingAsync< - [targetTier?: BillingPortalTargetTier], + [targetTier?: BillingPortalTargetTier, openInNewTab?: boolean], void - >(async (targetTier) => { + >(async (targetTier, openInNewTab = true) => { const response = await authStore.accessBillingPortal(targetTier) if (!response.billing_portal_url) { throw new Error( @@ -115,7 +115,11 @@ export const useFirebaseAuthActions = () => { }) ) } - window.open(response.billing_portal_url, '_blank') + if (openInNewTab) { + window.open(response.billing_portal_url, '_blank') + } else { + globalThis.location.href = response.billing_portal_url + } }, reportError) const fetchBalance = wrapWithErrorHandlingAsync(async () => { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 555ff07fb..75c4bb3fb 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2166,6 +2166,7 @@ "renderErrorState": "Render Error State" }, "cloudOnboarding": { + "skipToCloudApp": "Skip to the cloud app", "survey": { "title": "Cloud Survey", "placeholder": "Survey questions placeholder", diff --git a/src/platform/cloud/onboarding/CloudLoginView.vue b/src/platform/cloud/onboarding/CloudLoginView.vue index b7d1cd131..a4fea37a4 100644 --- a/src/platform/cloud/onboarding/CloudLoginView.vue +++ b/src/platform/cloud/onboarding/CloudLoginView.vue @@ -84,6 +84,7 @@ import { useRoute, useRouter } from 'vue-router' import Button from '@/components/ui/button/Button.vue' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue' +import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath' import { useToastStore } from '@/platform/updates/common/toastStore' import type { SignInData } from '@/schemas/signInSchema' @@ -91,12 +92,12 @@ const { t } = useI18n() const router = useRouter() const route = useRoute() const authActions = useFirebaseAuthActions() -const isSecureContext = window.isSecureContext +const isSecureContext = globalThis.isSecureContext const authError = ref('') const toastStore = useToastStore() -const navigateToSignup = () => { - void router.push({ name: 'cloud-signup', query: route.query }) +const navigateToSignup = async () => { + await router.push({ name: 'cloud-signup', query: route.query }) } const onSuccess = async () => { @@ -105,6 +106,13 @@ const onSuccess = async () => { summary: 'Login Completed', life: 2000 }) + + const previousFullPath = getSafePreviousFullPath(route.query) + if (previousFullPath) { + await router.replace(previousFullPath) + return + } + await router.push({ name: 'cloud-user-check' }) } diff --git a/src/platform/cloud/onboarding/CloudSignupView.vue b/src/platform/cloud/onboarding/CloudSignupView.vue index 5a8bf5c3e..756851ec7 100644 --- a/src/platform/cloud/onboarding/CloudSignupView.vue +++ b/src/platform/cloud/onboarding/CloudSignupView.vue @@ -100,6 +100,7 @@ import { useRoute, useRouter } from 'vue-router' import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue' import Button from '@/components/ui/button/Button.vue' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' +import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath' import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' import { useToastStore } from '@/platform/updates/common/toastStore' @@ -110,13 +111,13 @@ const { t } = useI18n() const router = useRouter() const route = useRoute() const authActions = useFirebaseAuthActions() -const isSecureContext = window.isSecureContext +const isSecureContext = globalThis.isSecureContext const authError = ref('') const userIsInChina = ref(false) const toastStore = useToastStore() -const navigateToLogin = () => { - void router.push({ name: 'cloud-login', query: route.query }) +const navigateToLogin = async () => { + await router.push({ name: 'cloud-login', query: route.query }) } const onSuccess = async () => { @@ -125,7 +126,14 @@ const onSuccess = async () => { summary: 'Sign up Completed', life: 2000 }) - // Direct redirect to main app - email verification removed + + const previousFullPath = getSafePreviousFullPath(route.query) + if (previousFullPath) { + await router.replace(previousFullPath) + return + } + + // Default redirect to the normal onboarding flow await router.push({ path: '/', query: route.query }) } diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts new file mode 100644 index 000000000..68fec345d --- /dev/null +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts @@ -0,0 +1,162 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import CloudSubscriptionRedirectView from './CloudSubscriptionRedirectView.vue' + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) + +// Router mocks +let mockQuery: Record = {} +const mockRouterPush = vi.fn() + +vi.mock('vue-router', () => ({ + useRoute: () => ({ + query: mockQuery + }), + useRouter: () => ({ + push: mockRouterPush + }) +})) + +// Firebase / subscription mocks +const authActionMocks = vi.hoisted(() => ({ + reportError: vi.fn(), + accessBillingPortal: vi.fn() +})) + +vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({ + useFirebaseAuthActions: () => authActionMocks +})) + +vi.mock('@/composables/useErrorHandling', () => ({ + useErrorHandling: () => ({ + wrapWithErrorHandlingAsync: + unknown>(fn: T) => + (...args: Parameters) => + fn(...args) + }) +})) + +const subscriptionMocks = vi.hoisted(() => ({ + isActiveSubscription: { value: false }, + isInitialized: { value: true } +})) + +vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ + useSubscription: () => subscriptionMocks +})) + +// Avoid real network / isCloud behavior +const mockPerformSubscriptionCheckout = vi.fn() +vi.mock('@/platform/cloud/subscription/utils/subscriptionCheckoutUtil', () => ({ + performSubscriptionCheckout: (...args: unknown[]) => + mockPerformSubscriptionCheckout(...args) +})) + +const createI18nInstance = () => + createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + cloudOnboarding: { + skipToCloudApp: 'Skip to the cloud app' + }, + g: { + comfyOrgLogoAlt: 'Comfy org logo' + }, + subscription: { + subscribeTo: 'Subscribe to {plan}', + tiers: { + standard: { name: 'Standard' }, + creator: { name: 'Creator' }, + pro: { name: 'Pro' } + } + } + } + } + }) + +const mountView = async (query: Record) => { + mockQuery = query + + const wrapper = mount(CloudSubscriptionRedirectView, { + global: { + plugins: [createI18nInstance()] + } + }) + + await flushPromises() + + return { wrapper } +} + +describe('CloudSubscriptionRedirectView', () => { + beforeEach(() => { + vi.clearAllMocks() + mockQuery = {} + subscriptionMocks.isActiveSubscription.value = false + subscriptionMocks.isInitialized.value = true + }) + + test('redirects to home when subscriptionType is missing', async () => { + await mountView({}) + + expect(mockRouterPush).toHaveBeenCalledWith('/') + }) + + test('redirects to home when subscriptionType is invalid', async () => { + await mountView({ tier: 'invalid' }) + + expect(mockRouterPush).toHaveBeenCalledWith('/') + }) + + test('shows subscription copy when subscriptionType is valid', async () => { + const { wrapper } = await mountView({ tier: 'creator' }) + + // Should not redirect to home + expect(mockRouterPush).not.toHaveBeenCalledWith('/') + + // Shows copy under logo + expect(wrapper.text()).toContain('Subscribe to Creator') + + // Triggers checkout flow + expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith( + 'creator', + 'monthly', + false + ) + + // Shows loading affordances + expect(wrapper.findComponent({ name: 'ProgressSpinner' }).exists()).toBe( + true + ) + const skipLink = wrapper.get('a[href="/"]') + expect(skipLink.text()).toContain('Skip to the cloud app') + }) + + test('opens billing portal when subscription is already active', async () => { + subscriptionMocks.isActiveSubscription.value = true + + await mountView({ tier: 'creator' }) + + expect(mockRouterPush).not.toHaveBeenCalledWith('/') + expect(authActionMocks.accessBillingPortal).toHaveBeenCalledTimes(1) + expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled() + }) + + test('uses first value when subscriptionType is an array', async () => { + const { wrapper } = await mountView({ + tier: ['creator', 'pro'] + }) + + expect(mockRouterPush).not.toHaveBeenCalledWith('/') + expect(wrapper.text()).toContain('Subscribe to Creator') + expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith( + 'creator', + 'monthly', + false + ) + }) +}) diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue new file mode 100644 index 000000000..d4da851ad --- /dev/null +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue @@ -0,0 +1,130 @@ + + + diff --git a/src/platform/cloud/onboarding/UserCheckView.vue b/src/platform/cloud/onboarding/UserCheckView.vue index e2186ff48..e18596f29 100644 --- a/src/platform/cloud/onboarding/UserCheckView.vue +++ b/src/platform/cloud/onboarding/UserCheckView.vue @@ -78,7 +78,7 @@ const { } // User is fully onboarded (active or whitelist check disabled) - window.location.href = '/' + globalThis.location.href = '/' }), null, { resetOnExecute: false } diff --git a/src/platform/cloud/onboarding/onboardingCloudRoutes.ts b/src/platform/cloud/onboarding/onboardingCloudRoutes.ts index 1a613c02e..52ffc7943 100644 --- a/src/platform/cloud/onboarding/onboardingCloudRoutes.ts +++ b/src/platform/cloud/onboarding/onboardingCloudRoutes.ts @@ -65,6 +65,13 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [ component: () => import('@/platform/cloud/onboarding/CloudAuthTimeoutView.vue'), props: true + }, + { + path: 'subscribe', + name: 'cloud-subscribe', + component: () => + import('@/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue'), + meta: { requiresAuth: true } } ] } diff --git a/src/platform/cloud/onboarding/utils/previousFullPath.test.ts b/src/platform/cloud/onboarding/utils/previousFullPath.test.ts new file mode 100644 index 000000000..d74c1e609 --- /dev/null +++ b/src/platform/cloud/onboarding/utils/previousFullPath.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'vitest' +import type { LocationQuery } from 'vue-router' + +import { getSafePreviousFullPath } from './previousFullPath' + +describe('getSafePreviousFullPath', () => { + test('returns null when missing', () => { + expect(getSafePreviousFullPath({})).toBeNull() + }) + + test('decodes and returns internal relative paths', () => { + const query: LocationQuery = { + previousFullPath: encodeURIComponent('/some/path?x=1') + } + expect(getSafePreviousFullPath(query)).toBe('/some/path?x=1') + }) + + test('rejects protocol-relative urls', () => { + const query: LocationQuery = { + previousFullPath: encodeURIComponent('//evil.com') + } + expect(getSafePreviousFullPath(query)).toBeNull() + }) + + test('rejects absolute external urls', () => { + const query: LocationQuery = { + previousFullPath: encodeURIComponent('https://evil.com/path') + } + expect(getSafePreviousFullPath(query)).toBeNull() + }) + + test('rejects malformed encodings', () => { + const query: LocationQuery = { + previousFullPath: '%E0%A4%A' + } + expect(getSafePreviousFullPath(query)).toBeNull() + }) +}) diff --git a/src/platform/cloud/onboarding/utils/previousFullPath.ts b/src/platform/cloud/onboarding/utils/previousFullPath.ts new file mode 100644 index 000000000..9f6b257e0 --- /dev/null +++ b/src/platform/cloud/onboarding/utils/previousFullPath.ts @@ -0,0 +1,27 @@ +import type { LocationQuery } from 'vue-router' + +const decodeQueryParam = (value: string): string | null => { + try { + return decodeURIComponent(value) + } catch { + return null + } +} + +const isSafeInternalRedirectPath = (path: string): boolean => { + // Must be a relative in-app path. Disallow protocol-relative URLs ("//evil.com"). + return path.startsWith('/') && !path.startsWith('//') +} + +export const getSafePreviousFullPath = ( + query: LocationQuery +): string | null => { + const raw = query.previousFullPath + const value = Array.isArray(raw) ? raw[0] : raw + if (!value) return null + + const decoded = decodeQueryParam(value) + if (!decoded) return null + + return isSafeInternalRedirectPath(decoded) ? decoded : null +} diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index 7f1a66834..1520a94bf 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -252,7 +252,6 @@ import { useI18n } from 'vue-i18n' import Button from '@/components/ui/button/Button.vue' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { useErrorHandling } from '@/composables/useErrorHandling' -import { getComfyApiBaseUrl } from '@/config/comfyApi' import { t } from '@/i18n' import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' import { @@ -263,13 +262,10 @@ import type { TierKey, TierPricing } from '@/platform/cloud/subscription/constants/tierPricing' +import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil' import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank' import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank' import { isCloud } from '@/platform/distribution/types' -import { - FirebaseAuthStoreError, - useFirebaseAuthStore -} from '@/stores/firebaseAuthStore' import type { components } from '@/types/comfyRegistryTypes' type SubscriptionTier = components['schemas']['SubscriptionTier'] @@ -332,7 +328,6 @@ const tiers: PricingTierConfig[] = [ ] const { n } = useI18n() -const { getAuthHeader } = useFirebaseAuthStore() const { isActiveSubscription, subscriptionTier, isYearlySubscription } = useSubscription() const { accessBillingPortal, reportError } = useFirebaseAuthActions() @@ -384,12 +379,13 @@ const getButtonLabel = (tier: PricingTierConfig): string => { : t('subscription.subscribeTo', { plan: planName }) } -const getButtonSeverity = (tier: PricingTierConfig): 'primary' | 'secondary' => - isCurrentPlan(tier.key) - ? 'secondary' - : tier.key === 'creator' - ? 'primary' - : 'secondary' +const getButtonSeverity = ( + tier: PricingTierConfig +): 'primary' | 'secondary' => { + if (isCurrentPlan(tier.key)) return 'secondary' + if (tier.key === 'creator') return 'primary' + return 'secondary' +} const getButtonTextClass = (tier: PricingTierConfig): string => tier.key === 'creator' @@ -405,47 +401,6 @@ const getAnnualTotal = (tier: PricingTierConfig): number => const getCreditsDisplay = (tier: PricingTierConfig): number => tier.pricing.credits * (currentBillingCycle.value === 'yearly' ? 12 : 1) -const initiateCheckout = async (tierKey: CheckoutTierKey) => { - const authHeader = await getAuthHeader() - if (!authHeader) { - throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated')) - } - - const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value) - const response = await fetch( - `${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`, - { - method: 'POST', - headers: { ...authHeader, 'Content-Type': 'application/json' } - } - ) - - if (!response.ok) { - let errorMessage = 'Failed to initiate checkout' - try { - const errorData = await response.json() - errorMessage = errorData.message || errorMessage - } catch { - // If JSON parsing fails, try to get text response or use HTTP status - try { - const errorText = await response.text() - errorMessage = - errorText || `HTTP ${response.status} ${response.statusText}` - } catch { - errorMessage = `HTTP ${response.status} ${response.statusText}` - } - } - - throw new FirebaseAuthStoreError( - t('toastMessages.failedToInitiateSubscription', { - error: errorMessage - }) - ) - } - - return await response.json() -} - const handleSubscribe = wrapWithErrorHandlingAsync( async (tierKey: CheckoutTierKey) => { if (!isCloud || isLoading.value || isCurrentPlan(tierKey)) return @@ -475,10 +430,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync( await accessBillingPortal(checkoutTier) } } else { - const response = await initiateCheckout(tierKey) - if (response.checkout_url) { - window.open(response.checkout_url, '_blank') - } + await performSubscriptionCheckout(tierKey, currentBillingCycle.value) } } finally { isLoading.value = false diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index bb5a3b6c0..e80c3642e 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -28,6 +28,7 @@ export type CloudSubscriptionStatusResponse = NonNullable< function useSubscriptionInternal() { const subscriptionStatus = ref(null) const telemetry = useTelemetry() + const isInitialized = ref(false) const isSubscribedOrIsNotCloud = computed(() => { if (!isCloud || !window.__CONFIG__?.subscription_required) return true @@ -200,10 +201,15 @@ function useSubscriptionInternal() { () => isLoggedIn.value, async (loggedIn) => { if (loggedIn) { - await fetchSubscriptionStatus() + try { + await fetchSubscriptionStatus() + } finally { + isInitialized.value = true + } } else { subscriptionStatus.value = null stopCancellationWatcher() + isInitialized.value = true } }, { immediate: true } @@ -244,6 +250,7 @@ function useSubscriptionInternal() { return { // State isActiveSubscription: isSubscribedOrIsNotCloud, + isInitialized, isCancelled, formattedRenewalDate, formattedEndDate, diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts new file mode 100644 index 000000000..bc77e68e9 --- /dev/null +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -0,0 +1,87 @@ +import { getComfyApiBaseUrl } from '@/config/comfyApi' +import { t } from '@/i18n' +import { isCloud } from '@/platform/distribution/types' +import { + FirebaseAuthStoreError, + useFirebaseAuthStore +} from '@/stores/firebaseAuthStore' +import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' +import type { BillingCycle } from './subscriptionTierRank' + +type CheckoutTier = TierKey | `${TierKey}-yearly` + +const getCheckoutTier = ( + tierKey: TierKey, + billingCycle: BillingCycle +): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey) + +/** + * Core subscription checkout logic shared between PricingTable and + * SubscriptionRedirectView. Handles: + * - Ensuring the user is authenticated + * - Calling the backend checkout endpoint + * - Normalizing error responses + * - Opening the checkout URL in a new tab when available + * + * Callers are responsible for: + * - Guarding on cloud-only behavior (isCloud) + * - Managing loading state + * - Wrapping with error handling (e.g. useErrorHandling) + */ +export async function performSubscriptionCheckout( + tierKey: TierKey, + currentBillingCycle: BillingCycle, + openInNewTab: boolean = true +): Promise { + if (!isCloud) return + + const { getAuthHeader } = useFirebaseAuthStore() + const authHeader = await getAuthHeader() + + if (!authHeader) { + throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated')) + } + + const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle) + + const response = await fetch( + `${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`, + { + method: 'POST', + headers: { ...authHeader, 'Content-Type': 'application/json' } + } + ) + + if (!response.ok) { + let errorMessage = 'Failed to initiate checkout' + try { + const errorData = await response.json() + errorMessage = errorData.message || errorMessage + } catch { + // If JSON parsing fails, try to get text response or use HTTP status + try { + const errorText = await response.text() + errorMessage = + errorText || `HTTP ${response.status} ${response.statusText}` + } catch { + errorMessage = `HTTP ${response.status} ${response.statusText}` + } + } + + throw new FirebaseAuthStoreError( + t('toastMessages.failedToInitiateSubscription', { + error: errorMessage + }) + ) + } + + const data = await response.json() + + if (data.checkout_url) { + if (openInNewTab) { + window.open(data.checkout_url, '_blank') + } else { + globalThis.location.href = data.checkout_url + } + } +} diff --git a/src/router.ts b/src/router.ts index 5ca66c953..102ec8f2a 100644 --- a/src/router.ts +++ b/src/router.ts @@ -149,9 +149,17 @@ if (isCloud) { return next() } + const query = + to.fullPath === '/' + ? undefined + : { previousFullPath: encodeURIComponent(to.fullPath) } + // Check if route requires authentication if (to.meta.requiresAuth && !isLoggedIn) { - return next({ name: 'cloud-login' }) + return next({ + name: 'cloud-login', + query + }) } // Handle other protected routes @@ -164,7 +172,10 @@ if (isCloud) { } // For web, redirect to login - return next({ name: 'cloud-login' }) + return next({ + name: 'cloud-login', + query + }) } // User is logged in - check if they need onboarding (when enabled) From 538f007f1d7e157cf4745c1932fac9649d1fc9d0 Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Thu, 15 Jan 2026 02:21:38 +0100 Subject: [PATCH 009/109] Road to No Explicit Any Part 5: load3d Module (#8064) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Removes all `any` types from the load3d module - Uses generics for EventCallback to provide type-safe event handling - Types node parameters with LGraphNode - Types onExecuted callback with NodeExecutionOutput - Types Camera Config property casts with CameraConfig interface ## Changes - `interfaces.ts`: EventCallback generic, EventManagerInterface generic methods - `EventManager.ts`: Generic emitEvent/addEventListener/removeEventListener - `AnimationManager.ts`: setupModelAnimations originalModel typed as GLTF union - `Load3d.ts`: Event listener methods use EventCallback - `load3d.ts`: Node params typed as LGraphNode, onExecuted uses NodeExecutionOutput, CameraConfig casts ## Test plan - [x] `pnpm typecheck` passes - [x] `pnpm lint` passes - [x] `pnpm test:unit` passes ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8064-Road-to-No-Explicit-Any-Part-5-load3d-Module-2e96d73d365081efbc01f2d8a4f3c11f) by [Unito](https://www.unito.io) --- src/composables/useLoad3d.ts | 3 +- src/extensions/core/load3d.ts | 47 ++++++++++++------- .../core/load3d/AnimationManager.ts | 14 ++++-- src/extensions/core/load3d/EventManager.ts | 10 ++-- src/extensions/core/load3d/Load3d.ts | 5 +- src/extensions/core/load3d/interfaces.ts | 15 +++--- 6 files changed, 59 insertions(+), 35 deletions(-) diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index ca6871e15..c8312051a 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -9,6 +9,7 @@ import type { CameraConfig, CameraState, CameraType, + EventCallback, LightConfig, MaterialMode, ModelConfig, @@ -564,7 +565,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { const handleEvents = (action: 'add' | 'remove') => { Object.entries(eventConfig).forEach(([event, handler]) => { const method = `${action}EventListener` as const - load3d?.[method](event, handler) + load3d?.[method](event, handler as EventCallback) }) } diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts index 55358d62c..668a1fd15 100644 --- a/src/extensions/core/load3d.ts +++ b/src/extensions/core/load3d.ts @@ -4,6 +4,10 @@ import Load3D from '@/components/load3d/Load3D.vue' import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue' import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d' import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper' +import type { + CameraConfig, + CameraState +} from '@/extensions/core/load3d/interfaces' import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import { t } from '@/i18n' @@ -11,7 +15,8 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces' import type { IStringWidget } from '@/lib/litegraph/src/types/widgets' import { useToastStore } from '@/platform/updates/common/toastStore' -import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import type { NodeExecutionOutput } from '@/schemas/apiSchema' +import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import { api } from '@/scripts/api' import { ComfyApp, app } from '@/scripts/app' import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget' @@ -32,12 +37,12 @@ const inputSpecPreview3D: CustomInputSpec = { isPreview: true } -async function handleModelUpload(files: FileList, node: any) { +async function handleModelUpload(files: FileList, node: LGraphNode) { if (!files?.length) return - const modelWidget = node.widgets?.find( - (w: any) => w.name === 'model_file' - ) as IStringWidget + const modelWidget = node.widgets?.find((w) => w.name === 'model_file') as + | IStringWidget + | undefined try { const resourceFolder = (node.properties['Resource Folder'] as string) || '' @@ -81,7 +86,7 @@ async function handleModelUpload(files: FileList, node: any) { } } -async function handleResourcesUpload(files: FileList, node: any) { +async function handleResourcesUpload(files: FileList, node: LGraphNode) { if (!files?.length) return try { @@ -330,7 +335,9 @@ useExtensionService().registerExtension({ await nextTick() useLoad3d(node).waitForLoad3d((load3d) => { - const cameraConfig = node.properties['Camera Config'] as any + const cameraConfig = node.properties['Camera Config'] as + | CameraConfig + | undefined const cameraState = cameraConfig?.state const config = new Load3DConfiguration(load3d, node.properties) @@ -357,7 +364,9 @@ useExtensionService().registerExtension({ return null } - const cameraConfig = (node.properties['Camera Config'] as any) || { + const cameraConfig: CameraConfig = (node.properties[ + 'Camera Config' + ] as CameraConfig | undefined) || { cameraType: currentLoad3d.getCurrentCameraType(), fov: currentLoad3d.cameraManager.perspectiveCamera.fov } @@ -388,7 +397,8 @@ useExtensionService().registerExtension({ mask: `threed/${dataMask.name} [temp]`, normal: `threed/${dataNormal.name} [temp]`, camera_info: - (node.properties['Camera Config'] as any)?.state || null, + (node.properties['Camera Config'] as CameraConfig | undefined) + ?.state || null, recording: '' } @@ -472,7 +482,9 @@ useExtensionService().registerExtension({ if (lastTimeModelFile) { modelWidget.value = lastTimeModelFile - const cameraConfig = node.properties['Camera Config'] as any + const cameraConfig = node.properties['Camera Config'] as + | CameraConfig + | undefined const cameraState = cameraConfig?.state const settings = { @@ -484,10 +496,13 @@ useExtensionService().registerExtension({ config.configure(settings) } - node.onExecuted = function (message: any) { - onExecuted?.apply(this, arguments as any) + node.onExecuted = function (output: NodeExecutionOutput) { + onExecuted?.call(this, output) - let filePath = message.result[0] + const result = (output as Record).result as + | unknown[] + | undefined + const filePath = result?.[0] as string | undefined if (!filePath) { const msg = t('toastMessages.unableToGetModelFilePath') @@ -495,10 +510,10 @@ useExtensionService().registerExtension({ useToastStore().addAlert(msg) } - let cameraState = message.result[1] - let bgImagePath = message.result[2] + const cameraState = result?.[1] as CameraState | undefined + const bgImagePath = result?.[2] as string | undefined - modelWidget.value = filePath.replaceAll('\\', '/') + modelWidget.value = filePath?.replaceAll('\\', '/') node.properties['Last Time Model File'] = modelWidget.value diff --git a/src/extensions/core/load3d/AnimationManager.ts b/src/extensions/core/load3d/AnimationManager.ts index 80fc6f153..c6dc3428b 100644 --- a/src/extensions/core/load3d/AnimationManager.ts +++ b/src/extensions/core/load3d/AnimationManager.ts @@ -1,9 +1,10 @@ import * as THREE from 'three' +import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader' -import { - type AnimationItem, - type AnimationManagerInterface, - type EventManagerInterface +import type { + AnimationItem, + AnimationManagerInterface, + EventManagerInterface } from '@/extensions/core/load3d/interfaces' export class AnimationManager implements AnimationManagerInterface { @@ -38,7 +39,10 @@ export class AnimationManager implements AnimationManagerInterface { this.eventManager.emitEvent('animationListChange', []) } - setupModelAnimations(model: THREE.Object3D, originalModel: any): void { + setupModelAnimations( + model: THREE.Object3D, + originalModel: THREE.Object3D | THREE.BufferGeometry | GLTF | null + ): void { if (this.currentAnimation) { this.currentAnimation.stopAllAction() this.animationActions = [] diff --git a/src/extensions/core/load3d/EventManager.ts b/src/extensions/core/load3d/EventManager.ts index 64b87eb9b..3a0a747e9 100644 --- a/src/extensions/core/load3d/EventManager.ts +++ b/src/extensions/core/load3d/EventManager.ts @@ -1,16 +1,16 @@ import { type EventCallback, type EventManagerInterface } from './interfaces' export class EventManager implements EventManagerInterface { - private listeners: { [key: string]: EventCallback[] } = {} + private listeners: Record = {} - addEventListener(event: string, callback: EventCallback): void { + addEventListener(event: string, callback: EventCallback): void { if (!this.listeners[event]) { this.listeners[event] = [] } - this.listeners[event].push(callback) + this.listeners[event].push(callback as EventCallback) } - removeEventListener(event: string, callback: EventCallback): void { + removeEventListener(event: string, callback: EventCallback): void { if (this.listeners[event]) { this.listeners[event] = this.listeners[event].filter( (cb) => cb !== callback @@ -18,7 +18,7 @@ export class EventManager implements EventManagerInterface { } } - emitEvent(event: string, data?: any): void { + emitEvent(event: string, data: T): void { if (this.listeners[event]) { this.listeners[event].forEach((callback) => callback(data)) } diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 91173346b..657a1796b 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -14,6 +14,7 @@ import { ViewHelperManager } from './ViewHelperManager' import { type CameraState, type CaptureResult, + type EventCallback, type Load3DOptions, type MaterialMode, type UpDirection @@ -610,11 +611,11 @@ class Load3d { this.forceRender() } - addEventListener(event: string, callback: (data?: any) => void): void { + addEventListener(event: string, callback: EventCallback): void { this.eventManager.addEventListener(event, callback) } - removeEventListener(event: string, callback: (data?: any) => void): void { + removeEventListener(event: string, callback: EventCallback): void { this.eventManager.removeEventListener(event, callback) } diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts index d368d8d3c..7beb3882a 100644 --- a/src/extensions/core/load3d/interfaces.ts +++ b/src/extensions/core/load3d/interfaces.ts @@ -47,8 +47,8 @@ export interface LightConfig { intensity: number } -export interface EventCallback { - (data?: any): void +export interface EventCallback { + (data: T): void } export interface Load3DOptions { @@ -128,9 +128,9 @@ export interface ViewHelperManagerInterface extends BaseManager { } export interface EventManagerInterface { - addEventListener(event: string, callback: EventCallback): void - removeEventListener(event: string, callback: EventCallback): void - emitEvent(event: string, data?: any): void + addEventListener(event: string, callback: EventCallback): void + removeEventListener(event: string, callback: EventCallback): void + emitEvent(event: string, data: T): void } export interface AnimationManagerInterface extends BaseManager { @@ -141,7 +141,10 @@ export interface AnimationManagerInterface extends BaseManager { isAnimationPlaying: boolean animationSpeed: number - setupModelAnimations(model: THREE.Object3D, originalModel: any): void + setupModelAnimations( + model: THREE.Object3D, + originalModel: THREE.Object3D | THREE.BufferGeometry | GLTF | null + ): void updateAnimationList(): void setAnimationSpeed(speed: number): void updateSelectedAnimation(index: number): void From 29f727a94601bdb780bdb1177c1160f7db5e2270 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Thu, 15 Jan 2026 11:43:33 +0900 Subject: [PATCH 010/109] [bugfix] Fix bulk action context menu not emitting events (#8065) --- src/components/sidebar/tabs/AssetsSidebarTab.vue | 5 ++++- src/platform/assets/components/MediaAssetContextMenu.vue | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index 8841d5553..015badfed 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -486,7 +486,10 @@ function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) { } function handleContextMenuHide() { - contextMenuAsset.value = null + // Delay clearing to allow command callbacks to emit before component unmounts + requestAnimationFrame(() => { + contextMenuAsset.value = null + }) } const handleZoomClick = (asset: AssetItem) => { diff --git a/src/platform/assets/components/MediaAssetContextMenu.vue b/src/platform/assets/components/MediaAssetContextMenu.vue index 0b54ce33b..57c99cf52 100644 --- a/src/platform/assets/components/MediaAssetContextMenu.vue +++ b/src/platform/assets/components/MediaAssetContextMenu.vue @@ -63,10 +63,10 @@ const { const emit = defineEmits<{ zoom: [] + hide: [] 'asset-deleted': [] 'bulk-download': [assets: AssetItem[]] 'bulk-delete': [assets: AssetItem[]] - hide: [] 'bulk-add-to-workflow': [assets: AssetItem[]] 'bulk-open-workflow': [assets: AssetItem[]] 'bulk-export-workflow': [assets: AssetItem[]] From ed3c855eb669b8c435bb7fc133d673525af2dd10 Mon Sep 17 00:00:00 2001 From: Andray <33491867+light-and-ray@users.noreply.github.com> Date: Thu, 15 Jan 2026 07:55:09 +0400 Subject: [PATCH 011/109] fix: add missing pwa icon in manifest.json (#8071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Icon is required for PWA in most browsers. In the best case it hides the install button inside "save and share" menu, and uses the favicon of low quality or the first letter of the title for icon. In the worst case the app is not installable If you don't see the change in the browser - clear browser's cache. I had an issue with it ## Changes - **What**: added "icons" into "manifest.json" ## Screenshots Screenshot_20260115_065548 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8071-fix-add-missing-pwa-icon-in-manifest-json-2e96d73d3650818f9f10e79e4cc8cd5d) by [Unito](https://www.unito.io) --- manifest.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/manifest.json b/manifest.json index d906b2e23..ad419b437 100644 --- a/manifest.json +++ b/manifest.json @@ -3,6 +3,13 @@ "short_name": "ComfyUI", "description": "ComfyUI: AI image generation platform", "start_url": "/", + "icons": [ + { + "src": "/assets/images/comfy-logo-single.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ], "display": "standalone", "background_color": "#ffffff", "theme_color": "#000000" From efc242b96876f8bb6674e5828aa0e2b5226c565c Mon Sep 17 00:00:00 2001 From: AustinMroz Date: Wed, 14 Jan 2026 20:49:13 -0800 Subject: [PATCH 012/109] Linear mode bug fixes (#8054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8054-Linear-mode-bug-fixes-2e86d73d365081ed8d75d6e2af679f6c) by [Unito](https://www.unito.io) --- src/components/topbar/TopMenuHelpButton.vue | 2 +- src/composables/useCoreCommands.ts | 2 +- src/locales/en/commands.json | 2 +- src/locales/en/main.json | 4 +- .../extensions/linearMode/LinearControls.vue | 23 ++++++++-- .../extensions/linearMode/LinearPreview.vue | 4 +- .../extensions/linearMode/OutputHistory.vue | 1 + .../extensions/linearMode/Preview3d.vue | 44 +++++++++++++++++++ src/views/LinearView.vue | 11 +++-- 9 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 src/renderer/extensions/linearMode/Preview3d.vue diff --git a/src/components/topbar/TopMenuHelpButton.vue b/src/components/topbar/TopMenuHelpButton.vue index afdca83e5..b7f1fe5a8 100644 --- a/src/components/topbar/TopMenuHelpButton.vue +++ b/src/components/topbar/TopMenuHelpButton.vue @@ -4,7 +4,7 @@ variant="textonly" @click="toggleHelpCenter" > - {{ $t('menu.helpAndFeedback') }} +
{{ $t('menu.helpAndFeedback') }}
{ const newMode = !canvasStore.linearMode app.rootGraph.extra.linearMode = newMode diff --git a/src/locales/en/commands.json b/src/locales/en/commands.json index fb5f9957d..60f906db0 100644 --- a/src/locales/en/commands.json +++ b/src/locales/en/commands.json @@ -276,7 +276,7 @@ "label": "Help Center" }, "Comfy_ToggleLinear": { - "label": "toggle linear mode" + "label": "Toggle Simple Mode" }, "Comfy_ToggleQPOV2": { "label": "Toggle Queue Panel V2" diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 75c4bb3fb..295b46c46 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1187,7 +1187,7 @@ "Experimental: Enable AssetAPI": "Experimental: Enable AssetAPI", "Canvas Performance": "Canvas Performance", "Help Center": "Help Center", - "toggle linear mode": "toggle simple mode", + "toggle linear mode": "Toggle Simple Mode", "Toggle Queue Panel V2": "Toggle Queue Panel V2", "Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)", "Undo": "Undo", @@ -2494,7 +2494,7 @@ "linearMode": "Simple Mode", "beta": "Beta - Give Feedback", "graphMode": "Graph Mode", - "dragAndDropImage": "Drag and drop an image", + "dragAndDropImage": "Click to browse or drag an image", "runCount": "Run count:", "rerun": "Rerun", "reuseParameters": "Reuse Parameters", diff --git a/src/renderer/extensions/linearMode/LinearControls.vue b/src/renderer/extensions/linearMode/LinearControls.vue index 36e3514e6..1f35caf41 100644 --- a/src/renderer/extensions/linearMode/LinearControls.vue +++ b/src/renderer/extensions/linearMode/LinearControls.vue @@ -23,6 +23,7 @@ import { useCommandStore } from '@/stores/commandStore' import { useExecutionStore } from '@/stores/executionStore' import { useQueueSettingsStore } from '@/stores/queueStore' import type { SimplifiedWidget } from '@/types/simplifiedWidget' +import { cn } from '@/utils/tailwindUtil' const commandStore = useCommandStore() const executionStore = useExecutionStore() @@ -55,13 +56,16 @@ function nodeToNodeData(node: LGraphNode) { ? undefined : { iconClass: 'icon-[lucide--image]', - label: t('linearMode.dragAndDropImage') + label: t('linearMode.dragAndDropImage'), + onClick: () => node.widgets?.[1]?.callback?.(undefined) } const nodeData = extractVueNodeData(node) for (const widget of nodeData.widgets ?? []) widget.slotMetadata = undefined return { ...nodeData, + //note lastNodeErrors uses exeuctionid, node.id is execution for root + hasErrors: !!executionStore.lastNodeErrors?.[node.id], dropIndicator, onDragDrop: node.onDragDrop, @@ -69,13 +73,18 @@ function nodeToNodeData(node: LGraphNode) { } } const partitionedNodes = computed(() => { - return partition( + const parts = partition( graphNodes.value .filter((node) => node.mode === 0 && node.widgets?.length) .map(nodeToNodeData) .reverse(), (node) => ['MarkdownNote', 'Note'].includes(node.type) ) + for (const noteNode of parts[0]) { + for (const widget of noteNode.widgets ?? []) + widget.options = { ...widget.options, read_only: true } + } + return parts }) const batchCountWidget: SimplifiedWidget = { @@ -165,7 +174,7 @@ defineExpose({ runButtonClick }) diff --git a/src/renderer/extensions/linearMode/LinearPreview.vue b/src/renderer/extensions/linearMode/LinearPreview.vue index 2e466b80e..e74a5a989 100644 --- a/src/renderer/extensions/linearMode/LinearPreview.vue +++ b/src/renderer/extensions/linearMode/LinearPreview.vue @@ -2,7 +2,6 @@ import { computed } from 'vue' import { downloadFile } from '@/base/common/downloadUtil' -import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue' import Popover from '@/components/ui/Popover.vue' import Button from '@/components/ui/button/Button.vue' import { d, t } from '@/i18n' @@ -12,6 +11,7 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil' import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue' +import Preview3d from '@/renderer/extensions/linearMode/Preview3d.vue' import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue' import { getMediaType, @@ -172,7 +172,7 @@ async function rerun(e: Event) { class="w-full max-w-128 m-auto my-12 overflow-y-auto" v-text="selectedOutput!.url" /> - diff --git a/src/renderer/extensions/linearMode/OutputHistory.vue b/src/renderer/extensions/linearMode/OutputHistory.vue index 2e3bd1a5e..46d7eb90e 100644 --- a/src/renderer/extensions/linearMode/OutputHistory.vue +++ b/src/renderer/extensions/linearMode/OutputHistory.vue @@ -277,6 +277,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => { 'border-2' ) " + @click="selectedIndex = [index, key]" > +import { useTemplateRef, watch } from 'vue' + +import AnimationControls from '@/components/load3d/controls/AnimationControls.vue' +import { useLoad3dViewer } from '@/composables/useLoad3dViewer' + +const { modelUrl } = defineProps<{ + modelUrl: string +}>() + +const containerRef = useTemplateRef('containerRef') + +const viewer = useLoad3dViewer() + +watch([containerRef, () => modelUrl], async () => { + if (!containerRef.value || !modelUrl) return + + await viewer.initializeStandaloneViewer(containerRef.value, modelUrl) +}) + +//TODO: refactor to add control buttons + + diff --git a/src/views/LinearView.vue b/src/views/LinearView.vue index c8c970620..d30b5f48a 100644 --- a/src/views/LinearView.vue +++ b/src/views/LinearView.vue @@ -44,7 +44,10 @@ const bottomRightRef = useTemplateRef('bottomRightRef') const linearWorkflowRef = useTemplateRef('linearWorkflowRef') + + diff --git a/src/components/ui/tags-input/TagsInputInput.vue b/src/components/ui/tags-input/TagsInputInput.vue new file mode 100644 index 000000000..62d3f01e8 --- /dev/null +++ b/src/components/ui/tags-input/TagsInputInput.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/components/ui/tags-input/TagsInputItem.vue b/src/components/ui/tags-input/TagsInputItem.vue new file mode 100644 index 000000000..40cdab511 --- /dev/null +++ b/src/components/ui/tags-input/TagsInputItem.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ui/tags-input/TagsInputItemDelete.vue b/src/components/ui/tags-input/TagsInputItemDelete.vue new file mode 100644 index 000000000..d5cc0a933 --- /dev/null +++ b/src/components/ui/tags-input/TagsInputItemDelete.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/components/ui/tags-input/TagsInputItemText.vue b/src/components/ui/tags-input/TagsInputItemText.vue new file mode 100644 index 000000000..f65119c72 --- /dev/null +++ b/src/components/ui/tags-input/TagsInputItemText.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/components/ui/tags-input/tagsInputContext.ts b/src/components/ui/tags-input/tagsInputContext.ts new file mode 100644 index 000000000..05fff98e5 --- /dev/null +++ b/src/components/ui/tags-input/tagsInputContext.ts @@ -0,0 +1,10 @@ +import type { InjectionKey, Ref } from 'vue' + +export type FocusCallback = (() => void) | undefined + +export const tagsInputFocusKey: InjectionKey< + (callback: FocusCallback) => void +> = Symbol('tagsInputFocus') + +export const tagsInputIsEditingKey: InjectionKey> = + Symbol('tagsInputIsEditing') diff --git a/src/locales/en/main.json b/src/locales/en/main.json index edbad7746..9fd1284fd 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -16,6 +16,7 @@ "increment": "Increment", "removeImage": "Remove image", "removeVideo": "Remove video", + "removeTag": "Remove tag", "chart": "Chart", "chartLowercase": "chart", "file": "file", From aff7f2a296c5a18059c2cc25423ca083204c2716 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Thu, 15 Jan 2026 15:49:32 -0500 Subject: [PATCH 016/109] fix: prevent Record Audio waveform from overflowing node bounds (#8070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add min-w-0 to flex containers to allow shrinking, reduce gaps and padding, and use shrink-0 on fixed-size elements to ensure the waveform area clips properly when the node is at minimum width. ## Screenshots (if applicable) before https://github.com/user-attachments/assets/215455aa-555b-4ade-9b36-9e89ac7c14aa after https://github.com/user-attachments/assets/bf56028b-ae02-4388-be83-460c7b1f14e1 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8070-fix-prevent-Record-Audio-waveform-from-overflowing-node-bounds-2e96d73d3650816b9660e5532ffa0bc3) by [Unito](https://www.unito.io) --- .../widgets/components/WidgetRecordAudio.vue | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetRecordAudio.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetRecordAudio.vue index aa0fc4df3..6e392d8e0 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetRecordAudio.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetRecordAudio.vue @@ -12,11 +12,11 @@
-
- +
+ {{ isRecording ? t('g.listening', 'Listening...') @@ -27,11 +27,11 @@ : '' }} - {{ formatTime(timer) }} + {{ formatTime(timer) }}
-
+
@@ -54,7 +54,7 @@
@@ -52,7 +52,7 @@ diff --git a/src/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue b/src/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue deleted file mode 100644 index 571b22ecf..000000000 --- a/src/workbench/extensions/manager/components/manager/ManagerNavSidebar.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - diff --git a/src/workbench/extensions/manager/components/manager/button/PackUpdateButton.vue b/src/workbench/extensions/manager/components/manager/button/PackUpdateButton.vue index 8057cc3c6..328b76ba1 100644 --- a/src/workbench/extensions/manager/components/manager/button/PackUpdateButton.vue +++ b/src/workbench/extensions/manager/components/manager/button/PackUpdateButton.vue @@ -3,9 +3,8 @@ v-tooltip.top=" hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null " - variant="textonly" class="border" - size="sm" + :size :disabled="isUpdating" @click="updateAllPacks" > @@ -19,14 +18,20 @@ import { ref } from 'vue' import DotSpinner from '@/components/common/DotSpinner.vue' import Button from '@/components/ui/button/Button.vue' +import type { ButtonVariants } from '@/components/ui/button/button.variants' import type { components } from '@/types/comfyRegistryTypes' import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' type NodePack = components['schemas']['Node'] -const { nodePacks, hasDisabledUpdatePacks } = defineProps<{ +const { + nodePacks, + hasDisabledUpdatePacks, + size = 'sm' +} = defineProps<{ nodePacks: NodePack[] hasDisabledUpdatePacks?: boolean + size?: ButtonVariants['size'] }>() const isUpdating = ref(false) diff --git a/src/workbench/extensions/manager/components/manager/registrySearchBar/RegistrySearchBar.vue b/src/workbench/extensions/manager/components/manager/registrySearchBar/RegistrySearchBar.vue deleted file mode 100644 index dbb1150d6..000000000 --- a/src/workbench/extensions/manager/components/manager/registrySearchBar/RegistrySearchBar.vue +++ /dev/null @@ -1,130 +0,0 @@ - - - diff --git a/src/workbench/extensions/manager/components/manager/registrySearchBar/SearchFilterDropdown.vue b/src/workbench/extensions/manager/components/manager/registrySearchBar/SearchFilterDropdown.vue deleted file mode 100644 index 973820c72..000000000 --- a/src/workbench/extensions/manager/components/manager/registrySearchBar/SearchFilterDropdown.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.ts b/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.ts index 96f1d4403..24daee309 100644 --- a/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.ts +++ b/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.ts @@ -14,7 +14,6 @@ import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comf /** * Composable to find missing NodePacks from workflow - * Uses the same filtering approach as ManagerDialogContent.vue * Automatically fetches workflow pack data when initialized * This is a shared singleton composable - all components use the same instance */ @@ -25,7 +24,6 @@ export const useMissingNodes = createSharedComposable(() => { const { workflowPacks, isLoading, error, startFetchWorkflowPacks } = useWorkflowPacks() - // Same filtering logic as ManagerDialogContent.vue const filterMissingPacks = (packs: components['schemas']['Node'][]) => packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id)) diff --git a/src/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes.ts b/src/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes.ts index c18fcdf61..fd6f74e1d 100644 --- a/src/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes.ts +++ b/src/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes.ts @@ -7,7 +7,6 @@ import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comf /** * Composable to find NodePacks that have updates available - * Uses the same filtering approach as ManagerDialogContent.vue * Automatically fetches installed pack data when initialized */ export const useUpdateAvailableNodes = () => { @@ -34,7 +33,6 @@ export const useUpdateAvailableNodes = () => { return compare(latestVersion, installedVersion) > 0 } - // Same filtering logic as ManagerDialogContent.vue const filterOutdatedPacks = (packs: components['schemas']['Node'][]) => packs.filter(isOutdatedPack) diff --git a/src/workbench/extensions/manager/composables/useManagerDialog.ts b/src/workbench/extensions/manager/composables/useManagerDialog.ts new file mode 100644 index 000000000..9cf8fb01e --- /dev/null +++ b/src/workbench/extensions/manager/composables/useManagerDialog.ts @@ -0,0 +1,36 @@ +import { useDialogService } from '@/services/dialogService' +import { useDialogStore } from '@/stores/dialogStore' +import type { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes' +import ManagerDialog from '@/workbench/extensions/manager/components/manager/ManagerDialog.vue' + +const DIALOG_KEY = 'global-manager' + +export function useManagerDialog() { + const dialogService = useDialogService() + const dialogStore = useDialogStore() + + function hide() { + dialogStore.closeDialog({ key: DIALOG_KEY }) + } + + function show(initialTab?: ManagerTab) { + dialogService.showLayoutDialog({ + key: DIALOG_KEY, + component: ManagerDialog, + props: { + onClose: hide, + initialTab + }, + dialogComponentProps: { + pt: { + content: { class: '!px-0 overflow-hidden h-full !py-0' } + } + } + }) + } + + return { + show, + hide + } +} diff --git a/src/workbench/extensions/manager/composables/useManagerState.test.ts b/src/workbench/extensions/manager/composables/useManagerState.test.ts index c2f0f83c8..1ba41d107 100644 --- a/src/workbench/extensions/manager/composables/useManagerState.test.ts +++ b/src/workbench/extensions/manager/composables/useManagerState.test.ts @@ -53,6 +53,13 @@ vi.mock('@/stores/toastStore', () => ({ })) })) +vi.mock('@/workbench/extensions/manager/composables/useManagerDialog', () => ({ + useManagerDialog: vi.fn(() => ({ + show: vi.fn(), + hide: vi.fn() + })) +})) + describe('useManagerState', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/workbench/extensions/manager/composables/useManagerState.ts b/src/workbench/extensions/manager/composables/useManagerState.ts index f8b7e095b..49c9debfd 100644 --- a/src/workbench/extensions/manager/composables/useManagerState.ts +++ b/src/workbench/extensions/manager/composables/useManagerState.ts @@ -7,6 +7,7 @@ import { api } from '@/scripts/api' import { useDialogService } from '@/services/dialogService' import { useCommandStore } from '@/stores/commandStore' import { useSystemStatsStore } from '@/stores/systemStatsStore' +import { useManagerDialog } from '@/workbench/extensions/manager/composables/useManagerDialog' import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes' export enum ManagerUIState { @@ -19,6 +20,7 @@ export function useManagerState() { const systemStatsStore = useSystemStatsStore() const { systemStats, isInitialized: systemInitialized } = storeToRefs(systemStatsStore) + const managerDialog = useManagerDialog() /** * The current manager UI state. @@ -186,11 +188,9 @@ export function useManagerState() { detail: t('manager.legacyMenuNotAvailable'), life: 3000 }) - dialogService.showManagerDialog({ initialTab: ManagerTab.All }) + await managerDialog.show(ManagerTab.All) } else { - dialogService.showManagerDialog( - options?.initialTab ? { initialTab: options.initialTab } : undefined - ) + await managerDialog.show(options?.initialTab) } break } diff --git a/src/workbench/extensions/manager/types/comfyManagerTypes.ts b/src/workbench/extensions/manager/types/comfyManagerTypes.ts index c53619719..bd7ab024e 100644 --- a/src/workbench/extensions/manager/types/comfyManagerTypes.ts +++ b/src/workbench/extensions/manager/types/comfyManagerTypes.ts @@ -20,12 +20,6 @@ export enum ManagerTab { UpdateAvailable = 'updateAvailable' } -export interface TabItem { - id: ManagerTab - label: string - icon: string -} - export type TaskLog = { taskName: string taskId: string @@ -37,11 +31,6 @@ export interface UseNodePacksOptions { maxConcurrent?: number } -export interface SearchOption { - id: T - label: string -} - export enum SortableAlgoliaField { Downloads = 'total_install', Created = 'create_time', From b979ba899291c4b69e3e2a35e9b2701766480a0c Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:40:27 +0100 Subject: [PATCH 027/109] fix: run E2E tests after i18n completes on release PRs (#8091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fixes issue where locale commits would cancel in-progress E2E tests on release PRs - E2E tests now run **once** after i18n workflow completes for version-bump PRs ## Changes 1. **Modified `ci-tests-e2e.yaml`**: - Added `workflow_call` trigger with `ref` and `pr_number` inputs - Added skip condition for version-bump PRs on `pull_request` trigger - Updated checkout steps to use `inputs.ref` when called via `workflow_call` 2. **Created `ci-tests-e2e-release.yaml`**: - Triggers on `workflow_run` completion of `i18n: Update Core` - Only runs for version-bump PRs from main repo - Calls original E2E workflow via `workflow_call` (no job duplication) ## How it works **Regular PRs:** `CI: Tests E2E` runs normally via `pull_request` trigger **Version-bump PRs:** 1. `CI: Tests E2E` skips (setup job condition fails) 2. `i18n: Update Core` runs and commits locale updates 3. `CI: Tests E2E (Release PRs)` triggers after i18n completes 4. Calls `CI: Tests E2E` via `workflow_call` 5. E2E tests run to completion without cancellation ## Test plan - [x] Tested workflow_call chain on fork - [x] Verify version-bump PR skips regular E2E workflow - [x] Verify E2E runs after i18n completes on next release Fixes the issue identified during v1.38.2 release where locale commits caused E2E tests to restart multiple times. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8091-fix-run-E2E-tests-after-i18n-completes-on-release-PRs-2ea6d73d36508151a315ed1f415afcc6) by [Unito](https://www.unito.io) --- .github/workflows/ci-tests-e2e-release.yaml | 65 +++++++++++++++++++++ .github/workflows/ci-tests-e2e.yaml | 50 +++++++++++++--- 2 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/ci-tests-e2e-release.yaml diff --git a/.github/workflows/ci-tests-e2e-release.yaml b/.github/workflows/ci-tests-e2e-release.yaml new file mode 100644 index 000000000..e6d12415d --- /dev/null +++ b/.github/workflows/ci-tests-e2e-release.yaml @@ -0,0 +1,65 @@ +# Description: Runs E2E tests for release PRs after i18n workflow completes +name: 'CI: Tests E2E (Release PRs)' + +on: + workflow_run: + workflows: ['i18n: Update Core'] + types: [completed] + +jobs: + check-eligibility: + runs-on: ubuntu-latest + # Only run if: + # 1. This is the main repository + # 2. The i18n workflow was triggered by a pull_request + # 3. The i18n workflow completed successfully or was skipped (no locale changes) + # 4. The branch is a version-bump branch + # 5. The PR is from the main repo (not a fork) + if: | + github.repository == 'Comfy-Org/ComfyUI_frontend' && + github.event.workflow_run.event == 'pull_request' && + (github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'skipped') && + startsWith(github.event.workflow_run.head_branch, 'version-bump-') && + github.event.workflow_run.head_repository.full_name == github.event.workflow_run.repository.full_name + outputs: + pr_number: ${{ steps.pr.outputs.result }} + head_sha: ${{ github.event.workflow_run.head_sha }} + steps: + - name: Log workflow trigger info + run: | + echo "Repository: ${{ github.repository }}" + echo "Event: ${{ github.event.workflow_run.event }}" + echo "Conclusion: ${{ github.event.workflow_run.conclusion }}" + echo "Head branch: ${{ github.event.workflow_run.head_branch }}" + echo "Head SHA: ${{ github.event.workflow_run.head_sha }}" + echo "Head repo: ${{ github.event.workflow_run.head_repository.full_name }}" + + - name: Get PR Number + id: pr + uses: actions/github-script@v7 + with: + script: | + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + }); + + const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha); + + if (!pr) { + console.log('No PR found for SHA:', context.payload.workflow_run.head_sha); + return null; + } + + console.log(`Found PR #${pr.number} for version bump: ${context.payload.workflow_run.head_branch}`); + return pr.number; + + run-e2e-tests: + needs: check-eligibility + if: needs.check-eligibility.outputs.pr_number != 'null' + uses: ./.github/workflows/ci-tests-e2e.yaml + with: + ref: ${{ needs.check-eligibility.outputs.head_sha }} + pr_number: ${{ needs.check-eligibility.outputs.pr_number }} + secrets: inherit diff --git a/.github/workflows/ci-tests-e2e.yaml b/.github/workflows/ci-tests-e2e.yaml index c1e0af411..acc9e3059 100644 --- a/.github/workflows/ci-tests-e2e.yaml +++ b/.github/workflows/ci-tests-e2e.yaml @@ -7,17 +7,35 @@ on: pull_request: branches-ignore: [wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*] + workflow_call: + inputs: + ref: + description: 'Git ref to checkout' + required: true + type: string + pr_number: + description: 'PR number for commenting' + required: false + type: string concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ inputs.ref || github.ref }} cancel-in-progress: true jobs: setup: runs-on: ubuntu-latest + # Skip version-bump PRs on pull_request trigger (they use ci-tests-e2e-release.yaml) + # Always run for push, workflow_call, or non-version-bump PRs + if: | + github.event_name == 'push' || + github.event_name == 'workflow_call' || + !startsWith(github.head_ref, 'version-bump-') steps: - name: Checkout repository uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref || '' }} - name: Setup frontend uses: ./.github/actions/setup-frontend with: @@ -52,6 +70,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref || '' }} - name: Download built frontend uses: actions/download-artifact@v4 with: @@ -99,6 +119,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref || '' }} - name: Download built frontend uses: actions/download-artifact@v4 with: @@ -143,6 +165,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref || '' }} - name: Install pnpm uses: pnpm/action-setup@v4 @@ -178,12 +202,16 @@ jobs: # Post starting comment for non-forked PRs comment-on-pr-start: runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + if: | + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) || + (github.event_name == 'workflow_call' && inputs.pr_number != '') permissions: pull-requests: write steps: - name: Checkout repository uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref || '' }} - name: Get start time id: start-time @@ -195,8 +223,8 @@ jobs: run: | chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh ./scripts/cicd/pr-playwright-deploy-and-comment.sh \ - "${{ github.event.pull_request.number }}" \ - "${{ github.head_ref }}" \ + "${{ inputs.pr_number || github.event.pull_request.number }}" \ + "${{ github.head_ref || inputs.ref }}" \ "starting" \ "${{ steps.start-time.outputs.time }}" @@ -204,13 +232,19 @@ jobs: deploy-and-comment: needs: [playwright-tests, merge-reports] runs-on: ubuntu-latest - if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + if: | + always() && ( + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) || + (github.event_name == 'workflow_call' && inputs.pr_number != '') + ) permissions: pull-requests: write contents: read steps: - name: Checkout repository uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref || '' }} - name: Download all playwright reports uses: actions/download-artifact@v4 @@ -223,10 +257,10 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} GITHUB_TOKEN: ${{ github.token }} - GITHUB_SHA: ${{ github.event.pull_request.head.sha }} + GITHUB_SHA: ${{ inputs.ref || github.event.pull_request.head.sha }} run: | bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \ - "${{ github.event.pull_request.number }}" \ - "${{ github.head_ref }}" \ + "${{ inputs.pr_number || github.event.pull_request.number }}" \ + "${{ github.head_ref || inputs.ref }}" \ "completed" #### END Deployment and commenting (non-forked PRs only) From d93c02c4372bbc78fd3c0e8e4877f45766def7c2 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Fri, 16 Jan 2026 11:24:50 -0800 Subject: [PATCH 028/109] Revert "fix: run E2E tests after i18n completes on release PRs" (#8105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts Comfy-Org/ComfyUI_frontend#8091 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8105-Revert-fix-run-E2E-tests-after-i18n-completes-on-release-PRs-2ea6d73d365081f2bd26cdc932acf7fb) by [Unito](https://www.unito.io) --- .github/workflows/ci-tests-e2e-release.yaml | 65 --------------------- .github/workflows/ci-tests-e2e.yaml | 50 +++------------- 2 files changed, 8 insertions(+), 107 deletions(-) delete mode 100644 .github/workflows/ci-tests-e2e-release.yaml diff --git a/.github/workflows/ci-tests-e2e-release.yaml b/.github/workflows/ci-tests-e2e-release.yaml deleted file mode 100644 index e6d12415d..000000000 --- a/.github/workflows/ci-tests-e2e-release.yaml +++ /dev/null @@ -1,65 +0,0 @@ -# Description: Runs E2E tests for release PRs after i18n workflow completes -name: 'CI: Tests E2E (Release PRs)' - -on: - workflow_run: - workflows: ['i18n: Update Core'] - types: [completed] - -jobs: - check-eligibility: - runs-on: ubuntu-latest - # Only run if: - # 1. This is the main repository - # 2. The i18n workflow was triggered by a pull_request - # 3. The i18n workflow completed successfully or was skipped (no locale changes) - # 4. The branch is a version-bump branch - # 5. The PR is from the main repo (not a fork) - if: | - github.repository == 'Comfy-Org/ComfyUI_frontend' && - github.event.workflow_run.event == 'pull_request' && - (github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'skipped') && - startsWith(github.event.workflow_run.head_branch, 'version-bump-') && - github.event.workflow_run.head_repository.full_name == github.event.workflow_run.repository.full_name - outputs: - pr_number: ${{ steps.pr.outputs.result }} - head_sha: ${{ github.event.workflow_run.head_sha }} - steps: - - name: Log workflow trigger info - run: | - echo "Repository: ${{ github.repository }}" - echo "Event: ${{ github.event.workflow_run.event }}" - echo "Conclusion: ${{ github.event.workflow_run.conclusion }}" - echo "Head branch: ${{ github.event.workflow_run.head_branch }}" - echo "Head SHA: ${{ github.event.workflow_run.head_sha }}" - echo "Head repo: ${{ github.event.workflow_run.head_repository.full_name }}" - - - name: Get PR Number - id: pr - uses: actions/github-script@v7 - with: - script: | - const { data: prs } = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - }); - - const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha); - - if (!pr) { - console.log('No PR found for SHA:', context.payload.workflow_run.head_sha); - return null; - } - - console.log(`Found PR #${pr.number} for version bump: ${context.payload.workflow_run.head_branch}`); - return pr.number; - - run-e2e-tests: - needs: check-eligibility - if: needs.check-eligibility.outputs.pr_number != 'null' - uses: ./.github/workflows/ci-tests-e2e.yaml - with: - ref: ${{ needs.check-eligibility.outputs.head_sha }} - pr_number: ${{ needs.check-eligibility.outputs.pr_number }} - secrets: inherit diff --git a/.github/workflows/ci-tests-e2e.yaml b/.github/workflows/ci-tests-e2e.yaml index acc9e3059..c1e0af411 100644 --- a/.github/workflows/ci-tests-e2e.yaml +++ b/.github/workflows/ci-tests-e2e.yaml @@ -7,35 +7,17 @@ on: pull_request: branches-ignore: [wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*] - workflow_call: - inputs: - ref: - description: 'Git ref to checkout' - required: true - type: string - pr_number: - description: 'PR number for commenting' - required: false - type: string concurrency: - group: ${{ github.workflow }}-${{ inputs.ref || github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: setup: runs-on: ubuntu-latest - # Skip version-bump PRs on pull_request trigger (they use ci-tests-e2e-release.yaml) - # Always run for push, workflow_call, or non-version-bump PRs - if: | - github.event_name == 'push' || - github.event_name == 'workflow_call' || - !startsWith(github.head_ref, 'version-bump-') steps: - name: Checkout repository uses: actions/checkout@v5 - with: - ref: ${{ inputs.ref || '' }} - name: Setup frontend uses: ./.github/actions/setup-frontend with: @@ -70,8 +52,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 - with: - ref: ${{ inputs.ref || '' }} - name: Download built frontend uses: actions/download-artifact@v4 with: @@ -119,8 +99,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 - with: - ref: ${{ inputs.ref || '' }} - name: Download built frontend uses: actions/download-artifact@v4 with: @@ -165,8 +143,6 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 - with: - ref: ${{ inputs.ref || '' }} - name: Install pnpm uses: pnpm/action-setup@v4 @@ -202,16 +178,12 @@ jobs: # Post starting comment for non-forked PRs comment-on-pr-start: runs-on: ubuntu-latest - if: | - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) || - (github.event_name == 'workflow_call' && inputs.pr_number != '') + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false permissions: pull-requests: write steps: - name: Checkout repository uses: actions/checkout@v5 - with: - ref: ${{ inputs.ref || '' }} - name: Get start time id: start-time @@ -223,8 +195,8 @@ jobs: run: | chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh ./scripts/cicd/pr-playwright-deploy-and-comment.sh \ - "${{ inputs.pr_number || github.event.pull_request.number }}" \ - "${{ github.head_ref || inputs.ref }}" \ + "${{ github.event.pull_request.number }}" \ + "${{ github.head_ref }}" \ "starting" \ "${{ steps.start-time.outputs.time }}" @@ -232,19 +204,13 @@ jobs: deploy-and-comment: needs: [playwright-tests, merge-reports] runs-on: ubuntu-latest - if: | - always() && ( - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) || - (github.event_name == 'workflow_call' && inputs.pr_number != '') - ) + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false permissions: pull-requests: write contents: read steps: - name: Checkout repository uses: actions/checkout@v5 - with: - ref: ${{ inputs.ref || '' }} - name: Download all playwright reports uses: actions/download-artifact@v4 @@ -257,10 +223,10 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} GITHUB_TOKEN: ${{ github.token }} - GITHUB_SHA: ${{ inputs.ref || github.event.pull_request.head.sha }} + GITHUB_SHA: ${{ github.event.pull_request.head.sha }} run: | bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \ - "${{ inputs.pr_number || github.event.pull_request.number }}" \ - "${{ github.head_ref || inputs.ref }}" \ + "${{ github.event.pull_request.number }}" \ + "${{ github.head_ref }}" \ "completed" #### END Deployment and commenting (non-forked PRs only) From 7556e3ef397caa74602177eef3afd8dcaa97c005 Mon Sep 17 00:00:00 2001 From: Yoland Yan <4950057+yoland68@users.noreply.github.com> Date: Fri, 16 Jan 2026 11:25:26 -0800 Subject: [PATCH 029/109] Update beta message in linear mode (#8106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request makes a minor update to the user interface text for Simple Mode. The "beta" label is now more descriptive, clarifying that Simple Mode is in beta and inviting feedback. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8106-Update-beta-message-in-linear-mode-2ea6d73d3650812193a7cd8f625ea682) by [Unito](https://www.unito.io) --- src/locales/en/main.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index a856c3b0e..17dad737f 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2496,7 +2496,7 @@ }, "linearMode": { "linearMode": "Simple Mode", - "beta": "Beta - Give Feedback", + "beta": "Simple Mode in Beta - Feedback", "graphMode": "Graph Mode", "dragAndDropImage": "Click to browse or drag an image", "runCount": "Run count:", @@ -2608,4 +2608,4 @@ "tokenExchangeFailed": "Failed to authenticate with workspace: {error}" } } -} \ No newline at end of file +} From cd4999209bbfb6c492e09ef8d38b5643126f1894 Mon Sep 17 00:00:00 2001 From: AustinMroz Date: Fri, 16 Jan 2026 11:36:52 -0800 Subject: [PATCH 030/109] Improve linear compatibility with Safari, run button metadata (#8107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I downloaded the oldest webkit based browser I could find after reports of display issues. The changes here cause some minor styling tweaks, but should be more compatible overall. A quick list of fixed issues - Center panel placeholder had incorrect aspect ratio image - Image previews had incorrect aspect ratio (But the other way around) image - On mobile, output groups would flex incorrectly, resulting in a large gap between them image Also moves the run button trigger source to a new 'linear' type ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8107-Improve-linear-compatibility-with-Safari-run-button-metadata-2ea6d73d3650814e89cbc190b6ca8f87) by [Unito](https://www.unito.io) --- src/components/ui/ZoomPane.vue | 2 +- src/platform/telemetry/types.ts | 1 + .../extensions/linearMode/ImagePreview.vue | 1 + .../extensions/linearMode/LinearControls.vue | 5 +---- .../extensions/linearMode/LinearPreview.vue | 2 +- .../extensions/linearMode/OutputHistory.vue | 14 ++++++-------- 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/components/ui/ZoomPane.vue b/src/components/ui/ZoomPane.vue index 60ea6533b..2cbb286ad 100644 --- a/src/components/ui/ZoomPane.vue +++ b/src/components/ui/ZoomPane.vue @@ -47,7 +47,7 @@ const transform = computed(() => { diff --git a/src/renderer/extensions/linearMode/OutputHistory.vue b/src/renderer/extensions/linearMode/OutputHistory.vue index ddd99b882..c5948ecfa 100644 --- a/src/renderer/extensions/linearMode/OutputHistory.vue +++ b/src/renderer/extensions/linearMode/OutputHistory.vue @@ -247,18 +247,16 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => { " /> -
+ From b1dfbfaa09cd5f76ca8c11715a913283dfd4b3c3 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Tue, 20 Jan 2026 16:44:08 -0800 Subject: [PATCH 069/109] chore: Replace prettier with oxfmt (#8177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configure oxfmt ignorePatterns to exclude non-JS/TS files (md, json, css, yaml, etc.) to match previous Prettier behavior. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8177-chore-configure-oxfmt-to-format-only-JS-TS-Vue-files-2ee6d73d3650815080f3cc8a4a932109) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp --- .claude/commands/setup_repo.md | 2 +- .github/workflows/ci-lint-format.yaml | 6 +- .i18nrc.cjs | 18 +- .oxfmtrc.json | 20 ++ .prettierignore | 2 - .prettierrc | 11 - .vscode/extensions.json | 15 +- AGENTS.md | 8 +- eslint.config.ts | 6 +- lint-staged.config.mjs | 25 -- lint-staged.config.ts | 5 +- package.json | 10 +- pnpm-lock.yaml | 323 ++++++------------- pnpm-workspace.yaml | 4 +- src/composables/useContextMenuTranslation.ts | 3 +- vite.config.mts | 3 +- 16 files changed, 158 insertions(+), 303 deletions(-) create mode 100644 .oxfmtrc.json delete mode 100644 .prettierignore delete mode 100644 .prettierrc delete mode 100644 lint-staged.config.mjs diff --git a/.claude/commands/setup_repo.md b/.claude/commands/setup_repo.md index d82e22ec6..71dee96a5 100644 --- a/.claude/commands/setup_repo.md +++ b/.claude/commands/setup_repo.md @@ -122,7 +122,7 @@ echo " pnpm build - Build for production" echo " pnpm test:unit - Run unit tests" echo " pnpm typecheck - Run TypeScript checks" echo " pnpm lint - Run ESLint" -echo " pnpm format - Format code with Prettier" +echo " pnpm format - Format code with oxfmt" echo "" echo "Next steps:" echo "1. Run 'pnpm dev' to start developing" diff --git a/.github/workflows/ci-lint-format.yaml b/.github/workflows/ci-lint-format.yaml index 3ce6d6aa9..c97f6255c 100644 --- a/.github/workflows/ci-lint-format.yaml +++ b/.github/workflows/ci-lint-format.yaml @@ -42,7 +42,7 @@ jobs: - name: Run Stylelint with auto-fix run: pnpm stylelint:fix - - name: Run Prettier with auto-format + - name: Run oxfmt with auto-format run: pnpm format - name: Check for changes @@ -60,7 +60,7 @@ jobs: git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add . - git commit -m "[automated] Apply ESLint and Prettier fixes" + git commit -m "[automated] Apply ESLint and Oxfmt fixes" git push - name: Final validation @@ -80,7 +80,7 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting' + body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting' }) - name: Comment on PR about manual fix needed diff --git a/.i18nrc.cjs b/.i18nrc.cjs index 86ce06eaa..4369f0a70 100644 --- a/.i18nrc.cjs +++ b/.i18nrc.cjs @@ -1,7 +1,7 @@ // This file is intentionally kept in CommonJS format (.cjs) // to resolve compatibility issues with dependencies that require CommonJS. // Do not convert this file to ESModule format unless all dependencies support it. -const { defineConfig } = require('@lobehub/i18n-cli'); +const { defineConfig } = require('@lobehub/i18n-cli') module.exports = defineConfig({ modelName: 'gpt-4.1', @@ -10,7 +10,19 @@ module.exports = defineConfig({ entry: 'src/locales/en', entryLocale: 'en', output: 'src/locales', - outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'], + outputLocales: [ + 'zh', + 'zh-TW', + 'ru', + 'ja', + 'ko', + 'fr', + 'es', + 'ar', + 'tr', + 'pt-BR', + 'fa' + ], reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face. 'latent' is the short form of 'latent space'. 'mask' is in the context of image processing. @@ -26,4 +38,4 @@ module.exports = defineConfig({ - Use Arabic-Indic numerals (۰-۹) for numbers where appropriate. - Maintain consistency with terminology used in Persian software and design applications. ` -}); +}) diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 000000000..5da4febe2 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,20 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "singleQuote": true, + "tabWidth": 2, + "semi": false, + "trailingComma": "none", + "printWidth": 80, + "ignorePatterns": [ + "packages/registry-types/src/comfyRegistryTypes.ts", + "src/types/generatedManagerTypes.ts", + "**/*.md", + "**/*.json", + "**/*.css", + "**/*.yaml", + "**/*.yml", + "**/*.html", + "**/*.svg", + "**/*.xml" + ] +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 4403edd8e..000000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -packages/registry-types/src/comfyRegistryTypes.ts -src/types/generatedManagerTypes.ts diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index aa43a43ac..000000000 --- a/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "singleQuote": true, - "tabWidth": 2, - "semi": false, - "trailingComma": "none", - "printWidth": 80, - "importOrder": ["^@core/(.*)$", "", "^@/(.*)$", "^[./]"], - "importOrderSeparation": true, - "importOrderSortSpecifiers": true, - "plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"] -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 54f28d400..9cbac42d7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,25 +1,22 @@ { "recommendations": [ + "antfu.vite", "austenc.tailwind-docs", "bradlc.vscode-tailwindcss", "davidanson.vscode-markdownlint", "dbaeumer.vscode-eslint", + "donjayamanne.githistory", "eamodio.gitlens", - "esbenp.prettier-vscode", - "figma.figma-vscode-extension", "github.vscode-github-actions", "github.vscode-pull-request-github", "hbenl.vscode-test-explorer", + "kisstkondoros.vscode-codemetrics", "lokalise.i18n-ally", "ms-playwright.playwright", + "oxc.oxc-vscode", + "sonarsource.sonarlint-vscode", "vitest.explorer", "vue.volar", - "sonarsource.sonarlint-vscode", - "deque-systems.vscode-axe-linter", - "kisstkondoros.vscode-codemetrics", - "donjayamanne.githistory", - "wix.vscode-import-cost", - "prograhammer.tslint-vue", - "antfu.vite" + "wix.vscode-import-cost" ] } diff --git a/AGENTS.md b/AGENTS.md index da2953783..9938865a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,10 +27,10 @@ See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob) - Build output: `dist/` - Configs - `vite.config.mts` - - `vitest.config.ts` - `playwright.config.ts` - `eslint.config.ts` - - `.prettierrc` + - `.oxfmtrc.json` + - `.oxlintrc.json` - etc. ## Monorepo Architecture @@ -46,7 +46,7 @@ The project uses **Nx** for build orchestration and task management - `pnpm test:unit`: Run Vitest unit tests - `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`) - `pnpm lint` / `pnpm lint:fix`: Lint (ESLint) -- `pnpm format` / `pnpm format:check`: Prettier +- `pnpm format` / `pnpm format:check`: oxfmt - `pnpm typecheck`: Vue TSC type checking - `pnpm storybook`: Start Storybook development server @@ -72,7 +72,7 @@ The project uses **Nx** for build orchestration and task management - Composition API only - Tailwind 4 styling - Avoid ` diff --git a/src/components/sidebar/SidebarIcon.test.ts b/src/components/sidebar/SidebarIcon.test.ts index 7564e7bcd..284a29825 100644 --- a/src/components/sidebar/SidebarIcon.test.ts +++ b/src/components/sidebar/SidebarIcon.test.ts @@ -1,6 +1,5 @@ import { mount } from '@vue/test-utils' import PrimeVue from 'primevue/config' -import OverlayBadge from 'primevue/overlaybadge' import Tooltip from 'primevue/tooltip' import { describe, expect, it } from 'vitest' import { createI18n } from 'vue-i18n' @@ -33,8 +32,7 @@ describe('SidebarIcon', () => { return mount(SidebarIcon, { global: { plugins: [PrimeVue, i18n], - directives: { tooltip: Tooltip }, - components: { OverlayBadge } + directives: { tooltip: Tooltip } }, props: { ...exampleProps, ...props }, ...options @@ -54,9 +52,9 @@ describe('SidebarIcon', () => { it('creates badge when iconBadge prop is set', () => { const badge = '2' const wrapper = mountSidebarIcon({ iconBadge: badge }) - const badgeEl = wrapper.findComponent(OverlayBadge) + const badgeEl = wrapper.find('.sidebar-icon-badge') expect(badgeEl.exists()).toBe(true) - expect(badgeEl.find('.p-badge').text()).toEqual(badge) + expect(badgeEl.text()).toEqual(badge) }) it('shows tooltip on hover', async () => { diff --git a/src/components/sidebar/SidebarIcon.vue b/src/components/sidebar/SidebarIcon.vue index 88900c1a7..10dfca8f8 100644 --- a/src/components/sidebar/SidebarIcon.vue +++ b/src/components/sidebar/SidebarIcon.vue @@ -17,22 +17,28 @@ >