From 99cb7a2da187b93135d28faa104aae9f7c3ab8b4 Mon Sep 17 00:00:00 2001 From: AustinMroz Date: Thu, 8 Jan 2026 09:12:02 -0800 Subject: [PATCH 01/17] Fix linked asset widget promotion in vue (#7895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asset widgets resolve the list of models by checking the name of the node the widget is contained on. When an asset widget is linked to a subgraph node, a clone is made of the widget and then the clone is used to initialize an asset widget in vue mode. Since the widget no longer holds any form of reference to the original node, asset data fails to resolve. This is fixed by storing the original nodeType as an option on the cloned widget when an asset widget is linked to a subgraph input. | Before | After | | ------ | ----- | | before | after| See also #7563, #7560 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7895-Fix-linked-asset-widget-promotion-in-vue-2e26d73d365081e5b295f6236458b978) by [Unito](https://www.unito.io) --- src/lib/litegraph/src/subgraph/SubgraphNode.ts | 3 +++ src/lib/litegraph/src/types/widgets.ts | 2 ++ .../vueNodes/widgets/components/WidgetSelectDropdown.vue | 5 +++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index 43f59b516..e26e1cd48 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -28,6 +28,7 @@ import type { } from '@/lib/litegraph/src/types/serialisation' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { UUID } from '@/lib/litegraph/src/utils/uuid' +import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget' import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap' import { ExecutableNodeDTO } from './ExecutableNodeDTO' @@ -333,6 +334,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { const promotedWidget = toConcreteWidget(widget, this).createCopyForNode( this ) + if (widget instanceof AssetWidget) + promotedWidget.options.nodeType ??= widget.node.type Object.assign(promotedWidget, { get name() { diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index 1cfb323d8..b2cf11557 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -27,6 +27,8 @@ export interface IWidgetOptions { socketless?: boolean /** If `true`, the widget will not be rendered by the Vue renderer. */ canvasOnly?: boolean + /** Used as a temporary override for determining the asset type in vue mode*/ + nodeType?: string values?: TValues /** Optional function to format values for display (e.g., hash → human-readable name) */ diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index f460390a2..84cb49218 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -60,8 +60,9 @@ const combinedProps = computed(() => ({ })) const getAssetData = () => { - if (props.isAssetMode && props.nodeType) { - return useAssetWidgetData(toRef(() => props.nodeType)) + const nodeType = props.widget.options?.nodeType ?? props.nodeType + if (props.isAssetMode && nodeType) { + return useAssetWidgetData(toRef(nodeType)) } return null } From 1bde87838dd96ec216b1a605be3686351ae36801 Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:21:23 +0200 Subject: [PATCH 02/17] fix(price-badges): add missing badge for WanReferenceVideoApi node (#7901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Screenshots Screenshot From 2026-01-08 10-11-05 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7901-fix-price-badges-add-missing-badge-for-WanReferenceVideoApi-node-2e26d73d365081c2b043d265343e90c0) by [Unito](https://www.unito.io) --- src/composables/node/useNodePricing.ts | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index cf627b6f9..ccf51a71c 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -2101,6 +2101,35 @@ const apiNodeCosts: Record = }, LtxvApiImageToVideo: { displayPrice: ltxvPricingCalculator + }, + WanReferenceVideoApi: { + displayPrice: (node: LGraphNode): string => { + const durationWidget = node.widgets?.find( + (w) => w.name === 'duration' + ) as IComboWidget + const sizeWidget = node.widgets?.find( + (w) => w.name === 'size' + ) as IComboWidget + + if (!durationWidget || !sizeWidget) { + return formatCreditsRangeLabel(0.7, 1.5, { + note: '(varies with size & duration)' + }) + } + + const seconds = parseFloat(String(durationWidget.value)) + const sizeStr = String(sizeWidget.value).toLowerCase() + + const rate = sizeStr.includes('1080p') ? 0.15 : 0.1 + const inputMin = 2 * rate + const inputMax = 5 * rate + const outputPrice = seconds * rate + + const minTotal = inputMin + outputPrice + const maxTotal = inputMax + outputPrice + + return formatCreditsRangeLabel(minTotal, maxTotal) + } } } @@ -2254,6 +2283,7 @@ export const useNodePricing = () => { ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'], WanTextToVideoApi: ['duration', 'size'], WanImageToVideoApi: ['duration', 'resolution'], + WanReferenceVideoApi: ['duration', 'size'], LtxvApiTextToVideo: ['model', 'duration', 'resolution'], LtxvApiImageToVideo: ['model', 'duration', 'resolution'] } From 15a05afc27927a98dec24323428b921bd71513ae Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:17:25 +0200 Subject: [PATCH 03/17] fix(price-badges): improve Gemini and OpenAI chat nodes (#7900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Added `~` to the price badges and a correct separator. ## Screenshots (if applicable) Before commit: Screenshot From 2026-01-08 09-53-00 After: Screenshot From 2026-01-08 09-52-09 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7900-fix-price-badges-improve-Gemini-and-OpenAI-chat-nodes-2e26d73d3650812093f2d173de50052d) by [Unito](https://www.unito.io) --- src/composables/node/useNodePricing.test.ts | 102 +++++++++++++------- src/composables/node/useNodePricing.ts | 73 ++++++++++---- 2 files changed, 121 insertions(+), 54 deletions(-) diff --git a/src/composables/node/useNodePricing.test.ts b/src/composables/node/useNodePricing.test.ts index a619f5a19..c811a4796 100644 --- a/src/composables/node/useNodePricing.test.ts +++ b/src/composables/node/useNodePricing.test.ts @@ -1664,31 +1664,41 @@ describe('useNodePricing', () => { { model: 'gemini-2.5-pro-preview-05-06', expected: creditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'gemini-2.5-pro', expected: creditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'gemini-3-pro-preview', expected: creditsListLabel([0.002, 0.012], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'gemini-2.5-flash-preview-04-17', expected: creditsListLabel([0.0003, 0.0025], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'gemini-2.5-flash', expected: creditsListLabel([0.0003, 0.0025], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'unknown-gemini-model', expected: 'Token-based' } @@ -1702,16 +1712,6 @@ describe('useNodePricing', () => { }) }) - it('should return per-second pricing for Gemini Veo models', () => { - const { getNodeDisplayPrice } = useNodePricing() - const node = createMockNode('GeminiNode', [ - { name: 'model', value: 'veo-2.0-generate-001' } - ]) - - const price = getNodeDisplayPrice(node) - expect(price).toBe(creditsLabel(0.5, { suffix: '/second' })) - }) - it('should return fallback for GeminiNode without model widget', () => { const { getNodeDisplayPrice } = useNodePricing() const node = createMockNode('GeminiNode', []) @@ -1737,73 +1737,97 @@ describe('useNodePricing', () => { { model: 'o4-mini', expected: creditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'o1-pro', expected: creditsListLabel([0.15, 0.6], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'o1', expected: creditsListLabel([0.015, 0.06], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'o3-mini', expected: creditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'o3', expected: creditsListLabel([0.01, 0.04], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'gpt-4o', expected: creditsListLabel([0.0025, 0.01], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'gpt-4.1-nano', expected: creditsListLabel([0.0001, 0.0004], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'gpt-4.1-mini', expected: creditsListLabel([0.0004, 0.0016], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'gpt-4.1', expected: creditsListLabel([0.002, 0.008], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'gpt-5-nano', expected: creditsListLabel([0.00005, 0.0004], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'gpt-5-mini', expected: creditsListLabel([0.00025, 0.002], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'gpt-5', expected: creditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } ] @@ -1824,37 +1848,49 @@ describe('useNodePricing', () => { { model: 'gpt-4.1-nano-test', expected: creditsListLabel([0.0001, 0.0004], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'gpt-4.1-mini-test', expected: creditsListLabel([0.0004, 0.0016], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'gpt-4.1-test', expected: creditsListLabel([0.002, 0.008], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'o1-pro-test', expected: creditsListLabel([0.15, 0.6], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'o1-test', expected: creditsListLabel([0.015, 0.06], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'o3-mini-test', expected: creditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) }, { model: 'unknown-model', expected: 'Token-based' } diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index ccf51a71c..641d38911 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -1823,28 +1823,35 @@ const apiNodeCosts: Record = const model = String(modelWidget.value) - // Google Veo video generation - if (model.includes('veo-2.0')) { - return formatCreditsLabel(0.5, { suffix: '/second' }) - } else if (model.includes('gemini-2.5-flash-preview-04-17')) { + if (model.includes('gemini-2.5-flash-preview-04-17')) { return formatCreditsListLabel([0.0003, 0.0025], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('gemini-2.5-flash')) { return formatCreditsListLabel([0.0003, 0.0025], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('gemini-2.5-pro-preview-05-06')) { return formatCreditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('gemini-2.5-pro')) { return formatCreditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('gemini-3-pro-preview')) { return formatCreditsListLabel([0.002, 0.012], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } // For other Gemini models, show token-based pricing info @@ -1899,51 +1906,75 @@ const apiNodeCosts: Record = // Specific pricing for exposed models based on official pricing data (converted to per 1K tokens) if (model.includes('o4-mini')) { return formatCreditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('o1-pro')) { return formatCreditsListLabel([0.15, 0.6], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('o1')) { return formatCreditsListLabel([0.015, 0.06], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('o3-mini')) { return formatCreditsListLabel([0.0011, 0.0044], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('o3')) { return formatCreditsListLabel([0.01, 0.04], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('gpt-4o')) { return formatCreditsListLabel([0.0025, 0.01], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('gpt-4.1-nano')) { return formatCreditsListLabel([0.0001, 0.0004], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('gpt-4.1-mini')) { return formatCreditsListLabel([0.0004, 0.0016], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('gpt-4.1')) { return formatCreditsListLabel([0.002, 0.008], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('gpt-5-nano')) { return formatCreditsListLabel([0.00005, 0.0004], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('gpt-5-mini')) { return formatCreditsListLabel([0.00025, 0.002], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } else if (model.includes('gpt-5')) { return formatCreditsListLabel([0.00125, 0.01], { - suffix: ' per 1K tokens' + suffix: ' per 1K tokens', + approximate: true, + separator: '-' }) } return 'Token-based' From b54ed975576a5e4b22e1e0310397bb59e00afa52 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Fri, 9 Jan 2026 06:27:27 +0900 Subject: [PATCH 04/17] feat: add red dot indicator to top menu custom nodes manager button (#7896) --- src/components/TopMenuSection.vue | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/components/TopMenuSection.vue b/src/components/TopMenuSection.vue index 448a081f9..4273600aa 100644 --- a/src/components/TopMenuSection.vue +++ b/src/components/TopMenuSection.vue @@ -20,9 +20,14 @@ variant="secondary" size="icon" :aria-label="t('menu.customNodesManager')" + class="relative" @click="openCustomNodeManager" > + @@ -91,12 +96,14 @@ import Button from '@/components/ui/button/Button.vue' import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useErrorHandling } from '@/composables/useErrorHandling' import { buildTooltipConfig } from '@/composables/useTooltipConfig' +import { useReleaseStore } from '@/platform/updates/common/releaseStore' import { app } from '@/scripts/app' import { useCommandStore } from '@/stores/commandStore' import { useQueueStore, useQueueUIStore } from '@/stores/queueStore' import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore' import { useWorkspaceStore } from '@/stores/workspaceStore' import { isElectron } from '@/utils/envUtil' +import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment' import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState' import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes' @@ -111,6 +118,10 @@ const commandStore = useCommandStore() const queueStore = useQueueStore() const queueUIStore = useQueueUIStore() const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore) +const releaseStore = useReleaseStore() +const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore) +const { shouldShowRedDot: shouldShowConflictRedDot } = + useConflictAcknowledgment() const isTopMenuHovered = ref(false) const queuedCount = computed(() => queueStore.pendingTasks.length) const queueHistoryTooltipConfig = computed(() => @@ -120,6 +131,12 @@ const customNodesManagerTooltipConfig = computed(() => buildTooltipConfig(t('menu.customNodesManager')) ) +// Use either release red dot or conflict red dot +const shouldShowRedDot = computed((): boolean => { + const releaseRedDot = showReleaseRedDot.value + return releaseRedDot || shouldShowConflictRedDot.value +}) + // Right side panel toggle const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore) const rightSidePanelTooltipConfig = computed(() => From 0ca27f3d9b20dde8fc14f20b15908fc4b08d0e7a Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Fri, 9 Jan 2026 06:37:12 +0900 Subject: [PATCH 05/17] 1.37.6 (#7885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch version increment to 1.37.6 **Base branch:** `main` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7885-1-37-6-2e26d73d3650814e8b57dcdf452461e5) by [Unito](https://www.unito.io) --------- Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com> Co-authored-by: github-actions --- package.json | 2 +- src/locales/en/nodeDefs.json | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 228513ac6..ecfb08da7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@comfyorg/comfyui-frontend", "private": true, - "version": "1.37.5", + "version": "1.37.6", "type": "module", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "homepage": "https://comfy.org", diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json index c78b7c796..936547f7d 100644 --- a/src/locales/en/nodeDefs.json +++ b/src/locales/en/nodeDefs.json @@ -6088,6 +6088,9 @@ }, "ckpt_name": { "name": "ckpt_name" + }, + "device": { + "name": "device" } }, "outputs": { @@ -15365,6 +15368,51 @@ } } }, + "WanReferenceVideoApi": { + "display_name": "Wan Reference to Video", + "description": "Use the character and voice from input videos, combined with a prompt, to generate a new video that maintains character consistency.", + "inputs": { + "model": { + "name": "model" + }, + "prompt": { + "name": "prompt", + "tooltip": "Prompt describing the elements and visual features. Supports English and Chinese. Use identifiers such as `character1` and `character2` to refer to the reference characters." + }, + "negative_prompt": { + "name": "negative_prompt", + "tooltip": "Negative prompt describing what to avoid." + }, + "reference_videos": { + "name": "reference_videos" + }, + "size": { + "name": "size" + }, + "duration": { + "name": "duration" + }, + "seed": { + "name": "seed" + }, + "shot_type": { + "name": "shot_type", + "tooltip": "Specifies the shot type for the generated video, that is, whether the video is a single continuous shot or multiple shots with cuts." + }, + "watermark": { + "name": "watermark", + "tooltip": "Whether to add an AI-generated watermark to the result." + }, + "control_after_generate": { + "name": "control after generate" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WanSoundImageToVideo": { "display_name": "WanSoundImageToVideo", "inputs": { From 405e756d4c2ffad7a64708771940dafd058b95ff Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Thu, 8 Jan 2026 14:29:02 -0800 Subject: [PATCH 06/17] feat: add model download progress dialog (#7897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add a progress dialog for model downloads that appears when downloads are active. ## Changes - Add `ModelImportProgressDialog` component for showing download progress - Add `ProgressToastItem` component for individual download job display - Add `StatusBadge` component for status indicators - Extend `assetDownloadStore` with: - `finishedDownloads` computed for completed/failed jobs - `hasDownloads` computed for dialog visibility - `clearFinishedDownloads()` to dismiss finished downloads - Dialog visibility driven by store state - Closing dialog clears finished downloads - Filter dropdown to show all/completed/failed downloads - Expandable/collapsible UI with animated transitions - Update AGENTS.md with import type convention and pluralization note ## Testing - Start a model download and verify the dialog appears - Verify expand/collapse animation works - Verify filter dropdown works - Verify closing the dialog clears finished downloads - Verify dialog hides when no downloads remain ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7897-feat-add-model-download-progress-dialog-2e26d73d36508116960eff6fbe7dc392) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp --- AGENTS.md | 5 +- src/components/common/StatusBadge.vue | 30 ++ src/components/toast/ProgressToastItem.vue | 66 +++++ src/locales/en/main.json | 16 ++ .../components/ModelImportProgressDialog.vue | 271 ++++++++++++++++++ src/stores/assetDownloadStore.ts | 84 ++---- src/views/GraphView.vue | 6 +- 7 files changed, 413 insertions(+), 65 deletions(-) create mode 100644 src/components/common/StatusBadge.vue create mode 100644 src/components/toast/ProgressToastItem.vue create mode 100644 src/platform/assets/components/ModelImportProgressDialog.vue diff --git a/AGENTS.md b/AGENTS.md index 0d80ec8a0..ca0985a7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,9 @@ The project uses **Nx** for build orchestration and task management - Imports: - sorted/grouped by plugin - run `pnpm format` before committing + - use separate `import type` statements, not inline `type` in mixed imports + - ✅ `import type { Foo } from './foo'` + `import { bar } from './foo'` + - ❌ `import { bar, type Foo } from './foo'` - ESLint: - Vue + TS rules - no floating promises @@ -137,7 +140,7 @@ The project uses **Nx** for build orchestration and task management 8. Implement proper error handling 9. Follow Vue 3 style guide and naming conventions 10. Use Vite for fast development and building -11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json +11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json. Use the plurals system in i18n instead of hardcoding pluralization in templates. 12. Avoid new usage of PrimeVue components 13. Write tests for all changes, especially bug fixes to catch future regressions 14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary diff --git a/src/components/common/StatusBadge.vue b/src/components/common/StatusBadge.vue new file mode 100644 index 000000000..46ef7ac79 --- /dev/null +++ b/src/components/common/StatusBadge.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/toast/ProgressToastItem.vue b/src/components/toast/ProgressToastItem.vue new file mode 100644 index 000000000..495af2e24 --- /dev/null +++ b/src/components/toast/ProgressToastItem.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 4cdaf3636..babf0113c 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2480,5 +2480,21 @@ "help": { "recentReleases": "Recent releases", "helpCenterMenu": "Help Center Menu" + }, + "progressToast": { + "importingModels": "Importing Models", + "downloadingModel": "Downloading model...", + "downloadsFailed": "{count} downloads failed | {count} download failed | {count} downloads failed", + "allDownloadsCompleted": "All downloads completed", + "noImportsInQueue": "No {filter} in queue", + "failed": "Failed", + "finished": "Finished", + "pending": "Pending", + "progressCount": "{completed} of {total}", + "filter": { + "all": "All", + "completed": "Completed", + "failed": "Failed" + } } } \ No newline at end of file diff --git a/src/platform/assets/components/ModelImportProgressDialog.vue b/src/platform/assets/components/ModelImportProgressDialog.vue new file mode 100644 index 000000000..d5f8c8666 --- /dev/null +++ b/src/platform/assets/components/ModelImportProgressDialog.vue @@ -0,0 +1,271 @@ + + + diff --git a/src/stores/assetDownloadStore.ts b/src/stores/assetDownloadStore.ts index 8efffe0ee..91bf69015 100644 --- a/src/stores/assetDownloadStore.ts +++ b/src/stores/assetDownloadStore.ts @@ -1,13 +1,10 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' -import { useEventListener } from '@vueuse/core' -import { st } from '@/i18n' -import { useToastStore } from '@/platform/updates/common/toastStore' import type { AssetDownloadWsMessage } from '@/schemas/apiSchema' import { api } from '@/scripts/api' -interface AssetDownload { +export interface AssetDownload { taskId: string assetId: string assetName: string @@ -24,32 +21,35 @@ interface CompletedDownload { timestamp: number } -const PROGRESS_TOAST_INTERVAL_MS = 5000 const PROCESSED_TASK_CLEANUP_MS = 60000 const MAX_COMPLETED_DOWNLOADS = 10 export const useAssetDownloadStore = defineStore('assetDownload', () => { - const toastStore = useToastStore() - /** Map of task IDs to their download progress data */ - const activeDownloads = ref>(new Map()) + const downloads = ref>(new Map()) /** Map of task IDs to model types, used to track which model type to refresh after download completes */ const pendingModelTypes = new Map() - /** Map of task IDs to timestamps, used to throttle progress toast notifications */ - const lastToastTime = new Map() - /** Set of task IDs that have reached a terminal state (completed/failed), prevents duplicate processing */ const processedTaskIds = new Set() /** Reactive signal for completed downloads */ const completedDownloads = ref([]) - const hasActiveDownloads = computed(() => activeDownloads.value.size > 0) - const downloadList = computed(() => - Array.from(activeDownloads.value.values()) + const downloadList = computed(() => Array.from(downloads.value.values())) + const activeDownloads = computed(() => + downloadList.value.filter( + (d) => d.status === 'created' || d.status === 'running' + ) ) + const finishedDownloads = computed(() => + downloadList.value.filter( + (d) => d.status === 'completed' || d.status === 'failed' + ) + ) + const hasActiveDownloads = computed(() => activeDownloads.value.length > 0) + const hasDownloads = computed(() => downloads.value.size > 0) /** * Associates a download task with its model type for later use when the download completes. @@ -82,19 +82,17 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => { error: data.error } + downloads.value.set(data.task_id, download) + if (data.status === 'completed') { - activeDownloads.value.delete(data.task_id) - lastToastTime.delete(data.task_id) const modelType = pendingModelTypes.get(data.task_id) if (modelType) { - // Emit completed download signal for other stores to react to const newDownload: CompletedDownload = { taskId: data.task_id, modelType, timestamp: Date.now() } - // Keep only the last MAX_COMPLETED_DOWNLOADS items (FIFO) const updated = [...completedDownloads.value, newDownload] if (updated.length > MAX_COMPLETED_DOWNLOADS) { updated.shift() @@ -107,65 +105,31 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => { () => processedTaskIds.delete(data.task_id), PROCESSED_TASK_CLEANUP_MS ) - toastStore.add({ - severity: 'success', - summary: st('assetBrowser.download.complete', 'Download complete'), - detail: data.asset_name, - life: 5000 - }) } else if (data.status === 'failed') { - activeDownloads.value.delete(data.task_id) - lastToastTime.delete(data.task_id) pendingModelTypes.delete(data.task_id) setTimeout( () => processedTaskIds.delete(data.task_id), PROCESSED_TASK_CLEANUP_MS ) - toastStore.add({ - severity: 'error', - summary: st('assetBrowser.download.failed', 'Download failed'), - detail: data.error || data.asset_name, - life: 8000 - }) - } else { - activeDownloads.value.set(data.task_id, download) - - const now = Date.now() - const lastTime = lastToastTime.get(data.task_id) ?? 0 - const shouldShowToast = now - lastTime >= PROGRESS_TOAST_INTERVAL_MS - - if (shouldShowToast) { - lastToastTime.set(data.task_id, now) - const progressPercent = Math.round(data.progress * 100) - toastStore.add({ - severity: 'info', - summary: st('assetBrowser.download.inProgress', 'Downloading...'), - detail: `${data.asset_name} (${progressPercent}%)`, - life: PROGRESS_TOAST_INTERVAL_MS, - closable: true - }) - } } } - let stopListener: (() => void) | undefined + api.addEventListener('asset_download', handleAssetDownload) - function setup() { - stopListener = useEventListener(api, 'asset_download', handleAssetDownload) - } - - function teardown() { - stopListener?.() - stopListener = undefined + function clearFinishedDownloads() { + for (const download of finishedDownloads.value) { + downloads.value.delete(download.taskId) + } } return { activeDownloads, + finishedDownloads, hasActiveDownloads, + hasDownloads, downloadList, completedDownloads, trackDownload, - setup, - teardown + clearFinishedDownloads } }) diff --git a/src/views/GraphView.vue b/src/views/GraphView.vue index 1bf5b7e42..e95257083 100644 --- a/src/views/GraphView.vue +++ b/src/views/GraphView.vue @@ -17,6 +17,7 @@ + @@ -49,6 +50,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling' import { useProgressFavicon } from '@/composables/useProgressFavicon' import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig' import { i18n, loadLocale } from '@/i18n' +import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue' import { isCloud } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore' import { useTelemetry } from '@/platform/telemetry' @@ -60,7 +62,6 @@ import { api } from '@/scripts/api' import { app } from '@/scripts/app' import { setupAutoQueueHandler } from '@/services/autoQueueService' import { useKeybindingService } from '@/services/keybindingService' -import { useAssetDownloadStore } from '@/stores/assetDownloadStore' import { useAssetsStore } from '@/stores/assetsStore' import { useCommandStore } from '@/stores/commandStore' import { useExecutionStore } from '@/stores/executionStore' @@ -88,7 +89,6 @@ const { t } = useI18n() const toast = useToast() const settingStore = useSettingStore() const executionStore = useExecutionStore() -const assetDownloadStore = useAssetDownloadStore() const colorPaletteStore = useColorPaletteStore() const queueStore = useQueueStore() const assetsStore = useAssetsStore() @@ -256,7 +256,6 @@ onMounted(() => { api.addEventListener('reconnecting', onReconnecting) api.addEventListener('reconnected', onReconnected) executionStore.bindExecutionEvents() - assetDownloadStore.setup() try { init() @@ -273,7 +272,6 @@ onBeforeUnmount(() => { api.removeEventListener('reconnecting', onReconnecting) api.removeEventListener('reconnected', onReconnected) executionStore.unbindExecutionEvents() - assetDownloadStore.teardown() // Clean up page visibility listener if (visibilityListener) { From 3bd74dcf39f468277bfa20d44d2116534e5ba0da Mon Sep 17 00:00:00 2001 From: sno Date: Fri, 9 Jan 2026 07:46:12 +0900 Subject: [PATCH 07/17] fix: enable immediate file saving for i18n translations (#7785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the pt-BR locale generation issue by enabling immediate file persistence in the lobe-i18n configuration. ## Problem The pt-BR locale was added in PR #6943 with proper infrastructure, but translation files have remained empty (`{}`) despite the i18n workflow running successfully on version-bump PRs. ### Root Cause The `lobe-i18n` tool has a `saveImmediately` configuration option (defaults to `false`) that controls whether translations are persisted to disk immediately during generation. When bootstrapping from completely empty `{}` JSON files, without `saveImmediately: true`, the tool generates translations in memory but doesn't write them to disk, resulting in empty files. **Evidence:** - All other locales: ~1,931 lines each (previously bootstrapped) - pt-BR before fix: 1 line (`{}` in all 4 files) - CI workflow runs successfully but pt-BR files remain empty - After adding `saveImmediately: true`: 18,787 lines generated across all 4 pt-BR files ## Solution Add `saveImmediately: true` to `.i18nrc.cjs` configuration: ```javascript module.exports = defineConfig({ modelName: 'gpt-4.1', splitToken: 1024, saveImmediately: true, // ← Enables immediate file persistence entry: 'src/locales/en', entryLocale: 'en', output: 'src/locales', outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'], // ... }); ``` This ensures that when lobe-i18n generates translations from empty files, they are immediately written to disk rather than kept only in memory. ## Validation This PR's commit history demonstrates the fix works: 1. **Commit `22e6e28f5`**: Applied the `saveImmediately: true` fix 2. **Commit `cd7e93786`**: Temporarily enabled i18n workflow for this branch (for testing) 3. **Commit `84545c218`**: CI successfully generated complete pt-BR translations: - `commands.json`: 327 lines - `main.json`: 2,458 lines - `nodeDefs.json`: 15,539 lines - `settings.json`: 463 lines - **Total: 18,787 lines of Portuguese translations** 4. **Commits `85f282f98` & `05d097f7b`**: Reverted test commits to keep PR minimal ## Changes - `.i18nrc.cjs`: Added `saveImmediately: true` configuration option (+1 line) ## Impact After this fix is merged, future `version-bump-*` PRs will automatically generate and persist pt-BR translations alongside all other locales, keeping Portuguese (Brazil) translations up-to-date with the codebase. ## References - Original issue: https://github.com/Comfy-Org/ComfyUI_frontend/pull/6943#issuecomment-3679664466 - Related PR: #6943 (Portuguese (Brazil) locale addition) - lobe-i18n documentation: https://github.com/lobehub/lobe-cli-toolbox/tree/master/packages/lobe-i18n Fixes #6943 (comment) --------- Co-authored-by: github-actions Co-authored-by: Alexander Brown --- .i18nrc.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/.i18nrc.cjs b/.i18nrc.cjs index 14c958591..f0ed79099 100644 --- a/.i18nrc.cjs +++ b/.i18nrc.cjs @@ -6,6 +6,7 @@ const { defineConfig } = require('@lobehub/i18n-cli'); module.exports = defineConfig({ modelName: 'gpt-4.1', splitToken: 1024, + saveImmediately: true, entry: 'src/locales/en', entryLocale: 'en', output: 'src/locales', From eea24166e08da260652b55d9cb2df1409972ba3c Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Fri, 9 Jan 2026 07:52:49 +0900 Subject: [PATCH 08/17] Refactor/code-reivew (#7893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary https://github.com/Comfy-Org/ComfyUI_frontend/pull/7871 https://github.com/Comfy-Org/ComfyUI_frontend/pull/7858 I refactored the code based on the reviews I received on those two PRs. ## Changes - **What**: 1. Updated IconGroup to address the backgroundClass handling. 2. Replaced text-gold-600 with a semantic color token. 3. Replaced PrimeVue Icon with a lucide icon. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7893-Refactor-code-reivew-2e26d73d365081e68a44e89ed1163062) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: GitHub Action --- src/components/button/IconGroup.vue | 4 +- .../dialog/content/MissingNodesHeader.vue | 2 +- src/components/topbar/TopbarBadge.test.ts | 6 ++- src/components/topbar/TopbarBadge.vue | 4 +- .../manager/ImportFailedNodeHeader.vue | 2 +- .../manager/ManagerDialogContent.vue | 4 +- .../components/manager/NodeConflictHeader.vue | 2 +- .../PackVersionSelectorPopover.test.ts | 8 ++-- .../manager/PackVersionSelectorPopover.vue | 2 +- .../manager/button/PackEnableToggle.test.ts | 6 +-- .../manager/button/PackEnableToggle.vue | 7 +-- .../manager/button/PackInstallButton.vue | 2 +- .../composables/useConflictDetection.ts | 45 ++++++++----------- 13 files changed, 46 insertions(+), 48 deletions(-) diff --git a/src/components/button/IconGroup.vue b/src/components/button/IconGroup.vue index c533125d6..81a23c5ce 100644 --- a/src/components/button/IconGroup.vue +++ b/src/components/button/IconGroup.vue @@ -2,8 +2,8 @@
diff --git a/src/components/dialog/content/MissingNodesHeader.vue b/src/components/dialog/content/MissingNodesHeader.vue index 4dd483fd0..1e150c3d3 100644 --- a/src/components/dialog/content/MissingNodesHeader.vue +++ b/src/components/dialog/content/MissingNodesHeader.vue @@ -1,7 +1,7 @@ @@ -80,6 +81,7 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' import { useWorkspaceStore } from '@/stores/workspaceStore' import { electronAPI, isElectron } from '@/utils/envUtil' import LinearView from '@/views/LinearView.vue' +import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue' setupAutoQueueHandler() useProgressFavicon() diff --git a/src/workbench/extensions/manager/components/ManagerProgressDialogContent.test.ts b/src/workbench/extensions/manager/components/ManagerProgressDialogContent.test.ts deleted file mode 100644 index b53e0fbec..000000000 --- a/src/workbench/extensions/manager/components/ManagerProgressDialogContent.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { VueWrapper } from '@vue/test-utils' -import { mount } from '@vue/test-utils' -import { createPinia } from 'pinia' -import Button from '@/components/ui/button/Button.vue' -import PrimeVue from 'primevue/config' -import Panel from 'primevue/panel' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { nextTick } from 'vue' -import { createI18n } from 'vue-i18n' - -import enMessages from '@/locales/en/main.json' with { type: 'json' } - -import ManagerProgressDialogContent from './ManagerProgressDialogContent.vue' - -type ComponentInstance = InstanceType & { - lastPanelRef: HTMLElement | null - onLogsAdded: () => void - handleScroll: (e: { target: HTMLElement }) => void - isUserScrolling: boolean - resetUserScrolling: () => void - collapsedPanels: Record - togglePanel: (index: number) => void -} - -const mockCollapse = vi.fn() - -const defaultMockTaskLogs = [ - { taskName: 'Task 1', logs: ['Log 1', 'Log 2'] }, - { taskName: 'Task 2', logs: ['Log 3', 'Log 4'] } -] - -vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({ - useComfyManagerStore: vi.fn(() => ({ - taskLogs: [...defaultMockTaskLogs], - succeededTasksLogs: [...defaultMockTaskLogs], - failedTasksLogs: [...defaultMockTaskLogs], - managerQueue: { historyCount: 2 }, - isLoading: false - })), - useManagerProgressDialogStore: vi.fn(() => ({ - isExpanded: true, - activeTabIndex: 0, - getActiveTabIndex: vi.fn(() => 0), - setActiveTabIndex: vi.fn(), - toggle: vi.fn(), - collapse: mockCollapse, - expand: vi.fn() - })) -})) - -describe('ManagerProgressDialogContent', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCollapse.mockReset() - }) - - const mountComponent = ({ - props = {} - }: Record = {}): VueWrapper => { - const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { en: enMessages } - }) - - return mount(ManagerProgressDialogContent, { - props: { - ...props - }, - global: { - plugins: [PrimeVue, createPinia(), i18n], - components: { - Panel, - Button - } - } - }) as VueWrapper - } - - it('renders the correct number of panels', async () => { - const wrapper = mountComponent() - await nextTick() - expect(wrapper.findAllComponents(Panel).length).toBe(2) - }) - - it('expands the last panel by default', async () => { - const wrapper = mountComponent() - await nextTick() - expect(wrapper.vm.collapsedPanels[1]).toBeFalsy() - }) - - it('toggles panel expansion when toggle method is called', async () => { - const wrapper = mountComponent() - await nextTick() - - // Initial state - first panel should be collapsed - expect(wrapper.vm.collapsedPanels[0]).toBeFalsy() - - wrapper.vm.togglePanel(0) - await nextTick() - - // After toggle - first panel should be expanded - expect(wrapper.vm.collapsedPanels[0]).toBe(true) - - wrapper.vm.togglePanel(0) - await nextTick() - - expect(wrapper.vm.collapsedPanels[0]).toBeFalsy() - }) - - it('displays the correct status for each panel', async () => { - const wrapper = mountComponent() - await nextTick() - - // Expand all panels to see status text - const panels = wrapper.findAllComponents(Panel) - for (let i = 0; i < panels.length; i++) { - if (!wrapper.vm.collapsedPanels[i]) { - wrapper.vm.togglePanel(i) - await nextTick() - } - } - - const panelsText = wrapper - .findAllComponents(Panel) - .map((panel) => panel.text()) - - expect(panelsText[0]).toContain('Completed ✓') - expect(panelsText[1]).toContain('Completed ✓') - }) - - it('auto-scrolls to bottom when new logs are added', async () => { - const wrapper = mountComponent() - await nextTick() - - const mockScrollElement = document.createElement('div') - Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 }) - Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 }) - Object.defineProperty(mockScrollElement, 'scrollTop', { - value: 0, - writable: true - }) - - wrapper.vm.lastPanelRef = mockScrollElement - - wrapper.vm.onLogsAdded() - await nextTick() - - // Check if scrollTop is set to scrollHeight (scrolled to bottom) - expect(mockScrollElement.scrollTop).toBe(200) - }) - - it('does not auto-scroll when user is manually scrolling', async () => { - const wrapper = mountComponent() - await nextTick() - - const mockScrollElement = document.createElement('div') - Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 }) - Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 }) - Object.defineProperty(mockScrollElement, 'scrollTop', { - value: 50, - writable: true - }) - - wrapper.vm.lastPanelRef = mockScrollElement - - wrapper.vm.handleScroll({ target: mockScrollElement }) - await nextTick() - - expect(wrapper.vm.isUserScrolling).toBe(true) - - // Now trigger the log update - wrapper.vm.onLogsAdded() - await nextTick() - - // Check that scrollTop is not changed (should still be 50) - expect(mockScrollElement.scrollTop).toBe(50) - }) - - it('calls collapse method when component is unmounted', async () => { - const wrapper = mountComponent() - await nextTick() - wrapper.unmount() - expect(mockCollapse).toHaveBeenCalled() - }) -}) diff --git a/src/workbench/extensions/manager/components/ManagerProgressDialogContent.vue b/src/workbench/extensions/manager/components/ManagerProgressDialogContent.vue deleted file mode 100644 index 14280dfad..000000000 --- a/src/workbench/extensions/manager/components/ManagerProgressDialogContent.vue +++ /dev/null @@ -1,182 +0,0 @@ - - - diff --git a/src/workbench/extensions/manager/components/ManagerProgressFooter.test.ts b/src/workbench/extensions/manager/components/ManagerProgressFooter.test.ts deleted file mode 100644 index 024e025b8..000000000 --- a/src/workbench/extensions/manager/components/ManagerProgressFooter.test.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { mount } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' -import PrimeVue from 'primevue/config' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { nextTick } from 'vue' -import { createI18n } from 'vue-i18n' - -import { useSettingStore } from '@/platform/settings/settingStore' -import { useCommandStore } from '@/stores/commandStore' -import { useDialogStore } from '@/stores/dialogStore' -import ManagerProgressFooter from '@/workbench/extensions/manager/components/ManagerProgressFooter.vue' -import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService' -import { - useComfyManagerStore, - useManagerProgressDialogStore -} from '@/workbench/extensions/manager/stores/comfyManagerStore' -import type { TaskLog } from '@/workbench/extensions/manager/types/comfyManagerTypes' - -// Mock modules -vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore') -vi.mock('@/stores/dialogStore') -vi.mock('@/platform/settings/settingStore') -vi.mock('@/stores/commandStore') -vi.mock('@/workbench/extensions/manager/services/comfyManagerService') -vi.mock( - '@/workbench/extensions/manager/composables/useConflictDetection', - () => ({ - useConflictDetection: vi.fn(() => ({ - conflictedPackages: { value: [] }, - runFullConflictAnalysis: vi.fn().mockResolvedValue(undefined) - })) - }) -) - -// Mock useEventListener to capture the event handler -let reconnectHandler: (() => void) | null = null -vi.mock('@vueuse/core', async () => { - const actual = await vi.importActual('@vueuse/core') - return { - ...actual, - useEventListener: vi.fn( - (_target: any, event: string, handler: any, _options: any) => { - if (event === 'reconnected') { - reconnectHandler = handler - } - } - ) - } -}) -vi.mock('@/platform/workflow/core/services/workflowService', () => ({ - useWorkflowService: vi.fn(() => ({ - reloadCurrentWorkflow: vi.fn().mockResolvedValue(undefined) - })) -})) -vi.mock('@/stores/workspace/colorPaletteStore', () => ({ - useColorPaletteStore: vi.fn(() => ({ - completedActivePalette: { - light_theme: false - } - })) -})) - -// Helper function to mount component with required setup -const mountComponent = (options: { captureError?: boolean } = {}) => { - const pinia = createPinia() - setActivePinia(pinia) - - const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { - en: { - g: { - close: 'Close', - progressCountOf: 'of' - }, - contextMenu: { - Collapse: 'Collapse', - Expand: 'Expand' - }, - manager: { - clickToFinishSetup: 'Click', - applyChanges: 'Apply Changes', - toFinishSetup: 'to finish setup', - restartingBackend: 'Restarting backend to apply changes...', - extensionsSuccessfullyInstalled: - 'Extension(s) successfully installed and are ready to use!', - restartToApplyChanges: 'To apply changes, please restart ComfyUI', - installingDependencies: 'Installing dependencies...' - } - } - } - }) - - const config: any = { - global: { - plugins: [pinia, PrimeVue, i18n] - } - } - - // Add error handler for tests that expect errors - if (options.captureError) { - config.global.config = { - errorHandler: () => { - // Suppress error in test - } - } - } - - return mount(ManagerProgressFooter, config) -} - -describe('ManagerProgressFooter', () => { - const mockTaskLogs: TaskLog[] = [] - - const mockComfyManagerStore = { - taskLogs: mockTaskLogs, - allTasksDone: true, - isProcessingTasks: false, - succeededTasksIds: [] as string[], - failedTasksIds: [] as string[], - taskHistory: {} as Record, - taskQueue: null, - resetTaskState: vi.fn(), - clearLogs: vi.fn(), - setStale: vi.fn(), - // Add other required properties - isLoading: { value: false }, - error: { value: null }, - statusMessage: { value: 'DONE' }, - installedPacks: {}, - installedPacksIds: new Set(), - isPackInstalled: vi.fn(), - isPackEnabled: vi.fn(), - getInstalledPackVersion: vi.fn(), - refreshInstalledList: vi.fn(), - installPack: vi.fn(), - uninstallPack: vi.fn(), - updatePack: vi.fn(), - updateAllPacks: vi.fn(), - disablePack: vi.fn(), - enablePack: vi.fn() - } - - const mockDialogStore = { - closeDialog: vi.fn(), - // Add other required properties - dialogStack: { value: [] }, - showDialog: vi.fn(), - $id: 'dialog', - $state: {} as any, - $patch: vi.fn(), - $reset: vi.fn(), - $subscribe: vi.fn(), - $dispose: vi.fn(), - $onAction: vi.fn() - } - - const mockSettingStore = { - get: vi.fn().mockReturnValue(false), - set: vi.fn(), - // Add other required properties - settingValues: { value: {} }, - settingsById: { value: {} }, - exists: vi.fn(), - getDefaultValue: vi.fn(), - loadSettingValues: vi.fn(), - updateValue: vi.fn(), - $id: 'setting', - $state: {} as any, - $patch: vi.fn(), - $reset: vi.fn(), - $subscribe: vi.fn(), - $dispose: vi.fn(), - $onAction: vi.fn() - } - - const mockProgressDialogStore = { - isExpanded: false, - toggle: vi.fn(), - collapse: vi.fn(), - expand: vi.fn() - } - - const mockCommandStore = { - execute: vi.fn().mockResolvedValue(undefined) - } - - const mockComfyManagerService = { - rebootComfyUI: vi.fn().mockResolvedValue(null) - } - - beforeEach(() => { - vi.clearAllMocks() - // Create new pinia instance for each test - const pinia = createPinia() - setActivePinia(pinia) - - // Reset task logs - mockTaskLogs.length = 0 - mockComfyManagerStore.taskLogs = mockTaskLogs - // Reset event handler - reconnectHandler = null - - vi.mocked(useComfyManagerStore).mockReturnValue( - mockComfyManagerStore as any - ) - vi.mocked(useDialogStore).mockReturnValue(mockDialogStore as any) - vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any) - vi.mocked(useManagerProgressDialogStore).mockReturnValue( - mockProgressDialogStore as any - ) - vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any) - vi.mocked(useComfyManagerService).mockReturnValue( - mockComfyManagerService as any - ) - }) - - describe('State 1: Queue Running', () => { - it('should display loading spinner and progress counter when queue is running', async () => { - // Setup queue running state - mockComfyManagerStore.isProcessingTasks = true - mockComfyManagerStore.succeededTasksIds = ['1', '2'] - mockComfyManagerStore.failedTasksIds = [] - mockComfyManagerStore.taskHistory = { - '1': { taskName: 'Installing pack1' }, - '2': { taskName: 'Installing pack2' }, - '3': { taskName: 'Installing pack3' } - } - mockTaskLogs.push( - { taskName: 'Installing pack1', taskId: '1', logs: [] }, - { taskName: 'Installing pack2', taskId: '2', logs: [] }, - { taskName: 'Installing pack3', taskId: '3', logs: [] } - ) - - const wrapper = mountComponent() - - // Check loading spinner exists (DotSpinner component) - expect(wrapper.find('.inline-flex').exists()).toBe(true) - - // Check current task name is displayed - expect(wrapper.text()).toContain('Installing pack3') - - // Check progress counter (completed: 2 of 3) - expect(wrapper.text()).toMatch(/2.*of.*3/) - - // Check expand/collapse button exists - const expandButton = wrapper.find('[aria-label="Expand"]') - expect(expandButton.exists()).toBe(true) - - // Check Apply Changes button is NOT shown - expect(wrapper.text()).not.toContain('Apply Changes') - }) - - it('should toggle expansion when expand button is clicked', async () => { - mockComfyManagerStore.isProcessingTasks = true - mockTaskLogs.push({ taskName: 'Installing', taskId: '1', logs: [] }) - - const wrapper = mountComponent() - - const expandButton = wrapper.find('[aria-label="Expand"]') - await expandButton.trigger('click') - - expect(mockProgressDialogStore.toggle).toHaveBeenCalled() - }) - }) - - describe('State 2: Tasks Completed (Waiting for Restart)', () => { - it('should display check mark and Apply Changes button when all tasks are done', async () => { - // Setup tasks completed state - mockComfyManagerStore.isProcessingTasks = false - mockTaskLogs.push( - { taskName: 'Installed pack1', taskId: '1', logs: [] }, - { taskName: 'Installed pack2', taskId: '2', logs: [] } - ) - mockComfyManagerStore.allTasksDone = true - - const wrapper = mountComponent() - - // Check check mark emoji - expect(wrapper.text()).toContain('✅') - - // Check restart message - expect(wrapper.text()).toContain( - 'To apply changes, please restart ComfyUI' - ) - expect(wrapper.text()).toContain('Apply Changes') - - // Check Apply Changes button exists - const applyButton = wrapper - .findAll('button') - .find((btn) => btn.text().includes('Apply Changes')) - expect(applyButton).toBeTruthy() - - // Check no progress counter - expect(wrapper.text()).not.toMatch(/\d+.*of.*\d+/) - }) - }) - - describe('State 3: Restarting', () => { - it('should display restarting message and spinner during restart', async () => { - // Setup completed state first - mockComfyManagerStore.isProcessingTasks = false - mockComfyManagerStore.allTasksDone = true - - const wrapper = mountComponent() - - // Click Apply Changes to trigger restart - const applyButton = wrapper - .findAll('button') - .find((btn) => btn.text().includes('Apply Changes')) - await applyButton?.trigger('click') - - // Wait for state update - await nextTick() - - // Check restarting message - expect(wrapper.text()).toContain('Restarting backend to apply changes...') - - // Check loading spinner during restart - expect(wrapper.find('.inline-flex').exists()).toBe(true) - - // Check Apply Changes button is hidden - expect(wrapper.text()).not.toContain('Apply Changes') - }) - }) - - describe('State 4: Restart Completed', () => { - it('should display success message and auto-close after 3 seconds', async () => { - vi.useFakeTimers() - - // Setup completed state - mockComfyManagerStore.isProcessingTasks = false - mockComfyManagerStore.allTasksDone = true - - const wrapper = mountComponent() - - // Trigger restart - const applyButton = wrapper - .findAll('button') - .find((btn) => btn.text().includes('Apply Changes')) - await applyButton?.trigger('click') - - // Wait for event listener to be set up - await nextTick() - - // Trigger the reconnect handler directly - if (reconnectHandler) { - await reconnectHandler() - } - - // Wait for restart completed state - await nextTick() - - // Check success message - expect(wrapper.text()).toContain('🎉') - expect(wrapper.text()).toContain( - 'Extension(s) successfully installed and are ready to use!' - ) - - // Check dialog closes after 3 seconds - vi.advanceTimersByTime(3000) - - await nextTick() - - expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({ - key: 'global-manager-progress-dialog' - }) - expect(mockComfyManagerStore.resetTaskState).toHaveBeenCalled() - - vi.useRealTimers() - }) - }) - - describe('Common Features', () => { - it('should always display close button', async () => { - const wrapper = mountComponent() - - const closeButton = wrapper.find('[aria-label="Close"]') - expect(closeButton.exists()).toBe(true) - }) - - it('should close dialog when close button is clicked', async () => { - const wrapper = mountComponent() - - const closeButton = wrapper.find('[aria-label="Close"]') - await closeButton.trigger('click') - - expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({ - key: 'global-manager-progress-dialog' - }) - }) - }) - - describe('Toast Management', () => { - it('should suppress reconnection toasts during restart', async () => { - mockComfyManagerStore.isProcessingTasks = false - mockComfyManagerStore.allTasksDone = true - mockSettingStore.get.mockReturnValue(false) // Original setting - - const wrapper = mountComponent() - - // Click Apply Changes - const applyButton = wrapper - .findAll('button') - .find((btn) => btn.text().includes('Apply Changes')) - await applyButton?.trigger('click') - - // Check toast setting was disabled - expect(mockSettingStore.set).toHaveBeenCalledWith( - 'Comfy.Toast.DisableReconnectingToast', - true - ) - }) - - it('should restore toast settings after restart completes', async () => { - mockComfyManagerStore.isProcessingTasks = false - mockComfyManagerStore.allTasksDone = true - mockSettingStore.get.mockReturnValue(false) // Original setting - - const wrapper = mountComponent() - - // Click Apply Changes - const applyButton = wrapper - .findAll('button') - .find((btn) => btn.text().includes('Apply Changes')) - await applyButton?.trigger('click') - - // Wait for event listener to be set up - await nextTick() - - // Trigger the reconnect handler directly - if (reconnectHandler) { - await reconnectHandler() - } - - // Wait for settings restoration - await nextTick() - - expect(mockSettingStore.set).toHaveBeenCalledWith( - 'Comfy.Toast.DisableReconnectingToast', - false // Restored to original - ) - }) - }) - - describe('Error Handling', () => { - it('should restore state and close dialog on restart error', async () => { - mockComfyManagerStore.isProcessingTasks = false - mockComfyManagerStore.allTasksDone = true - - // Mock restart to throw error - mockComfyManagerService.rebootComfyUI.mockRejectedValue( - new Error('Restart failed') - ) - - const wrapper = mountComponent({ captureError: true }) - - // Click Apply Changes - const applyButton = wrapper - .findAll('button') - .find((btn) => btn.text().includes('Apply Changes')) - - expect(applyButton).toBeTruthy() - - // The component throws the error but Vue Test Utils catches it - // We need to check if the error handling logic was executed - await applyButton!.trigger('click').catch(() => { - // Error is expected, ignore it - }) - - // Wait for error handling - await nextTick() - - // Check dialog was closed on error - expect(mockDialogStore.closeDialog).toHaveBeenCalled() - // Check toast settings were restored - expect(mockSettingStore.set).toHaveBeenCalledWith( - 'Comfy.Toast.DisableReconnectingToast', - false - ) - // Check that the error handler was called - expect(mockComfyManagerService.rebootComfyUI).toHaveBeenCalled() - }) - }) -}) diff --git a/src/workbench/extensions/manager/components/ManagerProgressFooter.vue b/src/workbench/extensions/manager/components/ManagerProgressFooter.vue deleted file mode 100644 index f8fe0052d..000000000 --- a/src/workbench/extensions/manager/components/ManagerProgressFooter.vue +++ /dev/null @@ -1,195 +0,0 @@ - - - diff --git a/src/workbench/extensions/manager/components/ManagerProgressHeader.vue b/src/workbench/extensions/manager/components/ManagerProgressHeader.vue deleted file mode 100644 index 686d43436..000000000 --- a/src/workbench/extensions/manager/components/ManagerProgressHeader.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/src/workbench/extensions/manager/components/ManagerProgressToast.vue b/src/workbench/extensions/manager/components/ManagerProgressToast.vue new file mode 100644 index 000000000..3e6305b7e --- /dev/null +++ b/src/workbench/extensions/manager/components/ManagerProgressToast.vue @@ -0,0 +1,353 @@ + + + diff --git a/src/workbench/extensions/manager/composables/useManagerQueue.test.ts b/src/workbench/extensions/manager/composables/useManagerQueue.test.ts index 502867b8b..740a1e5cf 100644 --- a/src/workbench/extensions/manager/composables/useManagerQueue.test.ts +++ b/src/workbench/extensions/manager/composables/useManagerQueue.test.ts @@ -4,13 +4,6 @@ import { ref } from 'vue' import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue' import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes' -// Mock dialog service -vi.mock('@/services/dialogService', () => ({ - useDialogService: () => ({ - showManagerProgressDialog: vi.fn() - }) -})) - // Mock the app API vi.mock('@/scripts/app', () => ({ app: { diff --git a/src/workbench/extensions/manager/composables/useManagerQueue.ts b/src/workbench/extensions/manager/composables/useManagerQueue.ts index 3886b40bf..7ac796414 100644 --- a/src/workbench/extensions/manager/composables/useManagerQueue.ts +++ b/src/workbench/extensions/manager/composables/useManagerQueue.ts @@ -4,7 +4,6 @@ import type { Ref } from 'vue' import { computed, ref } from 'vue' import { app } from '@/scripts/app' -import { useDialogService } from '@/services/dialogService' import { normalizePackKeys } from '@/utils/packUtils' import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes' @@ -27,8 +26,6 @@ export const useManagerQueue = ( taskQueue: Ref, installedPacks: Ref> ) => { - const { showManagerProgressDialog } = useDialogService() - // Task queue state (read-only from server) const maxHistoryItems = ref(64) const isLoading = ref(false) @@ -113,15 +110,6 @@ export const useManagerQueue = ( (event: CustomEvent) => { if (event?.type === MANAGER_WS_TASK_DONE_NAME && event.detail?.state) { updateTaskState(event.detail.state) - - // If no more tasks are running/pending, hide the progress dialog - if (allTasksDone.value) { - setTimeout(() => { - if (allTasksDone.value) { - showManagerProgressDialog() - } - }, 1000) // Small delay to let users see completion - } } } ) @@ -133,9 +121,6 @@ export const useManagerQueue = ( (event: CustomEvent) => { if (event?.type === MANAGER_WS_TASK_STARTED_NAME && event.detail?.state) { updateTaskState(event.detail.state) - - // Show progress dialog when a task starts - showManagerProgressDialog() } } ) diff --git a/src/workbench/extensions/manager/stores/comfyManagerStore.test.ts b/src/workbench/extensions/manager/stores/comfyManagerStore.test.ts index d131a0fc9..d1754d59a 100644 --- a/src/workbench/extensions/manager/stores/comfyManagerStore.test.ts +++ b/src/workbench/extensions/manager/stores/comfyManagerStore.test.ts @@ -17,12 +17,6 @@ vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({ useComfyManagerService: vi.fn() })) -vi.mock('@/services/dialogService', () => ({ - useDialogService: () => ({ - showManagerProgressDialog: vi.fn() - }) -})) - vi.mock('@/workbench/extensions/manager/composables/useManagerQueue', () => { const enqueueTaskMock = vi.fn() diff --git a/src/workbench/extensions/manager/stores/comfyManagerStore.ts b/src/workbench/extensions/manager/stores/comfyManagerStore.ts index 06097cbe7..df6df758a 100644 --- a/src/workbench/extensions/manager/stores/comfyManagerStore.ts +++ b/src/workbench/extensions/manager/stores/comfyManagerStore.ts @@ -8,7 +8,7 @@ import { useCachedRequest } from '@/composables/useCachedRequest' import { useServerLogs } from '@/composables/useServerLogs' import { api } from '@/scripts/api' import { app } from '@/scripts/app' -import { useDialogService } from '@/services/dialogService' + import { normalizePackKeys } from '@/utils/packUtils' import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue' import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService' @@ -32,7 +32,6 @@ type UpdateAllPacksParams = components['schemas']['UpdateAllPacksParams'] export const useComfyManagerStore = defineStore('comfyManager', () => { const { t } = useI18n() const managerService = useComfyManagerService() - const { showManagerProgressDialog } = useDialogService() const installedPacks = ref({}) const enabledPacksIds = ref>(new Set()) @@ -204,8 +203,6 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { }) try { - // Show progress dialog immediately when task is queued - showManagerProgressDialog() managerQueue.isProcessing.value = true // Prepare logging hook @@ -392,44 +389,3 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { enablePack } }) - -/** - * Store for state of the manager progress dialog content. - * The dialog itself is managed by the dialog store. This store is used to - * manage the visibility of the dialog's content, header, footer. - */ -export const useManagerProgressDialogStore = defineStore( - 'managerProgressDialog', - () => { - const isExpanded = ref(false) - const activeTabIndex = ref(0) - - const setActiveTabIndex = (index: number) => { - activeTabIndex.value = index - } - - const getActiveTabIndex = () => { - return activeTabIndex.value - } - - const toggle = () => { - isExpanded.value = !isExpanded.value - } - - const collapse = () => { - isExpanded.value = false - } - - const expand = () => { - isExpanded.value = true - } - return { - isExpanded, - toggle, - collapse, - expand, - setActiveTabIndex, - getActiveTabIndex - } - } -) From a2e0c3d596dd17bbdaef10e6a03b10c229933797 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Fri, 9 Jan 2026 09:58:06 +0900 Subject: [PATCH 11/17] feature: model browser folder grouping (#7892) --- .../widget/layout/BaseModalLayout.vue | 4 +- src/components/widget/nav/NavItem.vue | 2 +- .../assets/components/AssetBadgeGroup.vue | 2 +- .../assets/components/AssetFilterBar.vue | 2 +- .../composables/useAssetBrowser.test.ts | 95 +++++++++++++++++++ .../assets/composables/useAssetBrowser.ts | 21 +++- 6 files changed, 120 insertions(+), 6 deletions(-) diff --git a/src/components/widget/layout/BaseModalLayout.vue b/src/components/widget/layout/BaseModalLayout.vue index 2e4905aab..8ff9c0a7e 100644 --- a/src/components/widget/layout/BaseModalLayout.vue +++ b/src/components/widget/layout/BaseModalLayout.vue @@ -80,7 +80,9 @@ > {{ contentTitle }} -
+
diff --git a/src/components/widget/nav/NavItem.vue b/src/components/widget/nav/NavItem.vue index 30ccbb3d9..c564dfcae 100644 --- a/src/components/widget/nav/NavItem.vue +++ b/src/components/widget/nav/NavItem.vue @@ -9,7 +9,7 @@ role="button" @click="onClick" > -
+
diff --git a/src/platform/assets/components/AssetBadgeGroup.vue b/src/platform/assets/components/AssetBadgeGroup.vue index e78fe3a60..5c85713e4 100644 --- a/src/platform/assets/components/AssetBadgeGroup.vue +++ b/src/platform/assets/components/AssetBadgeGroup.vue @@ -5,7 +5,7 @@ :key="badge.label" :class=" cn( - 'px-2 py-1 rounded text-xs font-bold uppercase tracking-wider text-modal-card-tag-foreground bg-modal-card-tag-background' + 'px-2 py-1 rounded text-xs font-bold uppercase tracking-wider text-modal-card-tag-foreground bg-modal-card-tag-background break-all' ) " > diff --git a/src/platform/assets/components/AssetFilterBar.vue b/src/platform/assets/components/AssetFilterBar.vue index 0ffcba32d..560e3138e 100644 --- a/src/platform/assets/components/AssetFilterBar.vue +++ b/src/platform/assets/components/AssetFilterBar.vue @@ -48,7 +48,7 @@ @update:model-value="handleFilterChange" >
diff --git a/src/platform/assets/composables/useAssetBrowser.test.ts b/src/platform/assets/composables/useAssetBrowser.test.ts index 9c724f32b..1661e3dbb 100644 --- a/src/platform/assets/composables/useAssetBrowser.test.ts +++ b/src/platform/assets/composables/useAssetBrowser.test.ts @@ -98,6 +98,48 @@ describe('useAssetBrowser', () => { expect(result.description).toBe('loras model') }) + + it('removes category prefix from badge labels', () => { + const apiAsset = createApiAsset({ + tags: ['models', 'checkpoint/stable-diffusion-v1-5'] + }) + + const { filteredAssets } = useAssetBrowser(ref([apiAsset])) + const result = filteredAssets.value[0] + + expect(result.badges).toContainEqual({ + label: 'stable-diffusion-v1-5', + type: 'type' + }) + }) + + it('handles tags without slash for badges', () => { + const apiAsset = createApiAsset({ + tags: ['models', 'checkpoints'] + }) + + const { filteredAssets } = useAssetBrowser(ref([apiAsset])) + const result = filteredAssets.value[0] + + expect(result.badges).toContainEqual({ + label: 'checkpoints', + type: 'type' + }) + }) + + it('handles tags with multiple slashes in badges', () => { + const apiAsset = createApiAsset({ + tags: ['models', 'checkpoint/subfolder/model-name'] + }) + + const { filteredAssets } = useAssetBrowser(ref([apiAsset])) + const result = filteredAssets.value[0] + + expect(result.badges).toContainEqual({ + label: 'subfolder/model-name', + type: 'type' + }) + }) }) describe('Tag-Based Filtering', () => { @@ -533,5 +575,58 @@ describe('useAssetBrowser', () => { selectedCategory.value = 'unknown' expect(contentTitle.value).toBe('Assets') }) + + it('groups models by top-level folder name', () => { + const assets = [ + createApiAsset({ + id: 'asset-1', + tags: ['models', 'Chatterbox/subfolder1/model1'] + }), + createApiAsset({ + id: 'asset-2', + tags: ['models', 'Chatterbox/subfolder2/model2'] + }), + createApiAsset({ + id: 'asset-3', + tags: ['models', 'Chatterbox/subfolder3/model3'] + }), + createApiAsset({ + id: 'asset-4', + tags: ['models', 'OtherFolder/subfolder1/model4'] + }) + ] + + const { availableCategories, selectedCategory, categoryFilteredAssets } = + useAssetBrowser(ref(assets)) + + // Should group all Chatterbox subfolders under single category + expect(availableCategories.value).toEqual([ + { id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' }, + { + id: 'Chatterbox', + label: 'Chatterbox', + icon: 'icon-[lucide--package]' + }, + { + id: 'OtherFolder', + label: 'OtherFolder', + icon: 'icon-[lucide--package]' + } + ]) + + // When selecting Chatterbox category, should include all models from its subfolders + selectedCategory.value = 'Chatterbox' + expect(categoryFilteredAssets.value).toHaveLength(3) + expect(categoryFilteredAssets.value.map((a) => a.id)).toEqual([ + 'asset-1', + 'asset-2', + 'asset-3' + ]) + + // When selecting OtherFolder category, should include only its models + selectedCategory.value = 'OtherFolder' + expect(categoryFilteredAssets.value).toHaveLength(1) + expect(categoryFilteredAssets.value[0].id).toBe('asset-4') + }) }) }) diff --git a/src/platform/assets/composables/useAssetBrowser.ts b/src/platform/assets/composables/useAssetBrowser.ts index 799834614..2817be81c 100644 --- a/src/platform/assets/composables/useAssetBrowser.ts +++ b/src/platform/assets/composables/useAssetBrowser.ts @@ -15,7 +15,18 @@ export type OwnershipOption = 'all' | 'my-models' | 'public-models' function filterByCategory(category: string) { return (asset: AssetItem) => { - return category === 'all' || asset.tags.includes(category) + if (category === 'all') return true + + // Check if any tag matches the category (for exact matches) + if (asset.tags.includes(category)) return true + + // Check if any tag's top-level folder matches the category + return asset.tags.some((tag) => { + if (typeof tag === 'string' && tag.includes('/')) { + return tag.split('/')[0] === category + } + return false + }) } } @@ -93,7 +104,12 @@ export function useAssetBrowser( // Type badge from non-root tag if (typeTag) { - badges.push({ label: typeTag, type: 'type' }) + // Remove category prefix from badge label (e.g. "checkpoint/model" → "model") + const badgeLabel = typeTag.includes('/') + ? typeTag.substring(typeTag.indexOf('/') + 1) + : typeTag + + badges.push({ label: badgeLabel, type: 'type' }) } // Base model badge from metadata @@ -125,6 +141,7 @@ export function useAssetBrowser( .filter((asset) => asset.tags[0] === 'models') .map((asset) => asset.tags[1]) .filter((tag): tag is string => typeof tag === 'string' && tag.length > 0) + .map((tag) => tag.split('/')[0]) // Extract top-level folder name const uniqueCategories = Array.from(new Set(categories)) .sort() From 9e434a1002d12007d55e12d44942ae884a5d790b Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Fri, 9 Jan 2026 02:40:15 +0100 Subject: [PATCH 12/17] fix: replace text-white with theme-aware color tokens (#7908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace hardcoded `text-white` class with theme-aware alternatives to fix invisible text on light themes - Update Load3D control backgrounds to use semantic tokens - Update dropdown menus to use `bg-interface-menu-surface` - Update overlay backgrounds to use `bg-backdrop` with opacity ## Changes | Component | Old | New | |-----------|-----|-----| | Text on primary bg | `text-white` | `text-base-foreground` | | Dropdown menus | `bg-black/50` | `bg-interface-menu-surface` | | Control panels | `bg-smoke-700/30` | `bg-backdrop/30` | | Loading overlays | `bg-black bg-opacity-50` | `bg-backdrop/50` | | Selected states | `bg-smoke-600` | `bg-button-active-surface` | ## Files Modified (14) - `src/components/TopMenuSection.vue` - `src/components/input/MultiSelect.vue` - `src/components/load3d/*.vue` (12 files) - `src/renderer/extensions/vueNodes/VideoPreview.vue` ## Test plan - [ ] Verify text visibility in light theme - [ ] Verify text visibility in dark theme - [ ] Test Load3D viewer controls functionality - [ ] Test MultiSelect dropdown checkbox visibility ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7908-fix-replace-text-white-with-theme-aware-color-tokens-2e26d73d36508107bb01d1d6e3b74f6a) by [Unito](https://www.unito.io) --- src/components/TopMenuSection.vue | 2 +- src/components/input/MultiSelect.vue | 2 +- src/components/load3d/Load3DControls.vue | 12 ++++++------ src/components/load3d/LoadingOverlay.vue | 4 ++-- .../load3d/controls/AnimationControls.vue | 4 ++-- .../load3d/controls/CameraControls.vue | 2 +- .../load3d/controls/ExportControls.vue | 6 +++--- .../load3d/controls/LightControls.vue | 2 +- .../load3d/controls/ModelControls.vue | 17 ++++++++++------- src/components/load3d/controls/PopupSlider.vue | 4 ++-- .../load3d/controls/RecordingControls.vue | 10 +++++----- .../load3d/controls/SceneControls.vue | 10 +++++----- .../load3d/controls/ViewerControls.vue | 4 ++-- .../extensions/vueNodes/VideoPreview.vue | 13 +++++++++---- 14 files changed, 50 insertions(+), 42 deletions(-) diff --git a/src/components/TopMenuSection.vue b/src/components/TopMenuSection.vue index 4273600aa..7cdd5418a 100644 --- a/src/components/TopMenuSection.vue +++ b/src/components/TopMenuSection.vue @@ -54,7 +54,7 @@ {{ queuedCount }} diff --git a/src/components/input/MultiSelect.vue b/src/components/input/MultiSelect.vue index aaa372d73..21ba0d6a2 100644 --- a/src/components/input/MultiSelect.vue +++ b/src/components/input/MultiSelect.vue @@ -160,7 +160,7 @@ >
diff --git a/src/components/load3d/Load3DControls.vue b/src/components/load3d/Load3DControls.vue index 1609f39ea..68251c741 100644 --- a/src/components/load3d/Load3DControls.vue +++ b/src/components/load3d/Load3DControls.vue @@ -1,6 +1,6 @@