Compare commits

..

2 Commits

Author SHA1 Message Date
Benjamin Lu
20bc16d0cb fix: simplify and deduplicate output selection counting 2026-02-07 15:41:14 -08:00
Terry Jia
ad6f856a31 fix: terminal tabs fail to register due to useI18n() after await (#8717)
## Summary
useI18n() requires an active Vue component instance via
getCurrentInstance(), which returns null after an await in the setup
context. Since terminal tabs are loaded via dynamic import (await),
useI18n() was silently failing, preventing terminal tab registration.

Replace useI18n() calls with static English strings for the title field,
which is only used in command labels. The titleKey field already handles
reactive i18n in the UI via BottomPanel.vue's getTabDisplayTitle().

fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/8624

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/44e0118e-5566-4299-84cf-72b63d85521a


after


https://github.com/user-attachments/assets/3e99fb81-7a81-4065-a889-3ab5a393d8cf

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8717-fix-terminal-tabs-fail-to-register-due-to-useI18n-after-await-3006d73d3650810fb011c08862434bd5)
by [Unito](https://www.unito.io)
2026-02-07 12:45:07 -08:00
6 changed files with 520 additions and 25 deletions

View File

@@ -0,0 +1,314 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const mocks = vi.hoisted(() => ({
shiftKey: { value: true },
ctrlKey: { value: false },
metaKey: { value: false },
outputMedia: [] as AssetItem[]
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
useDebounceFn: <T extends (...args: never[]) => unknown>(fn: T) => fn,
useElementHover: () => ref(false),
useResizeObserver: () => undefined,
useStorage: <T>(_key: string, initialValue: T) => ref(initialValue),
useKeyModifier: (key: string) => {
if (key === 'Shift') return mocks.shiftKey
if (key === 'Control') return mocks.ctrlKey
if (key === 'Meta') return mocks.metaKey
return ref(false)
}
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key,
n: (value: number) => String(value)
}),
createI18n: () => ({
global: {
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}
})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: vi.fn()
})
}))
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
useMediaAssets: (type: 'input' | 'output') => ({
loading: ref(false),
error: ref(null),
media: ref(type === 'output' ? mocks.outputMedia : []),
fetchMediaList: vi.fn(async () => {}),
hasMore: ref(false),
isLoadingMore: ref(false),
loadMore: vi.fn(async () => {})
})
}))
vi.mock('@/platform/assets/composables/useMediaAssetFiltering', () => ({
useMediaAssetFiltering: (baseAssets: { value: AssetItem[] }) => ({
searchQuery: ref(''),
sortBy: ref('newest'),
mediaTypeFilters: ref([]),
filteredAssets: computed(() => baseAssets.value)
})
}))
vi.mock('@/platform/assets/composables/useOutputStacks', () => ({
useOutputStacks: ({ assets }: { assets: { value: AssetItem[] } }) => ({
assetItems: computed(() =>
assets.value.map((asset) => ({ key: asset.id, asset }))
),
selectableAssets: computed(() => assets.value),
isStackExpanded: () => false,
toggleStack: vi.fn(async () => {})
})
}))
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => ({
downloadMultipleAssets: vi.fn(),
deleteAssets: vi.fn(async () => true),
addMultipleToWorkflow: vi.fn(async () => {}),
openMultipleWorkflows: vi.fn(async () => {}),
exportMultipleWorkflows: vi.fn(async () => {})
})
}))
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
getAssetType: (tags: unknown) =>
Array.isArray(tags) && tags.includes('output') ? 'output' : 'input'
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: () => false
})
}))
vi.mock('@/stores/queueStore', () => {
class ResultItemImpl {
filename: string
subfolder: string
type: string
nodeId: string
mediaType: string
constructor({
filename,
subfolder,
type,
nodeId,
mediaType
}: {
filename: string
subfolder: string
type: string
nodeId: string
mediaType: string
}) {
this.filename = filename
this.subfolder = subfolder
this.type = type
this.nodeId = nodeId
this.mediaType = mediaType
}
}
return {
useQueueStore: () => ({
activeJobsCount: ref(0),
pendingTasks: []
}),
ResultItemImpl
}
})
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn(async () => {})
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: vi.fn()
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
clearInitializationByPromptIds: vi.fn()
})
}))
vi.mock('@/utils/formatUtil', () => ({
formatDuration: (duration: number) => `${duration}ms`,
getMediaTypeFromFilename: () => 'image'
}))
vi.mock('@/utils/tailwindUtil', () => ({
cn: (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(' ')
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
resolveOutputAssetItems: vi.fn(async () => [])
}
})
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
const sidebarTabTemplateStub = {
template: `
<div>
<slot name="alt-title" />
<slot name="tool-buttons" />
<slot name="header" />
<slot name="body" />
<slot name="footer" />
</div>
`
}
const buttonStub = {
template: '<button @click="$emit(\'click\', $event)"><slot /></button>'
}
const assetsSidebarGridViewStub = {
props: {
assets: {
type: Array,
required: true
}
},
template: `
<div>
<button
v-for="asset in assets"
:key="asset.id"
:data-testid="'asset-' + asset.id"
@click.stop="$emit('select-asset', asset, assets)"
>
{{ asset.name }}
</button>
</div>
`
}
function createAsset(
id: string,
name: string,
userMetadata?: Record<string, unknown>
): AssetItem {
return {
id,
name,
tags: ['output'],
user_metadata: userMetadata
}
}
describe('AssetsSidebarTab', () => {
beforeEach(() => {
setActivePinia(createPinia())
mocks.shiftKey.value = true
mocks.ctrlKey.value = false
mocks.metaKey.value = false
mocks.outputMedia = []
})
it('shows deduplicated selected count for parent stack and selected children', async () => {
const outputs = [
{
filename: 'parent.png',
nodeId: '1',
subfolder: 'outputs',
url: 'https://example.com/parent.png'
}
]
const parent = createAsset('parent', 'parent.png', {
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'outputs',
outputCount: 4,
allOutputs: outputs
})
const child1 = createAsset('child-1', 'child-1.png', {
promptId: 'prompt-1',
nodeId: '2',
subfolder: 'outputs'
})
const child2 = createAsset('child-2', 'child-2.png', {
promptId: 'prompt-1',
nodeId: '3',
subfolder: 'outputs'
})
const child3 = createAsset('child-3', 'child-3.png', {
promptId: 'prompt-1',
nodeId: '4',
subfolder: 'outputs'
})
mocks.outputMedia = [parent, child1, child2, child3]
const wrapper = mount(AssetsSidebarTab, {
global: {
stubs: {
SidebarTabTemplate: sidebarTabTemplateStub,
Button: buttonStub,
TabList: true,
Tab: true,
Divider: true,
ProgressSpinner: true,
NoResultsPlaceholder: true,
MediaAssetFilterBar: true,
AssetsSidebarListView: true,
AssetsSidebarGridView: assetsSidebarGridViewStub,
ResultGallery: true,
MediaAssetContextMenu: true
},
mocks: {
$t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}
}
})
await wrapper.find('[data-testid="asset-parent"]').trigger('click')
await wrapper.find('[data-testid="asset-child-3"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain(
'mediaAsset.selection.selectedCount:{"count":4}'
)
expect(wrapper.text()).not.toContain(
'mediaAsset.selection.selectedCount:{"count":7}'
)
})
})

View File

@@ -1,15 +1,13 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue'
import CommandTerminal from '@/components/bottomPanel/tabs/terminal/CommandTerminal.vue'
import type { BottomPanelExtension } from '@/types/extensionTypes'
export function useLogsTerminalTab(): BottomPanelExtension {
const { t } = useI18n()
return {
id: 'logs-terminal',
title: t('g.logs'),
title: 'Logs',
titleKey: 'g.logs',
component: markRaw(LogsTerminal),
type: 'vue'
@@ -17,10 +15,9 @@ export function useLogsTerminalTab(): BottomPanelExtension {
}
export function useCommandTerminalTab(): BottomPanelExtension {
const { t } = useI18n()
return {
id: 'command-terminal',
title: t('g.terminal'),
title: 'Terminal',
titleKey: 'g.terminal',
component: markRaw(CommandTerminal),
type: 'vue'

View File

@@ -95,9 +95,7 @@ function dynamicComboWidget(
const newSpec = value ? options[value] : undefined
const removedInputs = remove(node.inputs, isInGroup)
const removedWidgets = remove(node.widgets, isInGroup)
for (const widget of removedWidgets) widget.onRemove?.()
for (const widget of remove(node.widgets, isInGroup)) widget.onRemove?.()
if (!newSpec) return
@@ -176,18 +174,6 @@ function dynamicComboWidget(
)
}
}
for (const widget of removedWidgets) {
const newMatchingWidget = addedWidgets.find((w) => w.name === widget.name)
if (!newMatchingWidget || newMatchingWidget.type !== widget.type) continue
const nameSegment = widget.name.slice(widget.name.lastIndexOf('.') + 1)
const spec =
newSpec.required?.[nameSegment] ?? newSpec.optional?.[nameSegment]
if (spec?.[1] && 'default' in spec?.[1]) continue
newMatchingWidget.value = widget.value
newMatchingWidget.callback?.(widget.value)
}
node.size[1] = node.computeSize([...node.size])[1]
if (!node.graph) return

View File

@@ -36,6 +36,39 @@ function createMockAssets(count: number): AssetItem[] {
}))
}
type OutputStub = {
filename: string
nodeId: string
subfolder: string
url: string
}
function createOutputAsset(
id: string,
name: string,
metadata: Record<string, unknown>
): AssetItem {
return {
id,
name,
tags: ['output'],
user_metadata: metadata
}
}
function createOutput(
filename: string,
nodeId: string,
subfolder: string
): OutputStub {
return {
filename,
nodeId,
subfolder,
url: `https://example.com/${filename}`
}
}
describe('useAssetSelection', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -272,4 +305,80 @@ describe('useAssetSelection', () => {
expect(selected[0].id).toBe('asset-1')
})
})
describe('getTotalOutputCount', () => {
it('deduplicates overlapping parent stack and selected children with partial parent outputs', () => {
const { getTotalOutputCount } = useAssetSelection()
const outputs = [createOutput('parent.png', '1', 'outputs')]
const parent = createOutputAsset('parent', 'parent.png', {
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'outputs',
outputCount: 4,
allOutputs: outputs
})
const child1 = createOutputAsset('child-1', 'child-1.png', {
promptId: 'prompt-1',
nodeId: '2',
subfolder: 'outputs'
})
const child2 = createOutputAsset('child-2', 'child-2.png', {
promptId: 'prompt-1',
nodeId: '3',
subfolder: 'outputs'
})
const child3 = createOutputAsset('child-3', 'child-3.png', {
promptId: 'prompt-1',
nodeId: '4',
subfolder: 'outputs'
})
expect(getTotalOutputCount([parent, child1, child2, child3])).toBe(4)
})
it('deduplicates when parent includes full output list', () => {
const { getTotalOutputCount } = useAssetSelection()
const parent = createOutputAsset('parent', 'parent.png', {
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'outputs',
outputCount: 4,
allOutputs: [
createOutput('parent.png', '1', 'outputs'),
createOutput('child-1.png', '2', 'outputs'),
createOutput('child-2.png', '3', 'outputs'),
createOutput('child-3.png', '4', 'outputs')
]
})
const child = createOutputAsset('child-1', 'child-1.png', {
promptId: 'prompt-1',
nodeId: '2',
subfolder: 'outputs'
})
expect(getTotalOutputCount([parent, child])).toBe(4)
})
it('falls back to outputCount when only unresolved stack count exists', () => {
const { getTotalOutputCount } = useAssetSelection()
const parent = createOutputAsset('parent', 'parent.png', {
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'outputs',
outputCount: 4
})
expect(getTotalOutputCount([parent])).toBe(4)
})
it('counts non-output metadata assets as one each', () => {
const { getTotalOutputCount } = useAssetSelection()
const assetA: AssetItem = { id: 'a', name: 'a.png', tags: ['input'] }
const assetB: AssetItem = { id: 'b', name: 'b.png', tags: ['input'] }
expect(getTotalOutputCount([assetA, assetB])).toBe(2)
})
})
})

View File

@@ -1,9 +1,71 @@
import { useKeyModifier } from '@vueuse/core'
import { computed, ref } from 'vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getOutputKey } from '@/platform/assets/utils/outputAssetUtil'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
type PromptSelection = {
expectedOutputCount: number
outputKeys: Set<string>
}
function getPromptSelection(
selectionsByPromptId: Map<string, PromptSelection>,
promptId: string
): PromptSelection {
const existingSelection = selectionsByPromptId.get(promptId)
if (existingSelection) {
return existingSelection
}
const selection: PromptSelection = {
expectedOutputCount: 0,
outputKeys: new Set<string>()
}
selectionsByPromptId.set(promptId, selection)
return selection
}
function updateExpectedOutputCount(
selection: PromptSelection,
outputCount: OutputAssetMetadata['outputCount']
) {
if (
typeof outputCount === 'number' &&
outputCount > selection.expectedOutputCount
) {
selection.expectedOutputCount = outputCount
}
}
function resolveOutputKeysForSelection(
asset: AssetItem,
metadata: OutputAssetMetadata
): string[] {
const allOutputKeys = (metadata.allOutputs ?? [])
.map((output) => getOutputKey(output))
.filter((key): key is string => key !== null)
if (allOutputKeys.length > 0) {
return allOutputKeys
}
const assetOutputKey = getOutputKey({
nodeId: metadata.nodeId,
subfolder: metadata.subfolder,
filename: asset.name
})
return [assetOutputKey ?? `asset:${asset.id}`]
}
function getPromptSelectionCount(selection: PromptSelection): number {
return Math.max(selection.outputKeys.size, selection.expectedOutputCount)
}
export function useAssetSelection() {
const selectionStore = useAssetSelectionStore()
@@ -150,7 +212,38 @@ export function useAssetSelection() {
* Get the total output count for given assets
*/
function getTotalOutputCount(assets: AssetItem[]): number {
return assets.reduce((sum, asset) => sum + getOutputCount(asset), 0)
const nonOutputAssetIds = new Set<string>()
const promptSelectionsByPromptId = new Map<string, PromptSelection>()
for (const asset of assets) {
const outputMetadata = getOutputAssetMetadata(asset.user_metadata)
const promptId = outputMetadata?.promptId
if (!promptId) {
nonOutputAssetIds.add(asset.id)
continue
}
const promptSelection = getPromptSelection(
promptSelectionsByPromptId,
promptId
)
updateExpectedOutputCount(promptSelection, outputMetadata.outputCount)
for (const outputKey of resolveOutputKeysForSelection(
asset,
outputMetadata
)) {
promptSelection.outputKeys.add(outputKey)
}
}
let totalOutputCount = nonOutputAssetIds.size
for (const selection of promptSelectionsByPromptId.values()) {
totalOutputCount += getPromptSelectionCount(selection)
}
return totalOutputCount
}
/**

View File

@@ -24,10 +24,6 @@ function onValueChange(this: INumericWidget, v: number) {
// Round to nearest step, accounting for offset
this.value = Math.round((v - offset) / step) * step + offset
}
if (this.options.min !== undefined && this.value < this.options.min)
this.value = this.options.min
if (this.options.max !== undefined && this.value > this.options.max)
this.value = this.options.max
}
export const _for_testing = {