diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts
index 30b0da08e..ea5a0b78f 100644
--- a/browser_tests/fixtures/utils/litegraphUtils.ts
+++ b/browser_tests/fixtures/utils/litegraphUtils.ts
@@ -119,8 +119,7 @@ class NodeSlotReference {
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
// Debug logging - convert Float64Arrays to regular arrays for visibility
- // eslint-disable-next-line no-console
- console.log(
+ console.warn(
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
{
nodePos: [node.pos[0], node.pos[1]],
diff --git a/browser_tests/tests/featureFlags.spec.ts b/browser_tests/tests/featureFlags.spec.ts
index 707976829..9155ae910 100644
--- a/browser_tests/tests/featureFlags.spec.ts
+++ b/browser_tests/tests/featureFlags.spec.ts
@@ -27,7 +27,7 @@ test.describe('Feature Flags', () => {
try {
const parsed = JSON.parse(data)
if (parsed.type === 'feature_flags') {
- window.__capturedMessages.clientFeatureFlags = parsed
+ window.__capturedMessages!.clientFeatureFlags = parsed
}
} catch (e) {
// Not JSON, ignore
@@ -41,7 +41,7 @@ test.describe('Feature Flags', () => {
window['app']?.api?.serverFeatureFlags &&
Object.keys(window['app'].api.serverFeatureFlags).length > 0
) {
- window.__capturedMessages.serverFeatureFlags =
+ window.__capturedMessages!.serverFeatureFlags =
window['app'].api.serverFeatureFlags
clearInterval(checkInterval)
}
@@ -57,8 +57,8 @@ test.describe('Feature Flags', () => {
// Wait for both client and server feature flags
await newPage.waitForFunction(
() =>
- window.__capturedMessages.clientFeatureFlags !== null &&
- window.__capturedMessages.serverFeatureFlags !== null,
+ window.__capturedMessages!.clientFeatureFlags !== null &&
+ window.__capturedMessages!.serverFeatureFlags !== null,
{ timeout: 10000 }
)
@@ -66,27 +66,27 @@ test.describe('Feature Flags', () => {
const messages = await newPage.evaluate(() => window.__capturedMessages)
// Verify client sent feature flags
- expect(messages.clientFeatureFlags).toBeTruthy()
- expect(messages.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
- expect(messages.clientFeatureFlags).toHaveProperty('data')
- expect(messages.clientFeatureFlags.data).toHaveProperty(
+ expect(messages!.clientFeatureFlags).toBeTruthy()
+ expect(messages!.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
+ expect(messages!.clientFeatureFlags).toHaveProperty('data')
+ expect(messages!.clientFeatureFlags!.data).toHaveProperty(
'supports_preview_metadata'
)
expect(
- typeof messages.clientFeatureFlags.data.supports_preview_metadata
+ typeof messages!.clientFeatureFlags!.data.supports_preview_metadata
).toBe('boolean')
// Verify server sent feature flags back
- expect(messages.serverFeatureFlags).toBeTruthy()
- expect(messages.serverFeatureFlags).toHaveProperty(
+ expect(messages!.serverFeatureFlags).toBeTruthy()
+ expect(messages!.serverFeatureFlags).toHaveProperty(
'supports_preview_metadata'
)
- expect(typeof messages.serverFeatureFlags.supports_preview_metadata).toBe(
+ expect(typeof messages!.serverFeatureFlags!.supports_preview_metadata).toBe(
'boolean'
)
- expect(messages.serverFeatureFlags).toHaveProperty('max_upload_size')
- expect(typeof messages.serverFeatureFlags.max_upload_size).toBe('number')
- expect(Object.keys(messages.serverFeatureFlags).length).toBeGreaterThan(0)
+ expect(messages!.serverFeatureFlags).toHaveProperty('max_upload_size')
+ expect(typeof messages!.serverFeatureFlags!.max_upload_size).toBe('number')
+ expect(Object.keys(messages!.serverFeatureFlags!).length).toBeGreaterThan(0)
await newPage.close()
})
@@ -96,7 +96,7 @@ test.describe('Feature Flags', () => {
}) => {
// Get the actual server feature flags from the backend
const serverFlags = await comfyPage.page.evaluate(() => {
- return window['app'].api.serverFeatureFlags
+ return window['app']!.api.serverFeatureFlags
})
// Verify we received real feature flags from the backend
@@ -115,7 +115,7 @@ test.describe('Feature Flags', () => {
}) => {
// Test serverSupportsFeature with real backend flags
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
- return window['app'].api.serverSupportsFeature(
+ return window['app']!.api.serverSupportsFeature(
'supports_preview_metadata'
)
})
@@ -124,15 +124,17 @@ test.describe('Feature Flags', () => {
// Test non-existent feature - should always return false
const supportsNonExistent = await comfyPage.page.evaluate(() => {
- return window['app'].api.serverSupportsFeature('non_existent_feature_xyz')
+ return window['app']!.api.serverSupportsFeature(
+ 'non_existent_feature_xyz'
+ )
})
expect(supportsNonExistent).toBe(false)
// Test that the method only returns true for boolean true values
const testResults = await comfyPage.page.evaluate(() => {
// Temporarily modify serverFeatureFlags to test behavior
- const original = window['app'].api.serverFeatureFlags
- window['app'].api.serverFeatureFlags = {
+ const original = window['app']!.api.serverFeatureFlags
+ window['app']!.api.serverFeatureFlags = {
bool_true: true,
bool_false: false,
string_value: 'yes',
@@ -141,15 +143,15 @@ test.describe('Feature Flags', () => {
}
const results = {
- bool_true: window['app'].api.serverSupportsFeature('bool_true'),
- bool_false: window['app'].api.serverSupportsFeature('bool_false'),
- string_value: window['app'].api.serverSupportsFeature('string_value'),
- number_value: window['app'].api.serverSupportsFeature('number_value'),
- null_value: window['app'].api.serverSupportsFeature('null_value')
+ bool_true: window['app']!.api.serverSupportsFeature('bool_true'),
+ bool_false: window['app']!.api.serverSupportsFeature('bool_false'),
+ string_value: window['app']!.api.serverSupportsFeature('string_value'),
+ number_value: window['app']!.api.serverSupportsFeature('number_value'),
+ null_value: window['app']!.api.serverSupportsFeature('null_value')
}
// Restore original
- window['app'].api.serverFeatureFlags = original
+ window['app']!.api.serverFeatureFlags = original
return results
})
@@ -166,20 +168,20 @@ test.describe('Feature Flags', () => {
}) => {
// Test getServerFeature method
const previewMetadataValue = await comfyPage.page.evaluate(() => {
- return window['app'].api.getServerFeature('supports_preview_metadata')
+ return window['app']!.api.getServerFeature('supports_preview_metadata')
})
expect(typeof previewMetadataValue).toBe('boolean')
// Test getting max_upload_size
const maxUploadSize = await comfyPage.page.evaluate(() => {
- return window['app'].api.getServerFeature('max_upload_size')
+ return window['app']!.api.getServerFeature('max_upload_size')
})
expect(typeof maxUploadSize).toBe('number')
expect(maxUploadSize).toBeGreaterThan(0)
// Test getServerFeature with default value for non-existent feature
const defaultValue = await comfyPage.page.evaluate(() => {
- return window['app'].api.getServerFeature(
+ return window['app']!.api.getServerFeature(
'non_existent_feature_xyz',
'default'
)
@@ -192,7 +194,7 @@ test.describe('Feature Flags', () => {
}) => {
// Test getServerFeatures returns all flags
const allFeatures = await comfyPage.page.evaluate(() => {
- return window['app'].api.getServerFeatures()
+ return window['app']!.api.getServerFeatures()
})
expect(allFeatures).toBeTruthy()
@@ -205,14 +207,14 @@ test.describe('Feature Flags', () => {
test('Client feature flags are immutable', async ({ comfyPage }) => {
// Test that getClientFeatureFlags returns a copy
const immutabilityTest = await comfyPage.page.evaluate(() => {
- const flags1 = window['app'].api.getClientFeatureFlags()
- const flags2 = window['app'].api.getClientFeatureFlags()
+ const flags1 = window['app']!.api.getClientFeatureFlags()
+ const flags2 = window['app']!.api.getClientFeatureFlags()
// Modify the first object
flags1.test_modification = true
// Get flags again to check if original was modified
- const flags3 = window['app'].api.getClientFeatureFlags()
+ const flags3 = window['app']!.api.getClientFeatureFlags()
return {
areEqual: flags1 === flags2,
@@ -238,14 +240,14 @@ test.describe('Feature Flags', () => {
}) => {
const immutabilityTest = await comfyPage.page.evaluate(() => {
// Get a copy of server features
- const features1 = window['app'].api.getServerFeatures()
+ const features1 = window['app']!.api.getServerFeatures()
// Try to modify it
features1.supports_preview_metadata = false
features1.new_feature = 'added'
// Get another copy
- const features2 = window['app'].api.getServerFeatures()
+ const features2 = window['app']!.api.getServerFeatures()
return {
modifiedValue: features1.supports_preview_metadata,
@@ -274,7 +276,8 @@ test.describe('Feature Flags', () => {
// Set up monitoring before navigation
await newPage.addInitScript(() => {
// Track when various app components are ready
- ;(window as any).__appReadiness = {
+
+ window.__appReadiness = {
featureFlagsReceived: false,
apiInitialized: false,
appInitialized: false
@@ -286,7 +289,10 @@ test.describe('Feature Flags', () => {
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined
) {
- ;(window as any).__appReadiness.featureFlagsReceived = true
+ window.__appReadiness = {
+ ...window.__appReadiness,
+ featureFlagsReceived: true
+ }
clearInterval(checkFeatureFlags)
}
}, 10)
@@ -294,7 +300,10 @@ test.describe('Feature Flags', () => {
// Monitor API initialization
const checkApi = setInterval(() => {
if (window['app']?.api) {
- ;(window as any).__appReadiness.apiInitialized = true
+ window.__appReadiness = {
+ ...window.__appReadiness,
+ apiInitialized: true
+ }
clearInterval(checkApi)
}
}, 10)
@@ -302,7 +311,10 @@ test.describe('Feature Flags', () => {
// Monitor app initialization
const checkApp = setInterval(() => {
if (window['app']?.graph) {
- ;(window as any).__appReadiness.appInitialized = true
+ window.__appReadiness = {
+ ...window.__appReadiness,
+ appInitialized: true
+ }
clearInterval(checkApp)
}
}, 10)
@@ -331,8 +343,8 @@ test.describe('Feature Flags', () => {
// Get readiness state
const readiness = await newPage.evaluate(() => {
return {
- ...(window as any).__appReadiness,
- currentFlags: window['app'].api.serverFeatureFlags
+ ...window.__appReadiness,
+ currentFlags: window['app']!.api.serverFeatureFlags
}
})
diff --git a/browser_tests/tests/nodeHelp.spec.ts b/browser_tests/tests/nodeHelp.spec.ts
index cfb04bc46..709c19a4b 100644
--- a/browser_tests/tests/nodeHelp.spec.ts
+++ b/browser_tests/tests/nodeHelp.spec.ts
@@ -2,15 +2,17 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
+import type { ComfyPage } from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
+import type { NodeReference } from '../fixtures/utils/litegraphUtils'
// TODO: there might be a better solution for this
// Helper function to pan canvas and select node
-async function selectNodeWithPan(comfyPage: any, nodeRef: any) {
+async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
const nodePos = await nodeRef.getPosition()
await comfyPage.page.evaluate((pos) => {
- const app = window['app']
+ const app = window['app']!
const canvas = app.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
@@ -345,7 +347,7 @@ This is documentation for a custom node.
// Find and select a custom/group node
const nodeRefs = await comfyPage.page.evaluate(() => {
- return window['app'].graph.nodes.map((n: any) => n.id)
+ return window['app']!.graph!.nodes.map((n) => n.id)
})
if (nodeRefs.length > 0) {
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
diff --git a/browser_tests/tests/selectionToolboxSubmenus.spec.ts b/browser_tests/tests/selectionToolboxSubmenus.spec.ts
index f526b07e0..fd4c55faf 100644
--- a/browser_tests/tests/selectionToolboxSubmenus.spec.ts
+++ b/browser_tests/tests/selectionToolboxSubmenus.spec.ts
@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
+import type { ComfyPage } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -15,7 +16,7 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
await comfyPage.nextFrame()
})
- const openMoreOptions = async (comfyPage: any) => {
+ const openMoreOptions = async (comfyPage: ComfyPage) => {
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts
index 315758f5c..9b007afb2 100644
--- a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts
+++ b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts
@@ -419,7 +419,7 @@ test.describe('Vue Node Link Interaction', () => {
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
- const app = (window as any)['app']
+ const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)
@@ -505,7 +505,7 @@ test.describe('Vue Node Link Interaction', () => {
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
- const app = (window as any)['app']
+ const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)
diff --git a/package.json b/package.json
index 8f240b1c5..810d73c9e 100644
--- a/package.json
+++ b/package.json
@@ -169,6 +169,7 @@
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"glob": "^11.0.3",
+ "jsonata": "catalog:",
"jsondiffpatch": "^0.6.0",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts
index ac15a78b1..75d8b8db2 100644
--- a/packages/shared-frontend-utils/src/formatUtil.test.ts
+++ b/packages/shared-frontend-utils/src/formatUtil.test.ts
@@ -120,8 +120,8 @@ describe('formatUtil', () => {
})
it('should handle null and undefined gracefully', () => {
- expect(getMediaTypeFromFilename(null as any)).toBe('image')
- expect(getMediaTypeFromFilename(undefined as any)).toBe('image')
+ expect(getMediaTypeFromFilename(null)).toBe('image')
+ expect(getMediaTypeFromFilename(undefined)).toBe('image')
})
it('should handle special characters in filenames', () => {
diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts
index 032d1c9ed..fde399eb9 100644
--- a/packages/shared-frontend-utils/src/formatUtil.ts
+++ b/packages/shared-frontend-utils/src/formatUtil.ts
@@ -537,7 +537,9 @@ export function truncateFilename(
* @param filename The filename to analyze
* @returns The media type: 'image', 'video', 'audio', or '3D'
*/
-export function getMediaTypeFromFilename(filename: string): MediaType {
+export function getMediaTypeFromFilename(
+ filename: string | null | undefined
+): MediaType {
if (!filename) return 'image'
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'image'
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 15ccfa824..eced908c6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -186,6 +186,9 @@ catalogs:
jsdom:
specifier: ^27.4.0
version: 27.4.0
+ jsonata:
+ specifier: ^2.1.0
+ version: 2.1.0
knip:
specifier: ^5.75.1
version: 5.75.1
@@ -449,6 +452,9 @@ importers:
glob:
specifier: ^11.0.3
version: 11.0.3
+ jsonata:
+ specifier: 'catalog:'
+ version: 2.1.0
jsondiffpatch:
specifier: ^0.6.0
version: 0.6.0
@@ -6045,6 +6051,10 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ jsonata@2.1.0:
+ resolution: {integrity: sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w==}
+ engines: {node: '>= 8'}
+
jsonc-eslint-parser@2.4.0:
resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -14403,6 +14413,8 @@ snapshots:
json5@2.2.3: {}
+ jsonata@2.1.0: {}
+
jsonc-eslint-parser@2.4.0:
dependencies:
acorn: 8.15.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 5856550a0..0c91ec424 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -62,6 +62,7 @@ catalog:
happy-dom: ^20.0.11
husky: ^9.1.7
jiti: 2.6.1
+ jsonata: ^2.1.0
jsdom: ^27.4.0
knip: ^5.75.1
lint-staged: ^16.2.7
diff --git a/src/base/common/async.ts b/src/base/common/async.ts
index a97f6f1bd..d5aa30679 100644
--- a/src/base/common/async.ts
+++ b/src/base/common/async.ts
@@ -14,6 +14,7 @@ interface IdleDeadline {
interface IDisposable {
dispose(): void
}
+type GlobalWindow = typeof globalThis
/**
* Internal implementation function that handles the actual scheduling logic.
@@ -21,7 +22,7 @@ interface IDisposable {
* or fall back to setTimeout-based implementation.
*/
let _runWhenIdle: (
- targetWindow: any,
+ targetWindow: GlobalWindow,
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
@@ -37,7 +38,7 @@ export let runWhenGlobalIdle: (
// Self-invoking function to set up the idle callback implementation
;(function () {
- const safeGlobal: any = globalThis
+ const safeGlobal: GlobalWindow = globalThis as GlobalWindow
if (
typeof safeGlobal.requestIdleCallback !== 'function' ||
diff --git a/src/components/TopMenuSection.test.ts b/src/components/TopMenuSection.test.ts
index 6640f9b88..944ce714c 100644
--- a/src/components/TopMenuSection.test.ts
+++ b/src/components/TopMenuSection.test.ts
@@ -1,5 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
+import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -42,7 +43,8 @@ function createWrapper() {
queueProgressOverlay: {
viewJobHistory: 'View job history',
expandCollapsedQueue: 'Expand collapsed queue',
- activeJobsShort: '{count} active | {count} active'
+ activeJobsShort: '{count} active | {count} active',
+ clearQueueTooltip: 'Clear queue'
}
}
}
@@ -56,7 +58,12 @@ function createWrapper() {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
CurrentUserButton: true,
- LoginButton: true
+ LoginButton: true,
+ ContextMenu: {
+ name: 'ContextMenu',
+ props: ['model'],
+ template: '
'
+ }
},
directives: {
tooltip: () => {}
@@ -134,4 +141,24 @@ describe('TopMenuSection', () => {
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
})
+
+ it('disables the clear queue context menu item when no queued jobs exist', () => {
+ const wrapper = createWrapper()
+ const menu = wrapper.findComponent({ name: 'ContextMenu' })
+ const model = menu.props('model') as MenuItem[]
+ expect(model[0]?.label).toBe('Clear queue')
+ expect(model[0]?.disabled).toBe(true)
+ })
+
+ it('enables the clear queue context menu item when queued jobs exist', async () => {
+ const wrapper = createWrapper()
+ const queueStore = useQueueStore()
+ queueStore.pendingTasks = [createTask('pending-1', 'pending')]
+
+ await nextTick()
+
+ const menu = wrapper.findComponent({ name: 'ContextMenu' })
+ const model = menu.props('model') as MenuItem[]
+ expect(model[0]?.disabled).toBe(false)
+ })
})
diff --git a/src/components/TopMenuSection.vue b/src/components/TopMenuSection.vue
index a20537038..b6b7bc4f3 100644
--- a/src/components/TopMenuSection.vue
+++ b/src/components/TopMenuSection.vue
@@ -49,6 +49,7 @@
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
+ @contextmenu.stop.prevent="showQueueContextMenu"
>
{{ activeJobsLabel }}
@@ -57,6 +58,7 @@
{{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
+
import { storeToRefs } from 'pinia'
+import ContextMenu from 'primevue/contextmenu'
+import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -101,6 +105,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
+import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -119,6 +124,7 @@ const { t, n } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
+const executionStore = useExecutionStore()
const queueUIStore = useQueueUIStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
@@ -144,6 +150,18 @@ const queueHistoryTooltipConfig = computed(() =>
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
)
+const queueContextMenu = ref | null>(null)
+const queueContextMenuItems = computed