Compare commits

...

24 Commits

Author SHA1 Message Date
Comfy Org PR Bot
c8fd9a5374 1.38.13 (#8578)
Patch version increment to 1.38.13

**Base branch:** `core/1.38`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8578-1-38-13-2fc6d73d36508122b4c3dc622c48232e)
by [Unito](https://www.unito.io)

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2026-02-02 22:13:14 -08:00
Comfy Org PR Bot
ded366008e [backport core/1.38] fix: dedupe queueStore.update() to prevent race conditions (#8557)
Backport of #8523 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8557-backport-core-1-38-fix-dedupe-queueStore-update-to-prevent-race-conditions-2fc6d73d36508159ba03dd7ff626ecce)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 17:40:13 -08:00
Comfy Org PR Bot
d48e99db7c [backport core/1.38] fix: node header on preview has a gap on the right (not flush) (#8555)
Backport of #8487 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8555-backport-core-1-38-fix-node-header-on-preview-has-a-gap-on-the-right-not-flush-2fc6d73d365081a1b1cae2afb0675e9a)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-02 17:39:08 -08:00
Comfy Org PR Bot
c138670bf6 [backport core/1.38] fix: add Frame Nodes to core menu items for multi-selection context menu (#8553)
Backport of #8524 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8553-backport-core-1-38-fix-add-Frame-Nodes-to-core-menu-items-for-multi-selection-context--2fc6d73d36508188be6be38de9df2ba0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 17:38:27 -08:00
Comfy Org PR Bot
e4f1950af5 [backport core/1.38] fix: update reactive ref after merge in imagePreviewStore (#8502)
Backport of #8479 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8502-backport-core-1-38-fix-update-reactive-ref-after-merge-in-imagePreviewStore-2f96d73d3650815f944cc401a8c2d264)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Austin Mroz <austin@comfy.org>
2026-01-31 23:40:30 -08:00
Comfy Org PR Bot
44e630d00f [backport core/1.38] Update control_after_generate schema (#8506)
Backport of #8505 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8506-backport-core-1-38-Update-control_after_generate-schema-2f96d73d36508106b677fafb3fe302fe)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-30 21:43:04 -08:00
Comfy Org PR Bot
d27f9faa9e [backport core/1.38] fix: prevent image/video preview reset on dynamic widget addition (#8492)
Backport of #8366 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8492-backport-core-1-38-fix-prevent-image-video-preview-reset-on-dynamic-widget-addition-2f86d73d36508123acfdfac61554da7e)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 08:37:38 -08:00
Comfy Org PR Bot
c902869b2c [backport core/1.38] fix: properties panel obscures menus in legacy layout (#8490)
Backport of #8474 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8490-backport-core-1-38-fix-properties-panel-obscures-menus-in-legacy-layout-2f86d73d36508181977df8801754eca7)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 08:23:40 -08:00
Comfy Org PR Bot
ff9823e8f0 [backport core/1.38] Fix: Hide Jobs in Assets Panel when Queue V2 is disabled. (#8485)
Backport of #8450 to `core/1.38`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-30 08:03:27 +00:00
AustinMroz
bc4e060e92 Revert matchtype slot reactivity on core/1.38 (#8481)
Fixes a bug where canvas functionality is lost if a multitype input
(like the native switch) is added to the graph in litegraph mode.

See also #8477

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8481-Revert-matchtype-slot-reactivity-on-core-1-38-2f86d73d365081ac8e0aeb5ce96fe685)
by [Unito](https://www.unito.io)
2026-01-29 23:09:57 -08:00
Comfy Org PR Bot
bc31970939 [backport core/1.38] feat: add category support for blueprints and protect global blueprints (#8465)
Backport of #8378 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8465-backport-core-1-38-feat-add-category-support-for-blueprints-and-protect-global-bluepri-2f86d73d365081b79a8be0ac55d87f0f)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 19:15:42 -08:00
Comfy Org PR Bot
6bab72feb9 [backport core/1.38] Improve template search input performance issue (#8471)
Backport of #8343 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8471-backport-core-1-38-Improve-template-search-input-performance-issue-2f86d73d365081e3afbff8779ea0ebe0)
by [Unito](https://www.unito.io)

Co-authored-by: Kelly Yang <124ykl@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-29 19:13:41 -08:00
Comfy Org PR Bot
390deac188 [backport core/1.38] fix: default image input for the template is displayed as empty on dropdown selection (#8455)
Backport of #8276 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8455-backport-core-1-38-fix-default-image-input-for-the-template-is-displayed-as-empty-on-d-2f86d73d3650814a895ddbb921bc4776)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-29 16:59:19 -08:00
Comfy Org PR Bot
e4d1554b80 [backport core/1.38] Fix invalid keybind flash (#8451)
Backport of #8435 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8451-backport-core-1-38-Fix-invalid-keybind-flash-2f86d73d365081ba8443ffc6696a9d4d)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 16:34:48 -08:00
Comfy Org PR Bot
94956089f1 [backport core/1.38] Fix Help Center display in linear mode (#8448)
Backport of #8438 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8448-backport-core-1-38-Fix-Help-Center-display-in-linear-mode-2f86d73d365081ebb6a7d6de3c4555e3)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 16:21:52 -08:00
Comfy Org PR Bot
af3f96c0ca [backport core/1.38] make new queue panel disabled by default (#8445)
Backport of #8444 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8445-backport-core-1-38-make-new-queue-panel-disabled-by-default-2f76d73d36508182b643e4cf25962fef)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-01-29 16:16:16 -08:00
Comfy Org PR Bot
ada3145c2d [backport core/1.38] fix: dragging (e.g., when selecting text) in Markdown note causes node to drag (#8427)
Backport of #8413 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8427-backport-core-1-38-fix-dragging-e-g-when-selecting-text-in-Markdown-note-causes-no-2f76d73d3650813e8e28c101270bc42f)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 10:04:47 -08:00
Comfy Org PR Bot
89c76f6861 [backport core/1.38] fix: use getAuthHeader in createCustomer to support API key auth (#8425)
Backport of #8408 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8425-backport-core-1-38-fix-use-getAuthHeader-in-createCustomer-to-support-API-key-auth-2f76d73d36508136b4f1c043c384caa2)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 10:04:01 -08:00
Comfy Org PR Bot
b660638f22 [backport core/1.38] fix: add ResizeObserver to fix Preview3D initial render stretch (#8423)
Backport of #8351 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8423-backport-core-1-38-fix-add-ResizeObserver-to-fix-Preview3D-initial-render-stretch-2f76d73d365081a28ccbd2b5d9eb1aa5)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-01-29 10:03:12 -08:00
Comfy Org PR Bot
a96938a495 [backport core/1.38] Fix flake hidream test (#8420)
Backport of #8406 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8420-backport-core-1-38-Fix-flake-hidream-test-2f76d73d36508162ad34d8bc11f000b6)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 00:11:10 -08:00
Comfy Org PR Bot
f6b571013d [backport core/1.38] [bugfix] Fix manager missing node tab with shared composable (#8410)
Backport of #8409 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8410-backport-core-1-38-bugfix-Fix-manager-missing-node-tab-with-shared-composable-2f76d73d36508137b1e8daadb13cb631)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-01-29 00:05:17 -08:00
Comfy Org PR Bot
54e8775acb [backport core/1.38] fix: add null check in getCanvasCenter to prevent crash on asset insert (#8403)
Backport of #8399 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8403-backport-core-1-38-fix-add-null-check-in-getCanvasCenter-to-prevent-crash-on-asset-ins-2f76d73d365081b6af21e13162afafc5)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-01-28 20:48:47 -08:00
Comfy Org PR Bot
13e8aa7466 [backport core/1.38] fix: increase Vue node resize handle size for better usability (#8394)
Backport of #8391 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8394-backport-core-1-38-fix-increase-Vue-node-resize-handle-size-for-better-usability-2f76d73d365081baa796c6d8c62c4352)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-28 19:50:43 -08:00
Comfy Org PR Bot
7a224efaa0 [backport core/1.38] CI: Add formatting after generating locales. (#8361)
Backport of #8360 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8361-backport-core-1-38-CI-Add-formatting-after-generating-locales-2f66d73d365081c09af5df4c2e0a898b)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-27 22:41:54 -08:00
45 changed files with 816 additions and 104 deletions

View File

@@ -41,7 +41,7 @@ jobs:
env:
PLAYWRIGHT_TEST_URL: http://localhost:5173
- name: Update translations
run: pnpm locale
run: pnpm locale && pnpm format
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Commit updated locales

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.38.12",
"version": "1.38.13",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -767,7 +767,7 @@ useIntersectionObserver(loadTrigger, () => {
// Reset pagination when filters change
watch(
[
searchQuery,
filteredTemplates,
selectedNavItem,
sortBy,
selectedModels,

View File

@@ -265,18 +265,15 @@ function cancelEdit() {
}
async function saveKeybinding() {
if (currentEditingCommand.value && newBindingKeyCombo.value) {
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({
commandId: currentEditingCommand.value.id,
combo: newBindingKeyCombo.value
})
)
if (updated) {
await keybindingService.persistUserKeybindings()
}
}
const commandId = currentEditingCommand.value?.id
const combo = newBindingKeyCombo.value
cancelEdit()
if (!combo || commandId == undefined) return
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
if (updated) await keybindingService.persistUserKeybindings()
}
async function resetKeybinding(commandData: ICommandData) {

View File

@@ -1,6 +1,6 @@
<template>
<!-- Help Center Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<Teleport to="body">
<div
v-if="isHelpCenterVisible"
class="help-center-popup"

View File

@@ -76,14 +76,6 @@ describe('NodePreview', () => {
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
})
it('applies text-ellipsis class to node header for text truncation', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader.classes()).toContain('text-ellipsis')
expect(nodeHeader.classes()).toContain('mr-4')
})
it('sets title attribute on node header with full display name', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')

View File

@@ -10,7 +10,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<div v-else class="_sb_node_preview bg-component-node-background">
<div class="_sb_table">
<div
class="node_header mr-4 text-ellipsis"
class="node_header text-ellipsis"
:title="nodeDef.display_name"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,

View File

@@ -2,7 +2,7 @@
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="activeJobItems.length"
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
@@ -65,6 +65,7 @@ import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isActiveJobState } from '@/utils/queueUtil'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
@@ -90,6 +91,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
type AssetGridItem = { key: string; asset: AssetItem }

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex h-full flex-col">
<div
v-if="activeJobItems.length"
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
@@ -133,6 +133,7 @@ import {
} from '@/utils/formatUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
@@ -154,6 +155,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)

View File

@@ -13,10 +13,7 @@
severity="danger"
/>
</template>
<template
v-if="nodeDef.name.startsWith(useSubgraphStore().typePrefix)"
#actions
>
<template v-if="isUserBlueprint" #actions>
<Button
variant="destructive"
size="icon-sm"
@@ -128,8 +125,18 @@ const editBlueprint = async () => {
await useSubgraphStore().editBlueprint(props.node.data.name)
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const subgraphStore = useSubgraphStore()
const isUserBlueprint = computed(() => {
const name = nodeDef.value.name
if (!name.startsWith(subgraphStore.typePrefix)) return false
return !subgraphStore.isGlobalBlueprint(
name.slice(subgraphStore.typePrefix.length)
)
})
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
if (!isUserBlueprint.value) return []
return [
{
label: t('g.delete'),
icon: 'pi pi-trash',
@@ -137,15 +144,14 @@ const menuItems = computed<MenuItem[]>(() => {
command: deleteBlueprint
}
]
return items
})
function handleContextMenu(event: Event) {
if (!nodeDef.value.name.startsWith(useSubgraphStore().typePrefix)) return
if (!isUserBlueprint.value) return
menu.value?.show(event)
}
function deleteBlueprint() {
if (!props.node.data) return
void useSubgraphStore().deleteBlueprint(props.node.data.name)
void subgraphStore.deleteBlueprint(props.node.data.name)
}
const nodePreviewStyle = ref<CSSProperties>({

View File

@@ -134,6 +134,27 @@ describe('contextMenuConverter', () => {
// Node Info (section 4) should come before or with Color (section 4)
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
})
it('should recognize Frame Nodes as a core menu item', () => {
const options: MenuOption[] = [
{ label: 'Rename', source: 'vue' },
{ label: 'Frame Nodes', source: 'vue' },
{ label: 'Custom Extension', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Frame Nodes should appear in the core items section (before Extensions)
const frameNodesIndex = result.findIndex(
(opt) => opt.label === 'Frame Nodes'
)
const extensionsCategoryIndex = result.findIndex(
(opt) => opt.label === 'Extensions' && opt.type === 'category'
)
// Frame Nodes should come before Extensions category
expect(frameNodesIndex).toBeLessThan(extensionsCategoryIndex)
})
})
describe('convertContextMenuToOptions', () => {

View File

@@ -44,6 +44,7 @@ const CORE_MENU_ITEMS = new Set([
// Structure operations
'Convert to Subgraph',
'Frame selection',
'Frame Nodes',
'Minimize Node',
'Expand',
'Collapse',
@@ -103,7 +104,8 @@ function isDuplicateItem(label: string, existingItems: MenuOption[]): boolean {
shape: ['shape', 'shapes'],
pin: ['pin', 'unpin'],
delete: ['remove', 'delete'],
duplicate: ['clone', 'duplicate']
duplicate: ['clone', 'duplicate'],
frame: ['frame selection', 'frame nodes']
}
return existingItems.some((item) => {
@@ -226,6 +228,7 @@ const MENU_ORDER: string[] = [
// Section 3: Structure operations
'Convert to Subgraph',
'Frame selection',
'Frame Nodes',
'Minimize Node',
'Expand',
'Collapse',

View File

@@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectionMenuOptions } from '@/composables/graph/useSelectionMenuOptions'
const subgraphMocks = vi.hoisted(() => ({
const mocks = vi.hoisted(() => ({
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn(),
addSubgraphToLibrary: vi.fn(),
frameNodes: vi.fn(),
createI18nMock: vi.fn(() => ({
global: {
t: vi.fn(),
@@ -19,7 +20,7 @@ vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
}),
createI18n: subgraphMocks.createI18nMock
createI18n: mocks.createI18nMock
}))
vi.mock('@/composables/graph/useSelectionOperations', () => ({
@@ -42,18 +43,46 @@ vi.mock('@/composables/graph/useNodeArrangement', () => ({
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
useSubgraphOperations: () => ({
convertToSubgraph: subgraphMocks.convertToSubgraph,
unpackSubgraph: subgraphMocks.unpackSubgraph,
addSubgraphToLibrary: subgraphMocks.addSubgraphToLibrary
convertToSubgraph: mocks.convertToSubgraph,
unpackSubgraph: mocks.unpackSubgraph,
addSubgraphToLibrary: mocks.addSubgraphToLibrary
})
}))
vi.mock('@/composables/graph/useFrameNodes', () => ({
useFrameNodes: () => ({
frameNodes: vi.fn()
frameNodes: mocks.frameNodes
})
}))
describe('useSelectionMenuOptions - multiple nodes options', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns Frame Nodes option that invokes frameNodes when called', () => {
const { getMultipleNodesOptions } = useSelectionMenuOptions()
const options = getMultipleNodesOptions()
const frameOption = options.find((opt) => opt.label === 'g.frameNodes')
expect(frameOption).toBeDefined()
expect(frameOption?.action).toBeDefined()
frameOption?.action?.()
expect(mocks.frameNodes).toHaveBeenCalledOnce()
})
it('returns Convert to Group Node option from getMultipleNodesOptions', () => {
const { getMultipleNodesOptions } = useSelectionMenuOptions()
const options = getMultipleNodesOptions()
const groupNodeOption = options.find(
(opt) => opt.label === 'contextMenu.Convert to Group Node'
)
expect(groupNodeOption).toBeDefined()
})
})
describe('useSelectionMenuOptions - subgraph options', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -68,7 +97,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
expect(options).toHaveLength(1)
expect(options[0]?.label).toBe('contextMenu.Convert to Subgraph')
expect(options[0]?.action).toBe(subgraphMocks.convertToSubgraph)
expect(options[0]?.action).toBe(mocks.convertToSubgraph)
})
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
@@ -86,7 +115,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
const convertOption = options.find(
(option) => option.label === 'contextMenu.Convert to Subgraph'
)
expect(convertOption?.action).toBe(subgraphMocks.convertToSubgraph)
expect(convertOption?.action).toBe(mocks.convertToSubgraph)
})
it('hides convert option when only a single subgraph is selected', () => {

View File

@@ -87,6 +87,25 @@ describe('useSelectionState', () => {
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(true)
})
test('hasMultipleSelection should be true when 2+ items selected', () => {
const canvasStore = useCanvasStore()
const node1 = createMockLGraphNode({ id: 1 })
const node2 = createMockLGraphNode({ id: 2 })
canvasStore.$state.selectedItems = [node1, node2]
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(true)
})
test('hasMultipleSelection should be false when only 1 item selected', () => {
const canvasStore = useCanvasStore()
const node1 = createMockLGraphNode({ id: 1 })
canvasStore.$state.selectedItems = [node1]
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(false)
})
})
describe('Node Type Filtering', () => {

View File

@@ -1,4 +1,4 @@
import { refThrottled, watchDebounced } from '@vueuse/core'
import { refDebounced, watchDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { computed, ref, watch } from 'vue'
@@ -119,7 +119,7 @@ export function useTemplateFiltering(
)
})
const debouncedSearchQuery = refThrottled(searchQuery, 50)
const debouncedSearchQuery = refDebounced(searchQuery, 150)
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {

View File

@@ -1,5 +1,4 @@
import { remove } from 'es-toolkit'
import { shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type {
@@ -345,7 +344,6 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
requestAnimationFrame(() => {
const input = node.inputs[index]
if (!input) return
node.inputs[index] = shallowReactive(input)
node.onConnectionsChange?.(
LiteGraph.INPUT,
index,

View File

@@ -55,6 +55,7 @@ class Load3d {
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
private resizeObserver: ResizeObserver | null = null
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
this.clock = new THREE.Clock()
@@ -145,6 +146,7 @@ class Load3d {
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
this.handleResize()
this.startAnimation()
@@ -154,6 +156,14 @@ class Load3d {
}, 100)
}
private initResizeObserver(container: Element | HTMLElement): void {
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
this.forceRender()
})
this.resizeObserver.observe(container)
}
/**
* Initialize context menu on the Three.js canvas
* Detects right-click vs right-drag to show menu only on click
@@ -809,6 +819,11 @@ class Load3d {
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()
this.contextMenuAbortController = null

View File

@@ -993,7 +993,8 @@
"showAll": "Show all",
"hidden": "Hidden / nested parameters",
"hideAll": "Hide all",
"showRecommended": "Show recommended widgets"
"showRecommended": "Show recommended widgets",
"cannotDeleteGlobal": "Cannot delete installed blueprints"
},
"electronFileDownload": {
"inProgress": "In Progress",

View File

@@ -1178,7 +1178,7 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
tooltip:
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
defaultValue: true,
defaultValue: false,
experimental: true
},
{

View File

@@ -394,6 +394,7 @@ interface SubgraphDefinitionBase<
id: string
revision: number
name: string
category?: string
inputNode: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExportedSubgraphIONode>
@@ -425,6 +426,7 @@ const zSubgraphDefinition = zComfyWorkflow1
id: z.string().uuid(),
revision: z.number(),
name: z.string(),
category: z.string().optional(),
inputNode: zExportedSubgraphIONode,
outputNode: zExportedSubgraphIONode,

View File

@@ -158,7 +158,15 @@ const hasMultipleVideos = computed(() => props.imageUrls.length > 1)
// Watch for URL changes and reset state
watch(
() => props.imageUrls,
(newUrls) => {
(newUrls, oldUrls) => {
// Only reset state if URLs actually changed (not just array reference)
const urlsChanged =
!oldUrls ||
newUrls.length !== oldUrls.length ||
newUrls.some((url, i) => url !== oldUrls[i])
if (!urlsChanged) return
// Reset current index if it's out of bounds
if (currentIndex.value >= newUrls.length) {
currentIndex.value = 0
@@ -169,7 +177,7 @@ watch(
videoError.value = false
showLoader.value = newUrls.length > 0
},
{ deep: true, immediate: true }
{ immediate: true }
)
// Event handlers

View File

@@ -308,4 +308,80 @@ describe('ImagePreview', () => {
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('alt')).toBe('Node output 2')
})
describe('URL change detection', () => {
it('should NOT reset loading state when imageUrls prop is reassigned with identical URLs', async () => {
vi.useFakeTimers()
try {
const urls = ['/api/view?filename=test.png&type=output']
const wrapper = mountImagePreview({ imageUrls: urls })
// Simulate image load completing
const img = wrapper.find('img')
await img.trigger('load')
await nextTick()
// Verify loader is hidden after load
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
// Reassign with new array reference but same content
await wrapper.setProps({ imageUrls: [...urls] })
await nextTick()
// Advance past the 250ms delayed loader timeout
await vi.advanceTimersByTimeAsync(300)
await nextTick()
// Loading state should NOT have been reset - aria-busy should still be false
// because the URLs are identical (just a new array reference)
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('should reset loading state when imageUrls prop changes to different URLs', async () => {
const urls = ['/api/view?filename=test.png&type=output']
const wrapper = mountImagePreview({ imageUrls: urls })
// Simulate image load completing
const img = wrapper.find('img')
await img.trigger('load')
await nextTick()
// Verify loader is hidden
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
// Change to different URL
await wrapper.setProps({
imageUrls: ['/api/view?filename=different.png&type=output']
})
await nextTick()
// After 250ms timeout, loading state should be reset (aria-busy="true")
// We can check the internal state via the Skeleton appearing
// or wait for the timeout
await new Promise((resolve) => setTimeout(resolve, 300))
await nextTick()
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
})
it('should handle empty to non-empty URL transitions correctly', async () => {
const wrapper = mountImagePreview({ imageUrls: [] })
// No preview initially
expect(wrapper.find('.image-preview').exists()).toBe(false)
// Add URLs
await wrapper.setProps({
imageUrls: ['/api/view?filename=test.png&type=output']
})
await nextTick()
// Preview should appear
expect(wrapper.find('.image-preview').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(true)
})
})
})

View File

@@ -176,7 +176,15 @@ const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
// Watch for URL changes and reset state
watch(
() => props.imageUrls,
(newUrls) => {
(newUrls, oldUrls) => {
// Only reset state if URLs actually changed (not just array reference)
const urlsChanged =
!oldUrls ||
newUrls.length !== oldUrls.length ||
newUrls.some((url, i) => url !== oldUrls[i])
if (!urlsChanged) return
// Reset current index if it's out of bounds
if (currentIndex.value >= newUrls.length) {
currentIndex.value = 0
@@ -188,7 +196,7 @@ watch(
imageError.value = false
if (newUrls.length > 0) startDelayedLoader()
},
{ deep: true, immediate: true }
{ immediate: true }
)
// Event handlers

View File

@@ -150,7 +150,9 @@
v-if="!isCollapsed && nodeData.resizable !== false"
role="button"
:aria-label="t('g.resizeFromBottomRight')"
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
:class="
cn(baseResizeHandleClasses, '-right-1 -bottom-1 cursor-se-resize')
"
@pointerdown.stop="handleResizePointerDown"
/>
</div>
@@ -344,7 +346,7 @@ function initSizeStyles() {
}
const baseResizeHandleClasses =
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
'absolute h-5 w-5 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
const MIN_NODE_WIDTH = 225
@@ -549,6 +551,12 @@ const showAdvancedState = customRef((track, trigger) => {
}
})
const hasVideoInput = computed(() => {
return (
lgraphNode.value?.inputs?.some((input) => input.type === 'VIDEO') ?? false
)
})
const nodeMedia = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const node = lgraphNode.value
@@ -558,13 +566,9 @@ const nodeMedia = computed(() => {
const urls = nodeOutputs.getNodeImageUrls(node)
if (!urls?.length) return undefined
// Determine media type from previewMediaType or fallback to input slot types
// Note: Despite the field name "images", videos are also included in outputs
// TODO: fix the backend to return videos using the videos key instead of the images key
const hasVideoInput = node.inputs?.some((input) => input.type === 'VIDEO')
const type =
node.previewMediaType === 'video' ||
(!node.previewMediaType && hasVideoInput)
(!node.previewMediaType && hasVideoInput.value)
? 'video'
: 'image'

View File

@@ -206,6 +206,60 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(clickSpy).not.toHaveBeenCalled()
expect(keydownSpy).not.toHaveBeenCalled()
})
describe('Pointer Event Propagation', () => {
it('stops pointerdown propagation to prevent node drag during text selection', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(true)
const parentPointerdownHandler = vi.fn()
const wrapperEl = wrapper.element as HTMLElement
wrapperEl.addEventListener('pointerdown', parentPointerdownHandler)
await textarea.trigger('pointerdown')
expect(parentPointerdownHandler).not.toHaveBeenCalled()
})
it('stops pointermove propagation during text selection', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
const parentPointermoveHandler = vi.fn()
const wrapperEl = wrapper.element as HTMLElement
wrapperEl.addEventListener('pointermove', parentPointermoveHandler)
await textarea.trigger('pointermove')
expect(parentPointermoveHandler).not.toHaveBeenCalled()
})
it('stops pointerup propagation after text selection', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
const parentPointerupHandler = vi.fn()
const wrapperEl = wrapper.element as HTMLElement
wrapperEl.addEventListener('pointerup', parentPointerupHandler)
await textarea.trigger('pointerup')
expect(parentPointerupHandler).not.toHaveBeenCalled()
})
})
})
describe('Value Updates', () => {

View File

@@ -21,6 +21,9 @@
}
}"
data-capture-wheel="true"
@pointerdown.capture.stop
@pointermove.capture.stop
@pointerup.capture.stop
@click.stop
@keydown.stop
/>

View File

@@ -152,6 +152,50 @@ describe('WidgetSelectDropdown custom label mapping', () => {
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('falls back to original value when label mapping returns empty string', () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (value === 'photo_abc.jpg') {
return ''
}
return `Labeled: ${value}`
})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('Labeled: hash789.png')
})
it('falls back to original value when label mapping returns undefined', () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (value === 'hash789.png') {
return undefined as unknown as string
}
return `Labeled: ${value}`
})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('Labeled: photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('hash789.png')
})
})
describe('output items with custom label mapping', () => {
@@ -171,4 +215,102 @@ describe('WidgetSelectDropdown custom label mapping', () => {
expect(Array.isArray(outputItems)).toBe(true)
})
})
describe('missing value handling for template-loaded nodes', () => {
it('creates a fallback item in "all" filter when modelValue is not in available items', () => {
const widget = createMockWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems).toHaveLength(2)
expect(
inputItems.some((item) => item.name === 'template_image.png')
).toBe(false)
// The missing value should be accessible via dropdownItems when filter is 'all' (default)
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
).dropdownItems
expect(
dropdownItems.some((item) => item.name === 'template_image.png')
).toBe(true)
expect(dropdownItems[0].name).toBe('template_image.png')
expect(dropdownItems[0].id).toBe('missing-template_image.png')
})
it('does not include fallback item when filter is "inputs"', async () => {
const widget = createMockWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
const vmWithFilter = wrapper.vm as unknown as {
filterSelected: string
dropdownItems: DropdownItem[]
}
vmWithFilter.filterSelected = 'inputs'
await wrapper.vm.$nextTick()
const dropdownItems = vmWithFilter.dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not include fallback item when filter is "outputs"', async () => {
const widget = createMockWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
const vmWithFilter = wrapper.vm as unknown as {
filterSelected: string
dropdownItems: DropdownItem[]
outputItems: DropdownItem[]
}
vmWithFilter.filterSelected = 'outputs'
await wrapper.vm.$nextTick()
const dropdownItems = vmWithFilter.dropdownItems
expect(dropdownItems).toHaveLength(wrapper.vm.outputItems.length)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not create a fallback item when modelValue exists in available items', () => {
const widget = createMockWidget('img_001.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'img_001.png')
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
).dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not create a fallback item when modelValue is undefined', () => {
const widget = createMockWidget(undefined as unknown as string, {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, undefined)
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
).dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
})
})

View File

@@ -85,14 +85,15 @@ const selectedSet = ref<Set<SelectedKey>>(new Set())
/**
* Transforms a value using getOptionLabel if available.
* Falls back to the original value if getOptionLabel is not provided or throws an error.
* Falls back to the original value if getOptionLabel is not provided,
* returns undefined/null, or throws an error.
*/
function getDisplayLabel(value: string): string {
const getOptionLabel = props.widget.options?.getOptionLabel
if (!getOptionLabel) return value
try {
return getOptionLabel(value)
return getOptionLabel(value) || value
} catch (e) {
console.error('Failed to map value:', e)
return value
@@ -146,11 +147,69 @@ const outputItems = computed<DropdownItem[]>(() => {
}))
})
/**
* Creates a fallback item for the current modelValue when it doesn't exist
* in the available items list. This handles cases like template-loaded nodes
* where the saved value may not exist in the current server environment.
* Works for both local mode (inputItems/outputItems) and cloud mode (assetData).
*/
const missingValueItem = computed<DropdownItem | undefined>(() => {
const currentValue = modelValue.value
if (!currentValue) return undefined
// Check in cloud mode assets
if (props.isAssetMode && assetData) {
const existsInAssets = assetData.dropdownItems.value.some(
(item) => item.name === currentValue
)
if (existsInAssets) return undefined
return {
id: `missing-${currentValue}`,
mediaSrc: '',
name: currentValue,
label: getDisplayLabel(currentValue),
metadata: ''
}
}
// Check in local mode inputs/outputs
const existsInInputs = inputItems.value.some(
(item) => item.name === currentValue
)
const existsInOutputs = outputItems.value.some(
(item) => item.name === currentValue
)
if (existsInInputs || existsInOutputs) return undefined
const isOutput = currentValue.endsWith(' [output]')
const strippedValue = isOutput
? currentValue.replace(' [output]', '')
: currentValue
return {
id: `missing-${currentValue}`,
mediaSrc: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'),
name: currentValue,
label: getDisplayLabel(currentValue),
metadata: ''
}
})
const allItems = computed<DropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
return assetData.dropdownItems.value
const items = assetData.dropdownItems.value
if (missingValueItem.value) {
return [missingValueItem.value, ...items]
}
return items
}
return [...inputItems.value, ...outputItems.value]
return [
...(missingValueItem.value ? [missingValueItem.value] : []),
...inputItems.value,
...outputItems.value
]
})
const dropdownItems = computed<DropdownItem[]>(() => {
@@ -165,7 +224,7 @@ const dropdownItems = computed<DropdownItem[]>(() => {
return outputItems.value
case 'all':
default:
return [...inputItems.value, ...outputItems.value]
return allItems.value
}
})

View File

@@ -74,10 +74,14 @@ const addMultiSelectWidget = (
// TODO: Add remote support to multi-select widget
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
if (inputSpec.control_after_generate) {
const defaultType =
typeof inputSpec.control_after_generate === 'string'
? inputSpec.control_after_generate
: 'fixed'
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
'fixed',
defaultType,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
@@ -209,10 +213,14 @@ const createInputMappingWidget = (
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}
const defaultType =
typeof inputSpec.control_after_generate === 'string'
? inputSpec.control_after_generate
: 'randomize'
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
defaultType,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
@@ -284,10 +292,14 @@ const addComboWidget = (
throw new Error(`Expected combo widget but received ${widget.type}`)
}
const defaultType =
typeof inputSpec.control_after_generate === 'string'
? inputSpec.control_after_generate
: 'randomize'
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
defaultType,
undefined,
transformInputSpecV2ToV1(inputSpec)
)

View File

@@ -72,10 +72,14 @@ export const useIntWidget = () => {
['seed', 'noise_seed'].includes(inputSpec.name)
if (controlAfterGenerate) {
const defaultType =
typeof inputSpec.control_after_generate === 'string'
? inputSpec.control_after_generate
: 'randomize'
const controlWidget = addValueControlWidget(
node,
widget,
'randomize',
defaultType,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)

View File

@@ -2,6 +2,7 @@ import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import { resultItemType } from '@/schemas/apiSchema'
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
const zComboOption = z.union([z.string(), z.number()])
const zRemoteWidgetConfig = z.object({
@@ -50,7 +51,9 @@ export const zIntInputOptions = zNumericInputOptions.extend({
* If true, a linked widget will be added to the node to select the mode
* of `control_after_generate`.
*/
control_after_generate: z.boolean().optional()
control_after_generate: z
.union([z.boolean(), z.enum(CONTROL_OPTIONS)])
.optional()
})
export const zFloatInputOptions = zNumericInputOptions.extend({
@@ -74,7 +77,9 @@ export const zStringInputOptions = zBaseInputOptions.extend({
})
export const zComboInputOptions = zBaseInputOptions.extend({
control_after_generate: z.boolean().optional(),
control_after_generate: z
.union([z.boolean(), z.enum(CONTROL_OPTIONS)])
.optional(),
image_upload: z.boolean().optional(),
image_folder: resultItemType.optional(),
allow_batch: z.boolean().optional(),

View File

@@ -231,7 +231,10 @@ type ComplexApiEvents = keyof NeverNever<ApiEventTypes>
export type GlobalSubgraphData = {
name: string
info: { node_pack: string }
info: {
node_pack: string
category?: string
}
data: string | Promise<string>
}

View File

@@ -876,7 +876,11 @@ export const useLitegraphService = () => {
function getCanvasCenter(): Point {
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
const [x, y, w, h] = app.canvas.ds.visible_area
const visibleArea = app.canvas?.ds?.visible_area
if (!visibleArea) {
return [0, 0]
}
const [x, y, w, h] = visibleArea
return [x + w / dpi / 2, y + h / dpi / 2]
}

View File

@@ -278,7 +278,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
const createCustomer = async (): Promise<CreateCustomerResponse> => {
const authHeader = await getFirebaseAuthHeader()
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}

View File

@@ -1,3 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,7 +14,9 @@ vi.mock('@/utils/litegraphUtil', () => ({
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => '&format=test_webp')
getPreviewFormatParam: vi.fn(() => '&format=test_webp'),
nodeOutputs: {} as Record<string, unknown>,
nodePreviewImages: {} as Record<string, string[]>
}
}))
@@ -28,6 +31,62 @@ const createMockOutputs = (
images?: ExecutedWsMessage['output']['images']
): ExecutedWsMessage['output'] => ({ images })
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn(() => ({
executionIdToNodeLocatorId: vi.fn((id: string) => id)
}))
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
nodeIdToNodeLocatorId: vi.fn((id: string | number) => String(id)),
nodeToNodeLocatorId: vi.fn((node: { id: number }) => String(node.id))
}))
}))
describe('imagePreviewStore setNodeOutputsByExecutionId with merge', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should update reactive nodeOutputs.value when merging outputs', () => {
const store = useNodeOutputStore()
const executionId = '1'
const initialOutput = createMockOutputs([{ filename: 'a.png' }])
store.setNodeOutputsByExecutionId(executionId, initialOutput)
expect(app.nodeOutputs[executionId]?.images).toHaveLength(1)
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
const newOutput = createMockOutputs([{ filename: 'b.png' }])
store.setNodeOutputsByExecutionId(executionId, newOutput, { merge: true })
expect(app.nodeOutputs[executionId]?.images).toHaveLength(2)
expect(store.nodeOutputs[executionId]?.images).toHaveLength(2)
})
it('should assign to reactive ref after merge for Vue reactivity', () => {
const store = useNodeOutputStore()
const executionId = '1'
const initialOutput = createMockOutputs([{ filename: 'a.png' }])
store.setNodeOutputsByExecutionId(executionId, initialOutput)
const newOutput = createMockOutputs([{ filename: 'b.png' }])
store.setNodeOutputsByExecutionId(executionId, newOutput, { merge: true })
expect(store.nodeOutputs[executionId]).toStrictEqual(
app.nodeOutputs[executionId]
)
expect(store.nodeOutputs[executionId]?.images).toHaveLength(2)
})
})
describe('imagePreviewStore getPreviewParam', () => {
beforeEach(() => {
setActivePinia(createPinia())

View File

@@ -148,6 +148,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
existingOutput[k] = newValue
}
}
nodeOutputs.value[nodeLocatorId] = existingOutput
return
}
}

View File

@@ -44,6 +44,9 @@ const createTaskOutput = (
}
})
type QueueResponse = { Running: JobListItem[]; Pending: JobListItem[] }
type QueueResolver = (value: QueueResponse) => void
// Mock API
vi.mock('@/scripts/api', () => ({
api: {
@@ -797,4 +800,106 @@ describe('useQueueStore', () => {
expect(mockGetHistory).toHaveBeenCalled()
})
})
describe('update deduplication', () => {
it('should discard stale responses when newer request completes first', async () => {
let resolveFirst: QueueResolver
let resolveSecond: QueueResolver
const firstQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveFirst = resolve
})
const secondQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveSecond = resolve
})
mockGetHistory.mockResolvedValue([])
mockGetQueue
.mockReturnValueOnce(firstQueuePromise)
.mockReturnValueOnce(secondQueuePromise)
const firstUpdate = store.update()
const secondUpdate = store.update()
resolveSecond!({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
await secondUpdate
expect(store.pendingTasks).toHaveLength(1)
expect(store.pendingTasks[0].promptId).toBe('new-job')
resolveFirst!({
Running: [],
Pending: [createPendingJob(1, 'stale-job')]
})
await firstUpdate
expect(store.pendingTasks).toHaveLength(1)
expect(store.pendingTasks[0].promptId).toBe('new-job')
})
it('should set isLoading to false only for the latest request', async () => {
let resolveFirst: QueueResolver
let resolveSecond: QueueResolver
const firstQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveFirst = resolve
})
const secondQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveSecond = resolve
})
mockGetHistory.mockResolvedValue([])
mockGetQueue
.mockReturnValueOnce(firstQueuePromise)
.mockReturnValueOnce(secondQueuePromise)
const firstUpdate = store.update()
expect(store.isLoading).toBe(true)
const secondUpdate = store.update()
expect(store.isLoading).toBe(true)
resolveSecond!({ Running: [], Pending: [] })
await secondUpdate
expect(store.isLoading).toBe(false)
resolveFirst!({ Running: [], Pending: [] })
await firstUpdate
expect(store.isLoading).toBe(false)
})
it('should handle stale request failure without affecting latest state', async () => {
let resolveSecond: QueueResolver
const secondQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveSecond = resolve
})
mockGetHistory.mockResolvedValue([])
mockGetQueue
.mockRejectedValueOnce(new Error('stale network error'))
.mockReturnValueOnce(secondQueuePromise)
const firstUpdate = store.update()
const secondUpdate = store.update()
resolveSecond!({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
await secondUpdate
expect(store.pendingTasks).toHaveLength(1)
expect(store.pendingTasks[0].promptId).toBe('new-job')
expect(store.isLoading).toBe(false)
await expect(firstUpdate).rejects.toThrow('stale network error')
expect(store.pendingTasks).toHaveLength(1)
expect(store.pendingTasks[0].promptId).toBe('new-job')
expect(store.isLoading).toBe(false)
})
})
})

View File

@@ -475,6 +475,9 @@ export const useQueueStore = defineStore('queue', () => {
const maxHistoryItems = ref(64)
const isLoading = ref(false)
// Scoped per-store instance; incremented to dedupe concurrent update() calls
let updateRequestId = 0
const tasks = computed<TaskItemImpl[]>(
() =>
[
@@ -498,6 +501,7 @@ export const useQueueStore = defineStore('queue', () => {
)
const update = async () => {
const requestId = ++updateRequestId
isLoading.value = true
try {
const [queue, history] = await Promise.all([
@@ -505,6 +509,8 @@ export const useQueueStore = defineStore('queue', () => {
api.getHistory(maxHistoryItems.value)
])
if (requestId !== updateRequestId) return
// API returns pre-sorted data (sort_by=create_time&order=desc)
runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
@@ -545,7 +551,12 @@ export const useQueueStore = defineStore('queue', () => {
return existing
})
} finally {
isLoading.value = false
// Only clear loading if this is the latest request.
// A stale request completing (success or error) should not touch loading state
// since a newer request is responsible for it.
if (requestId === updateRequestId) {
isLoading.value = false
}
}
}

View File

@@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import type { GlobalSubgraphData } from '@/scripts/api'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
@@ -24,6 +25,7 @@ vi.mock('@/scripts/api', () => ({
getUserData: vi.fn(),
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
getGlobalSubgraphs: vi.fn(),
apiURL: vi.fn(),
addEventListener: vi.fn()
}
@@ -59,7 +61,10 @@ const mockGraph = {
describe('useSubgraphStore', () => {
let store: ReturnType<typeof useSubgraphStore>
const mockFetch = async (filenames: Record<string, unknown>) => {
async function mockFetch(
filenames: Record<string, unknown>,
globalSubgraphs: Record<string, GlobalSubgraphData> = {}
) {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue(
Object.keys(filenames).map((filename) => ({
path: filename,
@@ -67,13 +72,13 @@ describe('useSubgraphStore', () => {
size: 1 // size !== -1 for remote workflows
}))
)
vi.mocked(api).getUserData = vi.fn(
(f) =>
({
status: 200,
text: () => JSON.stringify(filenames[f.slice(10)])
}) as any
vi.mocked(api).getUserData = vi.fn((f) =>
Promise.resolve({
status: 200,
text: () => Promise.resolve(JSON.stringify(filenames[f.slice(10)]))
} as Response)
)
vi.mocked(api.getGlobalSubgraphs).mockResolvedValue(globalSubgraphs)
return await store.fetchSubgraphs()
}
@@ -113,7 +118,7 @@ describe('useSubgraphStore', () => {
await mockFetch({ 'test.json': mockGraph })
expect(
useNodeDefStore().nodeDefs.filter(
(d) => d.category == 'Subgraph Blueprints'
(d) => d.category === 'Subgraph Blueprints/User'
)
).toHaveLength(1)
})
@@ -131,4 +136,25 @@ describe('useSubgraphStore', () => {
} as ComfyNodeDefV1)
expect(res).toBeTruthy()
})
it('should identify user blueprints as non-global', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(store.isGlobalBlueprint('test')).toBe(false)
})
it('should identify global blueprints loaded from getGlobalSubgraphs', async () => {
await mockFetch(
{},
{
global_test: {
name: 'Global Test Blueprint',
info: { node_pack: 'comfy_essentials' },
data: JSON.stringify(mockGraph)
}
}
)
expect(store.isGlobalBlueprint('global_test')).toBe(true)
})
it('should return false for non-existent blueprints', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(store.isGlobalBlueprint('nonexistent')).toBe(false)
})
})

View File

@@ -96,7 +96,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
this.hasPromptedSave = true
}
const ret = await super.save()
useSubgraphStore().updateDef(await this.load())
registerNodeDef(await this.load(), {
category: 'Subgraph Blueprints/User'
})
return ret
}
@@ -104,7 +106,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
this.validateSubgraph()
this.hasPromptedSave = true
const ret = await super.saveAs(path)
useSubgraphStore().updateDef(await this.load())
registerNodeDef(await this.load(), {
category: 'Subgraph Blueprints/User'
})
return ret
}
override async load({ force = false }: { force?: boolean } = {}): Promise<
@@ -151,7 +155,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
options.path = SubgraphBlueprint.basePath + options.path
const bp = await new SubgraphBlueprint(options, true).load()
useWorkflowStore().attachWorkflow(bp)
registerNodeDef(bp)
registerNodeDef(bp, { category: 'Subgraph Blueprints/User' })
}
async function loadInstalledBlueprints() {
async function loadGlobalBlueprint([k, v]: [string, GlobalSubgraphData]) {
@@ -165,11 +169,15 @@ export const useSubgraphStore = defineStore('subgraph', () => {
blueprint.filename = v.name
useWorkflowStore().attachWorkflow(blueprint)
const loaded = await blueprint.load()
const category = v.info.category
? `Subgraph Blueprints/${v.info.category}`
: 'Subgraph Blueprints'
registerNodeDef(
loaded,
{
python_module: v.info.node_pack,
display_name: v.name
display_name: v.name,
category
},
k
)
@@ -284,7 +292,6 @@ export const useSubgraphStore = defineStore('subgraph', () => {
await workflow.save()
//add to files list?
useWorkflowStore().attachWorkflow(loadedWorkflow)
registerNodeDef(loadedWorkflow)
useToastStore().add({
severity: 'success',
summary: t('subgraphStore.publishSuccess'),
@@ -292,9 +299,6 @@ export const useSubgraphStore = defineStore('subgraph', () => {
life: 4000
})
}
function updateDef(blueprint: LoadedComfyWorkflow) {
registerNodeDef(blueprint)
}
async function editBlueprint(nodeType: string) {
const name = nodeType.slice(typePrefix.length)
if (!(name in subgraphCache))
@@ -315,9 +319,17 @@ export const useSubgraphStore = defineStore('subgraph', () => {
}
async function deleteBlueprint(nodeType: string) {
const name = nodeType.slice(typePrefix.length)
if (!(name in subgraphCache))
//As loading is blocked on in startup, this can likely be changed to invalid type
throw new Error('not yet loaded')
if (!(name in subgraphCache)) throw new Error('not yet loaded')
if (isGlobalBlueprint(name)) {
useToastStore().add({
severity: 'warn',
summary: t('subgraphStore.cannotDeleteGlobal'),
life: 4000
})
return
}
if (
!(await useDialogService().confirm({
title: t('subgraphStore.confirmDeleteTitle'),
@@ -338,15 +350,20 @@ export const useSubgraphStore = defineStore('subgraph', () => {
return workflow instanceof SubgraphBlueprint
}
function isGlobalBlueprint(name: string): boolean {
const nodeDef = subgraphDefCache.value.get(name)
return nodeDef !== undefined && nodeDef.python_module !== 'blueprint'
}
return {
deleteBlueprint,
editBlueprint,
fetchSubgraphs,
getBlueprint,
isGlobalBlueprint,
isSubgraphBlueprint,
publishSubgraph,
subgraphBlueprints,
typePrefix,
updateDef
typePrefix
}
})

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -19,8 +19,13 @@ type RightSidePanelSection = 'advanced-inputs' | string
export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
const settingStore = useSettingStore()
const isLegacyMenu = computed(
() => settingStore.get('Comfy.UseNewMenu') === 'Disabled'
)
const isOpen = computed({
get: () => settingStore.get('Comfy.RightSidePanel.IsOpen'),
get: () =>
!isLegacyMenu.value && settingStore.get('Comfy.RightSidePanel.IsOpen'),
set: (value: boolean) =>
settingStore.set('Comfy.RightSidePanel.IsOpen', value)
})
@@ -29,7 +34,15 @@ export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
const focusedSection = ref<RightSidePanelSection | null>(null)
const searchQuery = ref('')
// Auto-close panel when switching to legacy menu mode
watch(isLegacyMenu, (legacy) => {
if (legacy) {
void settingStore.set('Comfy.RightSidePanel.IsOpen', false)
}
})
function openPanel(tab?: RightSidePanelTab) {
if (isLegacyMenu.value) return
isOpen.value = true
if (tab) {
activeTab.value = tab

View File

@@ -15,7 +15,7 @@ export type WidgetValue =
| void
| File[]
const CONTROL_OPTIONS = [
export const CONTROL_OPTIONS = [
'fixed',
'increment',
'decrement',

View File

@@ -1,3 +1,4 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, onUnmounted, ref } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -9,7 +10,6 @@ import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { components } from '@/types/comfyRegistryTypes'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
import { useNodePacks } from '@/workbench/extensions/manager/composables/nodePack/useNodePacks'
import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes'
type WorkflowPack = {
id:
@@ -22,9 +22,10 @@ const CORE_NODES_PACK_NAME = 'comfy-core'
/**
* Handles parsing node pack metadata from nodes on the graph and fetching the
* associated node packs from the registry
* associated node packs from the registry.
* This is a shared singleton composable - all components use the same instance.
*/
export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
const _useWorkflowPacks = () => {
const nodeDefStore = useNodeDefStore()
const systemStatsStore = useSystemStatsStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
@@ -129,7 +130,7 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
)
const { startFetch, cleanup, error, isLoading, nodePacks, isReady } =
useNodePacks(workflowPacksIds, options)
useNodePacks(workflowPacksIds)
const isIdInWorkflow = (packId: string) =>
workflowPacksIds.value.includes(packId)
@@ -153,3 +154,5 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
filterWorkflowPack
}
}
export const useWorkflowPacks = createSharedComposable(_useWorkflowPacks)