Compare commits

..

48 Commits

Author SHA1 Message Date
bymyself
1d6b7437db fix: resolve merge conflict with main (draftTypes.ts deleted on both sides)
Amp-Thread-ID: https://ampcode.com/threads/T-019c79f4-bca4-768d-a3d0-7d612f7186ac
2026-02-20 00:44:13 -08:00
Christian Byrne
bad9642fad Review fixes for V2 Node Search (#8987) (#9001)
Addresses code review feedback from both manual review and CodeRabbit on
#8987.

**18 commits, one per fix:**

### Fixes (11)
- Add `migrateDeprecatedValue` for removed `'simple'` setting option
- Extract magic number and use semantic color token in NodePreviewCard
- Use Lucide icon instead of PrimeIcons in SearchBoxV2
- Use function declaration instead of expression in NodeSearchBox
- Log pricing evaluation errors at debug level
- Extract preview breakpoint constant and use Tailwind overflow class
- Use reactive props destructuring in EssentialNodeCard
- Prevent one-frame preview position flash via opacity
- Cancel stale click-mode drag on listener cleanup
- Use explicit watch for hoverNode and fix category filter consistency
- Add dompurify to pnpm workspace catalog

### Test improvements (7)
- Add assertions to EssentialNodeCard drag and hover tests
- Use `vi.hoisted()` and `vi.resetAllMocks()` in useNodeDragToCanvas
tests
- Fix NodeSearchCategorySidebar test suite (describe reference, mock
ordering, test name)
- Move `vi.restoreAllMocks` before setup in NodeSearchFilterBar tests
- Add wrapper cleanup in TextTicker tests
- Add highlightQuery sanitization tests
- Reset NodeSearchBoxImpl setting in linkInteraction beforeEach

All 4,753 unit tests pass.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9001-Review-fixes-for-V2-Node-Search-8987-30d6d73d365081a892dac9bf533fb072)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-20 00:36:24 -08:00
GitHub Action
ab1b72a8df [automated] Apply ESLint and Oxfmt fixes 2026-02-19 17:31:08 +00:00
pythongosssss
615f5724f4 fix test 2026-02-19 09:26:18 -08:00
pythongosssss
67af4415a8 fix tests 2026-02-19 09:26:18 -08:00
pythongosssss
3d2699c07c fix tests & rabbit 2026-02-19 09:26:18 -08:00
pythongosssss
a597050d7c update tests to use v1 search 2026-02-19 09:26:18 -08:00
pythongosssss
c44155bcb3 hide node library setting 2026-02-19 09:26:18 -08:00
pythongosssss
99d6f87f00 set v2 search as default
update settings descriptions
hide essentials category if no essentials nodes
2026-02-19 09:26:18 -08:00
pythongosssss
c545c9d5d0 tweaks 2026-02-19 09:26:18 -08:00
pythongosssss
7d73f01588 Add text ticker 2026-02-19 09:26:18 -08:00
pythongosssss
663efeff35 Add text ticker overflow description 2026-02-19 09:26:18 -08:00
pythongosssss
f88a42619e add essentials 2026-02-19 09:26:18 -08:00
pythongosssss
ca86212d66 design feedback
- add separator to categories
- allow collapsing categories
- color tweaks
- swap input for tag component for keyboard nav
- extract node search input component
- fix toolbox selection after ghost
- tidy
2026-02-19 09:26:18 -08:00
pythongosssss
2e3a77567e review feedback 2026-02-19 09:25:24 -08:00
pythongosssss
d85db6367e new v2 node search box with categories - input/output/source filters - uses new preview, extracted price + provider badges - adds as ghost - tests 2026-02-19 09:25:12 -08:00
GitHub Action
c8f1e0395a [automated] Apply ESLint and Oxfmt fixes 2026-02-19 15:59:42 +00:00
Yourz
7d00323935 fix: unit tests 2026-02-19 23:54:48 +08:00
Yourz
cc85df9ae4 fix: design reviews 2026-02-19 23:41:48 +08:00
Yourz
def0def247 fix: design reviews 2026-02-19 23:40:05 +08:00
Yourz
4754be7567 fix: unit tests 2026-02-19 23:40:05 +08:00
Yourz
b52785a8a0 fix: update for design reviews 2026-02-19 23:40:05 +08:00
Yourz
1b16b0f6f8 fix: unit tests 2026-02-19 23:40:05 +08:00
Yourz
fedce61238 fix: update for design reviews, and add setting toggle to switch node library design 2026-02-19 23:40:05 +08:00
Yourz
726ae23cfd fix: add missing DEFAULT_SORTING_ID to test mock
Amp-Thread-ID: https://ampcode.com/threads/T-019c56f7-b162-760c-999d-024481032451
Co-authored-by: Amp <amp@ampcode.com>
2026-02-19 23:40:04 +08:00
Yourz
7a4f7dc4de fix: update for design reviews 2026-02-19 23:40:04 +08:00
Yourz
824f8bb281 fix: improve NodePreviewCard styling and drag-drop reliability
- Change NodePreviewCard width to w-50 with scale-50
- Add gap-2 between input/output name and type
- Truncate type text instead of name when overflowing
- Teleport preview to body to fix z-index stacking context issues
- Remove dropEffect check in handleDragEnd for consistent behavior
- Update unit tests to reflect new drag-drop behavior

Amp-Thread-ID: https://ampcode.com/threads/T-019c56f7-b162-760c-999d-024481032451
Co-authored-by: Amp <amp@ampcode.com>
2026-02-19 23:40:04 +08:00
github-actions
a4975a0e62 [automated] Update test expectations 2026-02-19 23:40:04 +08:00
Yourz
9a4aff542c fix: ci 2026-02-19 23:40:03 +08:00
Yourz
34dd961561 feat: essentials tab 2026-02-19 23:40:03 +08:00
Yourz
d78e4b92cb feat: support drag and drop creation 2026-02-19 23:40:03 +08:00
Yourz
41d4adaf45 fix: update icons 2026-02-19 23:40:03 +08:00
Yourz
99d31a7376 fix: update for reviews 2026-02-19 23:40:03 +08:00
Yourz
193d4827af fix: coderabbit reviews 2026-02-19 23:40:02 +08:00
Yourz
594c2497fd fix: coderabbit reviews 2026-02-19 23:40:02 +08:00
Yourz
1a94e34c0c fix: coderabbit reviews 2026-02-19 23:40:02 +08:00
Yourz
1aa10e7c8f fix: coderabbit reviews 2026-02-19 23:40:02 +08:00
Yourz
91ad183c24 feat: new drag preview 2026-02-19 23:40:02 +08:00
Yourz
e637471353 fix: ci and badge 2026-02-19 23:40:01 +08:00
Yourz
98532a8217 feat: api nodes icon 2026-02-19 23:40:01 +08:00
Yourz
70b514a48d feat: drop nodes when click the canvas 2026-02-19 23:40:00 +08:00
Yourz
f80b87b46c fix: reviews for coderabbit 2026-02-19 23:40:00 +08:00
Yourz
bde6678cd7 fix: reviews 2026-02-19 23:40:00 +08:00
Yourz
37477b2e43 fix: ci 2026-02-19 23:40:00 +08:00
Yourz
3f07dd255f fix: preview card, icon 2026-02-19 23:40:00 +08:00
Yourz
2ed895c3dc feat: previewCard and BadgPill and api node logo 2026-02-19 23:40:00 +08:00
Yourz
f5b0b70676 feat: add main_category field and group nodes by category
- Add main_category field to node definitions in api.ts
- Add main_category to ComfyNodeDefImpl class
- Group nodes by main_category in 'all' and 'custom' tabs
- Format category names from snake_case to Title Case

Amp-Thread-ID: https://ampcode.com/threads/T-019c1ee5-bb3c-70fb-8c24-33966d8dbef8
Co-authored-by: Amp <amp@ampcode.com>
2026-02-19 23:40:00 +08:00
Yourz
9058d3ec54 feat: implement NodeLibrarySidebarTabV2 with Reka UI components
- Add three-tab structure (Essential, All, Custom) using Reka UI Tabs
- Implement TreeExplorerV2 with virtualized tree using TreeRoot/TreeVirtualizer
- Add node hover preview with teleport to show NodePreview component
- Implement context menu for toggling favorites on nodes
- Add search functionality that auto-expands matching folders
- Create panel components: EssentialNodesPanel, AllNodesPanel, CustomNodesPanel
- Add 'Open Manager' button in CustomNodesPanel
- Use custom icons: comfy--node for nodes, ph--folder-fill for folders

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c1ee5-bb3c-70fb-8c24-33966d8dbef8
2026-02-19 23:40:00 +08:00
263 changed files with 2943 additions and 15038 deletions

View File

@@ -34,13 +34,10 @@ jobs:
- name: Build project
run: pnpm build
env:
DISTRIBUTION: localhost
- name: Scan dist for GTM telemetry references
- name: Scan dist for telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Google Tag Manager references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
@@ -49,33 +46,7 @@ jobs:
-e '(?i)googletagmanager\.com/gtm\.js\\?id=' \
-e '(?i)googletagmanager\.com/ns\.html\\?id=' \
dist; then
echo '❌ ERROR: Google Tag Manager references found in dist assets!'
echo 'GTM must be properly tree-shaken from OSS builds.'
echo 'Telemetry references found in dist assets.'
exit 1
fi
echo 'No GTM references found'
- name: Scan dist for Mixpanel telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Mixpanel references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '(?i)mixpanel\.init' \
-e '(?i)mixpanel\.identify' \
-e 'MixpanelTelemetryProvider' \
-e 'mp\.comfy\.org' \
-e 'mixpanel-browser' \
-e '(?i)mixpanel\.track\(' \
dist; then
echo '❌ ERROR: Mixpanel references found in dist assets!'
echo 'Mixpanel must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Mixpanel references found'
echo 'No telemetry references found in dist assets.'

View File

@@ -100,8 +100,7 @@ const config: StorybookConfig = {
rolldownOptions: {
treeshake: false,
output: {
keepNames: true,
strictExecutionOrder: true
keepNames: true
},
onwarn: (warning, warn) => {
// Suppress specific warnings

View File

@@ -244,9 +244,21 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
const parsed = await comfyPage.page.evaluate(() => {
return window['app'].graph.serialize()
})
const parsed = await (
await comfyPage.page.waitForFunction(
() => {
const workflow = localStorage.getItem('workflow')
if (!workflow) return null
try {
const data = JSON.parse(workflow)
return Array.isArray(data?.nodes) ? data : null
} catch {
return null
}
},
{ timeout: 3000 }
)
).jsonValue()
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
for (const node of parsed.nodes) {

View File

@@ -1,32 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Confirm dialog text wrapping', { tag: ['@mobile'] }, () => {
test('@mobile confirm dialog buttons are visible with long unbreakable text', async ({
comfyPage
}) => {
const longFilename = 'workflow_checkpoint_' + 'a'.repeat(200) + '.json'
await comfyPage.page.evaluate((msg) => {
window
.app!.extensionManager.dialog.confirm({
title: 'Confirm',
type: 'default',
message: msg
})
.catch(() => {})
}, longFilename)
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
const confirmButton = dialog.getByRole('button', { name: 'Confirm' })
await expect(confirmButton).toBeVisible()
await expect(confirmButton).toBeInViewport()
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await expect(cancelButton).toBeVisible()
await expect(cancelButton).toBeInViewport()
})
})

View File

@@ -61,21 +61,16 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
})
test.describe('Execution error', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test('Should display an error message when an execution error occurs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.queueButton.click()
await comfyPage.nextFrame()
// Wait for the error overlay to be visible
const errorOverlay = comfyPage.page.locator('[data-testid="error-overlay"]')
await expect(errorOverlay).toBeVisible()
// Wait for the element with the .comfy-execution-error selector to be visible
const executionError = comfyPage.page.locator('.comfy-error-report')
await expect(executionError).toBeVisible()
})
})
@@ -345,23 +340,17 @@ test.describe('Support', () => {
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
// Prevent loading the external page
await comfyPage.page
.context()
.route('https://support.comfy.org/**', (route) =>
route.fulfill({ body: '<html></html>', contentType: 'text/html' })
)
const popupPromise = comfyPage.page.waitForEvent('popup')
const pagePromise = comfyPage.page.context().waitForEvent('page')
await comfyPage.menu.topbar.triggerTopbarCommand(['Help', 'Support'])
const popup = await popupPromise
const newPage = await pagePromise
const url = new URL(popup.url())
expect(url.hostname).toBe('support.comfy.org')
await newPage.waitForLoadState('networkidle')
await expect(newPage).toHaveURL(/.*support\.comfy\.org.*/)
const url = new URL(newPage.url())
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
await popup.close()
await newPage.close()
})
})

View File

@@ -7,29 +7,22 @@ test.beforeEach(async ({ comfyPage }) => {
})
test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setup()
})
test(
'Report error on unconnected slot',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.canvasOps.clickEmptySpace()
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await expect(
comfyPage.page.locator('[data-testid="error-overlay"]')
).toBeVisible()
await expect(comfyPage.page.locator('.comfy-error-report')).toBeVisible()
await comfyPage.page
.locator('[data-testid="error-overlay"]')
.getByRole('button', { name: 'Dismiss' })
.locator('.p-dialog')
.getByRole('button', { name: 'Close' })
.click()
await comfyPage.page
.locator('[data-testid="error-overlay"]')
.waitFor({ state: 'hidden' })
await comfyPage.page.locator('.comfy-error-report').waitFor({
state: 'hidden'
})
await expect(comfyPage.canvas).toHaveScreenshot(
'execution-error-unconnected-slot.png'
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -10,7 +10,6 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
})

View File

@@ -13,9 +13,6 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
// Wait for the legacy menu to appear and canvas to settle after layout shift.
await comfyPage.page.locator('.comfy-menu').waitFor({ state: 'visible' })
await comfyPage.nextFrame()
})
test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
@@ -736,25 +733,6 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
)
// Wait for V2 persistence debounce to save the modified workflow
const start = Date.now()
await comfyPage.page.waitForFunction((since) => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
const json = window.localStorage.getItem(key)
if (!json) continue
try {
const index = JSON.parse(json)
if (typeof index.updatedAt === 'number' && index.updatedAt >= since) {
return true
}
} catch {
// ignore
}
}
return false
}, start)
await comfyPage.setup({ clearStorage: false })
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
@@ -777,17 +755,10 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for sessionStorage to persist the workflow paths before reloading
// V2 persistence uses sessionStorage with client-scoped keys
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i)
if (key?.startsWith('Comfy.Workflow.OpenPaths:')) {
return true
}
}
return false
})
// Wait for localStorage to persist the workflow paths before reloading
await comfyPage.page.waitForFunction(
() => !!window.localStorage.getItem('Comfy.OpenWorkflowsPaths')
)
await comfyPage.setup({ clearStorage: false })
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -27,7 +27,6 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
})
test.describe('Selection Toolbox', () => {

View File

@@ -5,7 +5,6 @@ import { TestIds } from '../../fixtures/selectors'
test.describe('Properties panel position', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
// Open a sidebar tab to ensure sidebar is visible
await comfyPage.menu.nodeLibraryTab.open()
await comfyPage.actionbar.propertiesButton.click()

View File

@@ -53,7 +53,6 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -5,7 +5,6 @@ import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Node library sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
await comfyPage.settings.setSetting(
'Comfy.NodeLibrary.BookmarksCustomization',

View File

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

View File

@@ -1192,7 +1192,7 @@ button.comfy-queue-btn {
.graphdialog {
min-height: 1em;
background-color: var(--comfy-menu-bg);
z-index: 1500;
z-index: 41; /* z-index is set to 41 here in order to appear over selection-overlay-container which should have a z-index of 40 */
}
.graphdialog .name {

View File

@@ -339,7 +339,7 @@ describe('TopMenuSection', () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activeJobId = 'job-1'
executionStore.activePromptId = 'prompt-1'
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
@@ -429,7 +429,7 @@ describe('TopMenuSection', () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activeJobId = 'job-1'
executionStore.activePromptId = 'prompt-1'
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)

View File

@@ -110,7 +110,6 @@
</Button>
</div>
</div>
<ErrorOverlay />
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
@@ -157,7 +156,6 @@ import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
@@ -292,12 +290,12 @@ const showQueueContextMenu = (event: MouseEvent) => {
}
const handleClearQueue = async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
executionStore.clearInitializationByPromptIds(pendingPromptIds)
}
const openCustomNodeManager = async () => {

View File

@@ -1,197 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type {
JobListItem,
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useCommandStore } from '@/stores/commandStore'
import {
TaskItemImpl,
useQueueSettingsStore,
useQueueStore
} from '@/stores/queueStore'
import ComfyQueueButton from './ComfyQueueButton.vue'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
vi.mock('@/workbench/extensions/manager/utils/graphHasMissingNodes', () => ({
graphHasMissingNodes: () => false
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {}
}
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({
shiftDown: false
})
}))
const SplitButtonStub = defineComponent({
name: 'SplitButton',
props: {
label: {
type: String,
default: ''
},
severity: {
type: String,
default: 'primary'
}
},
template: `
<button
data-testid="split-button"
:data-label="label"
:data-severity="severity"
>
<slot name="icon" />
</button>
`
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
menu: {
run: 'Run',
disabledTooltip: 'Disabled tooltip',
onChange: 'On Change',
onChangeTooltip: 'On change tooltip',
instant: 'Instant',
instantTooltip: 'Instant tooltip',
stopRunInstant: 'Stop Run (Instant)',
stopRunInstantTooltip: 'Stop running',
runWorkflow: 'Run workflow',
runWorkflowFront: 'Run workflow front',
runWorkflowDisabled: 'Run workflow disabled'
}
}
}
})
function createTask(id: string, status: JobStatus): TaskItemImpl {
const job: JobListItem = {
id,
status,
create_time: Date.now(),
priority: 1
}
return new TaskItemImpl(job)
}
function createWrapper() {
const pinia = createTestingPinia({ createSpy: vi.fn })
return mount(ComfyQueueButton, {
global: {
plugins: [pinia, i18n],
directives: {
tooltip: () => {}
},
stubs: {
SplitButton: SplitButtonStub,
BatchCountEdit: true
}
}
})
}
describe('ComfyQueueButton', () => {
it('keeps the run instant presentation while idle even with active jobs', async () => {
const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore()
const queueStore = useQueueStore()
queueSettingsStore.mode = 'instant-idle'
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
await nextTick()
const splitButton = wrapper.get('[data-testid="queue-button"]')
expect(splitButton.attributes('data-label')).toBe('Run (Instant)')
expect(splitButton.attributes('data-severity')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
})
it('switches to stop presentation when instant mode is armed', async () => {
const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore()
queueSettingsStore.mode = 'instant-running'
await nextTick()
const splitButton = wrapper.get('[data-testid="queue-button"]')
expect(splitButton.attributes('data-label')).toBe('Stop Run (Instant)')
expect(splitButton.attributes('data-severity')).toBe('danger')
expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true)
})
it('disarms instant mode without interrupting even when jobs are active', async () => {
const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore()
const queueStore = useQueueStore()
const commandStore = useCommandStore()
queueSettingsStore.mode = 'instant-running'
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
await nextTick()
await wrapper.get('[data-testid="queue-button"]').trigger('click')
await nextTick()
expect(queueSettingsStore.mode).toBe('instant-idle')
const splitButtonWhileStopping = wrapper.get('[data-testid="queue-button"]')
expect(splitButtonWhileStopping.attributes('data-label')).toBe(
'Run (Instant)'
)
expect(splitButtonWhileStopping.attributes('data-severity')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
expect(commandStore.execute).not.toHaveBeenCalled()
const splitButton = wrapper.get('[data-testid="queue-button"]')
expect(queueSettingsStore.mode).toBe('instant-idle')
expect(splitButton.attributes('data-label')).toBe('Run (Instant)')
expect(splitButton.attributes('data-severity')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
})
it('activates instant running mode when queueing again', async () => {
const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore()
const commandStore = useCommandStore()
queueSettingsStore.mode = 'instant-idle'
await nextTick()
await wrapper.get('[data-testid="queue-button"]').trigger('click')
await nextTick()
expect(queueSettingsStore.mode).toBe('instant-running')
expect(commandStore.execute).toHaveBeenCalledWith('Comfy.QueuePrompt', {
metadata: {
subscribe_to_run: false,
trigger_source: 'button'
}
})
})
})

View File

@@ -6,8 +6,8 @@
showDelay: 600
}"
class="comfyui-queue-button"
:label="queueButtonLabel"
:severity="queueButtonSeverity"
:label="String(activeQueueModeMenuItem?.label ?? '')"
severity="primary"
size="small"
:model="queueModeMenuItems"
data-testid="queue-button"
@@ -22,7 +22,7 @@
value: item.tooltip,
showDelay: 600
}"
:variant="item.key === selectedQueueMode ? 'primary' : 'secondary'"
:variant="item.key === queueMode ? 'primary' : 'secondary'"
size="sm"
class="w-full justify-start"
>
@@ -48,11 +48,7 @@ import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
isInstantMode,
isInstantRunningMode,
useQueueSettingsStore
} from '@/stores/queueStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
@@ -67,12 +63,6 @@ const hasMissingNodes = computed(() =>
)
const { t } = useI18n()
type QueueModeMenuKey = 'disabled' | 'change' | 'instant-idle'
const selectedQueueMode = computed<QueueModeMenuKey>(() =>
isInstantMode(queueMode.value) ? 'instant-idle' : queueMode.value
)
const queueModeMenuItemLookup = computed(() => {
const items: Record<string, MenuItem> = {
disabled: {
@@ -96,15 +86,15 @@ const queueModeMenuItemLookup = computed(() => {
}
}
if (!isCloud) {
items['instant-idle'] = {
key: 'instant-idle',
items.instant = {
key: 'instant',
label: `${t('menu.run')} (${t('menu.instant')})`,
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected'
})
queueMode.value = 'instant-idle'
queueMode.value = 'instant'
}
}
}
@@ -114,7 +104,7 @@ const queueModeMenuItemLookup = computed(() => {
const activeQueueModeMenuItem = computed(() => {
// Fallback to disabled mode if current mode is not available (e.g., instant mode in cloud)
return (
queueModeMenuItemLookup.value[selectedQueueMode.value] ||
queueModeMenuItemLookup.value[queueMode.value] ||
queueModeMenuItemLookup.value.disabled
)
})
@@ -122,24 +112,7 @@ const queueModeMenuItems = computed(() =>
Object.values(queueModeMenuItemLookup.value)
)
const isStopInstantAction = computed(() =>
isInstantRunningMode(queueMode.value)
)
const queueButtonLabel = computed(() =>
isStopInstantAction.value
? t('menu.stopRunInstant')
: String(activeQueueModeMenuItem.value?.label ?? '')
)
const queueButtonSeverity = computed(() =>
isStopInstantAction.value ? 'danger' : 'primary'
)
const iconClass = computed(() => {
if (isStopInstantAction.value) {
return 'icon-[lucide--square]'
}
if (hasMissingNodes.value) {
return 'icon-[lucide--triangle-alert]'
}
@@ -149,7 +122,7 @@ const iconClass = computed(() => {
if (queueMode.value === 'disabled') {
return 'icon-[lucide--play]'
}
if (isInstantMode(queueMode.value)) {
if (queueMode.value === 'instant') {
return 'icon-[lucide--fast-forward]'
}
if (queueMode.value === 'change') {
@@ -159,9 +132,6 @@ const iconClass = computed(() => {
})
const queueButtonTooltip = computed(() => {
if (isStopInstantAction.value) {
return t('menu.stopRunInstantTooltip')
}
if (hasMissingNodes.value) {
return t('menu.runWorkflowDisabled')
}
@@ -173,20 +143,11 @@ const queueButtonTooltip = computed(() => {
const commandStore = useCommandStore()
const queuePrompt = async (e: Event) => {
if (isStopInstantAction.value) {
queueMode.value = 'instant-idle'
return
}
const isShiftPressed = 'shiftKey' in e && e.shiftKey
const commandId = isShiftPressed
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
if (isInstantMode(queueMode.value)) {
queueMode.value = 'instant-running'
}
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted'

View File

@@ -1,22 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import MarqueeLine from './MarqueeLine.vue'
describe(MarqueeLine, () => {
it('renders slot content', () => {
const wrapper = mount(MarqueeLine, {
slots: { default: 'Hello World' }
})
expect(wrapper.text()).toBe('Hello World')
})
it('renders content inside a span within the container', () => {
const wrapper = mount(MarqueeLine, {
slots: { default: 'Test Text' }
})
const span = wrapper.find('span')
expect(span.exists()).toBe(true)
expect(span.text()).toBe('Test Text')
})
})

View File

@@ -1,24 +0,0 @@
<template>
<div
class="overflow-hidden [container-type:inline-size] [mask-image:linear-gradient(to_right,black_70%,transparent)] motion-safe:group-hover:[mask-image:none]"
>
<span
class="whitespace-nowrap inline-block min-w-full text-center [--_marquee-end:min(calc(-100%+100cqw),0px)] motion-safe:group-hover:[animation:marquee-scroll_3s_linear_infinite_alternate]"
>
<slot />
</span>
</div>
</template>
<style>
@keyframes marquee-scroll {
0%,
20% {
transform: translateX(0);
}
80%,
100% {
transform: translateX(var(--_marquee-end));
}
}
</style>

View File

@@ -6,12 +6,10 @@
</h2>
<div class="grid grid-cols-2 gap-2">
<template v-for="col in systemColumns" :key="col.field">
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
<div class="font-medium">
{{ col.header }}
</div>
<div :class="cn(isOutdated(col) && 'text-danger-100')">
{{ getDisplayValue(col) }}
</div>
<div>{{ getDisplayValue(col) }}</div>
</template>
</div>
</div>
@@ -49,7 +47,6 @@ import DeviceInfo from '@/components/common/DeviceInfo.vue'
import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
stats: SystemStats
@@ -79,8 +76,7 @@ const localColumns: ColumnDef[] = [
{ field: 'pytorch_version', header: 'Pytorch Version' },
{ field: 'argv', header: 'Arguments' },
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize },
{ field: 'installed_templates_version', header: 'Templates Version' }
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize }
]
/** Columns for cloud distribution */
@@ -101,13 +97,6 @@ const cloudColumns: ColumnDef[] = [
const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
function isOutdated(column: ColumnDef): boolean {
if (column.field !== 'installed_templates_version') return false
const installed = props.stats.system.installed_templates_version
const required = props.stats.system.required_templates_version
return !!installed && !!required && installed !== required
}
const getDisplayValue = (column: ColumnDef) => {
const value = systemInfo.value[column.field]
if (column.formatNumber && typeof value === 'number') {

View File

@@ -1,105 +0,0 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import MarqueeLine from './MarqueeLine.vue'
import TextTickerMultiLine from './TextTickerMultiLine.vue'
type Callback = () => void
const resizeCallbacks: Callback[] = []
const mutationCallbacks: Callback[] = []
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...actual,
useResizeObserver: (_target: unknown, cb: Callback) => {
resizeCallbacks.push(cb)
return { stop: vi.fn() }
},
useMutationObserver: (_target: unknown, cb: Callback) => {
mutationCallbacks.push(cb)
return { stop: vi.fn() }
}
}
})
function mockElementSize(
el: HTMLElement,
clientWidth: number,
scrollWidth: number
) {
Object.defineProperty(el, 'clientWidth', {
value: clientWidth,
configurable: true
})
Object.defineProperty(el, 'scrollWidth', {
value: scrollWidth,
configurable: true
})
}
describe(TextTickerMultiLine, () => {
let wrapper: ReturnType<typeof mount>
afterEach(() => {
wrapper?.unmount()
resizeCallbacks.length = 0
mutationCallbacks.length = 0
})
function mountComponent(text: string) {
wrapper = mount(TextTickerMultiLine, {
slots: { default: text }
})
return wrapper
}
function getMeasureEl(): HTMLElement {
return wrapper.find('[aria-hidden="true"]').element as HTMLElement
}
async function triggerSplitLines() {
resizeCallbacks.forEach((cb) => cb())
await nextTick()
}
it('renders slot content', () => {
mountComponent('Load Checkpoint')
expect(wrapper.text()).toContain('Load Checkpoint')
})
it('renders a single MarqueeLine when text fits', async () => {
mountComponent('Short')
mockElementSize(getMeasureEl(), 200, 100)
await triggerSplitLines()
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(1)
})
it('renders two MarqueeLines when text overflows', async () => {
mountComponent('Load Checkpoint Loader Simple')
mockElementSize(getMeasureEl(), 100, 300)
await triggerSplitLines()
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(2)
})
it('splits text at word boundary when overflowing', async () => {
mountComponent('Load Checkpoint Loader')
mockElementSize(getMeasureEl(), 100, 200)
await triggerSplitLines()
const lines = wrapper.findAllComponents(MarqueeLine)
expect(lines[0].text()).toBe('Load')
expect(lines[1].text()).toBe('Checkpoint Loader')
})
it('has hidden measurement element with aria-hidden', () => {
mountComponent('Test')
const measureEl = wrapper.find('[aria-hidden="true"]')
expect(measureEl.exists()).toBe(true)
expect(measureEl.classes()).toContain('invisible')
})
})

View File

@@ -1,66 +0,0 @@
<template>
<div>
<!-- Hidden single-line measurement element for overflow detection -->
<div
ref="measureRef"
class="invisible absolute inset-x-0 top-0 overflow-hidden whitespace-nowrap pointer-events-none"
aria-hidden="true"
>
<slot />
</div>
<MarqueeLine v-if="!secondLine">
<slot />
</MarqueeLine>
<div v-else class="flex flex-col w-full">
<MarqueeLine>{{ firstLine }}</MarqueeLine>
<MarqueeLine>{{ secondLine }}</MarqueeLine>
</div>
</div>
</template>
<script setup lang="ts">
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
import { ref } from 'vue'
import MarqueeLine from './MarqueeLine.vue'
import { splitTextAtWordBoundary } from '@/utils/textTickerUtils'
const measureRef = ref<HTMLElement | null>(null)
const firstLine = ref('')
const secondLine = ref('')
function splitLines() {
const el = measureRef.value
const text = el?.textContent?.trim()
if (!el || !text) {
firstLine.value = ''
secondLine.value = ''
return
}
const containerWidth = el.clientWidth
const textWidth = el.scrollWidth
if (textWidth <= containerWidth) {
firstLine.value = text
secondLine.value = ''
return
}
const [first, second] = splitTextAtWordBoundary(
text,
containerWidth / textWidth
)
firstLine.value = first
secondLine.value = second
}
useResizeObserver(measureRef, splitLines)
useMutationObserver(measureRef, splitLines, {
childList: true,
characterData: true,
subtree: true
})
</script>

View File

@@ -1,41 +1,37 @@
<template>
<ContextMenuRoot>
<ContextMenuTrigger :disabled="!showContextMenu" as-child>
<TreeRoot
:expanded="[...expandedKeys]"
:items="root.children ?? []"
:get-key="(item) => item.key"
:get-children="
(item) => (item.children?.length ? item.children : undefined)
"
class="m-0 p-0 pb-6"
<TreeRoot
:expanded="[...expandedKeys]"
:items="root.children ?? []"
:get-key="(item) => item.key"
:get-children="
(item) => (item.children?.length ? item.children : undefined)
"
class="m-0 p-0 pb-6"
>
<TreeVirtualizer
v-slot="{ item }"
:estimate-size="36"
:text-content="(item) => item.value.label ?? ''"
>
<TreeVirtualizer
v-slot="{ item }"
:estimate-size="36"
:text-content="(item) => item.value.label ?? ''"
<TreeExplorerV2Node
:item="
item as FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
"
@node-click="
(node: RenderedTreeExplorerNode<ComfyNodeDefImpl>, e: MouseEvent) =>
emit('nodeClick', node, e)
"
>
<TreeExplorerV2Node
:item="
item as FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
"
@node-click="
(
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
e: MouseEvent
) => emit('nodeClick', node, e)
"
>
<template #folder="{ node }">
<slot name="folder" :node="node" />
</template>
<template #node="{ node }">
<slot name="node" :node="node" />
</template>
</TreeExplorerV2Node>
</TreeVirtualizer>
</TreeRoot>
</ContextMenuTrigger>
<template #folder="{ node }">
<slot name="folder" :node="node" />
</template>
<template #node="{ node }">
<slot name="node" :node="node" />
</template>
</TreeExplorerV2Node>
</TreeVirtualizer>
</TreeRoot>
<ContextMenuPortal v-if="showContextMenu">
<ContextMenuContent
@@ -53,11 +49,7 @@
"
class="size-4"
/>
{{
isCurrentNodeBookmarked
? $t('sideToolbar.nodeLibraryTab.sections.unfavoriteNode')
: $t('sideToolbar.nodeLibraryTab.sections.favoriteNode')
}}
{{ $t('sideToolbar.nodeLibraryTab.sections.favorites') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
@@ -71,7 +63,6 @@ import {
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuTrigger,
TreeRoot,
TreeVirtualizer
} from 'reka-ui'

View File

@@ -80,6 +80,10 @@ describe('TreeExplorerV2Node', () => {
global: {
stubs: {
TreeItem: treeItemStub.stub,
ContextMenuTrigger: {
name: 'ContextMenuTrigger',
template: '<div data-testid="context-menu-trigger"><slot /></div>'
},
Teleport: { template: '<div />' }
},
provide: {
@@ -141,12 +145,36 @@ describe('TreeExplorerV2Node', () => {
})
describe('context menu', () => {
it('sets contextMenuNode when contextmenu event is triggered on node', async () => {
it('renders ContextMenuTrigger when showContextMenu is true for nodes', () => {
const { wrapper } = mountComponent({
item: createMockItem('node'),
showContextMenu: true
})
expect(
wrapper.find('[data-testid="context-menu-trigger"]').exists()
).toBe(true)
})
it('does not render ContextMenuTrigger for folder items', () => {
const { wrapper } = mountComponent({
item: createMockItem('folder')
})
expect(
wrapper.find('[data-testid="context-menu-trigger"]').exists()
).toBe(false)
})
it('sets contextMenuNode when contextmenu event is triggered', async () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
const nodeItem = createMockItem('node')
const { wrapper } = mountComponent(
{ item: nodeItem },
{
item: nodeItem,
showContextMenu: true
},
{
provide: {
[InjectKeyContextMenuNode as symbol]: contextMenuNode
@@ -159,24 +187,6 @@ describe('TreeExplorerV2Node', () => {
expect(contextMenuNode.value).toEqual(nodeItem.value)
})
it('does not set contextMenuNode for folder items', async () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
const { wrapper } = mountComponent(
{ item: createMockItem('folder') },
{
provide: {
[InjectKeyContextMenuNode as symbol]: contextMenuNode
}
}
)
const folderDiv = wrapper.find('div.group\\/tree-node')
await folderDiv.trigger('contextmenu')
expect(contextMenuNode.value).toBeNull()
})
})
describe('rendering', () => {

View File

@@ -5,26 +5,27 @@
:level="item.level"
as-child
>
<!-- Node -->
<div
v-if="item.value.type === 'node'"
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
draggable="true"
@click.stop="handleClick($event, handleToggle, handleSelect)"
@contextmenu="handleContextMenu"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
>
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
<slot name="node" :node="item.value">
{{ item.value.label }}
</slot>
</span>
</div>
<!-- Node with context menu -->
<ContextMenuTrigger v-if="item.value.type === 'node'" as-child>
<div
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
draggable="true"
@click.stop="handleClick($event, handleToggle, handleSelect)"
@contextmenu="handleContextMenu"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
>
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
<slot name="node" :node="item.value">
{{ item.value.label }}
</slot>
</span>
</div>
</ContextMenuTrigger>
<!-- Folder -->
<div
@@ -68,7 +69,7 @@
<script setup lang="ts">
import type { FlattenedItem } from 'reka-ui'
import { TreeItem } from 'reka-ui'
import { ContextMenuTrigger, TreeItem } from 'reka-ui'
import { computed, inject } from 'vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'

View File

@@ -73,10 +73,6 @@ function getDialogPt(item: {
<style>
@reference '../../assets/css/style.css';
.global-dialog {
max-width: calc(100vw - 1rem);
}
.global-dialog .p-dialog-header {
@apply p-2 2xl:p-[var(--p-dialog-header-padding)];
@apply pb-0;

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col break-words px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
class="flex flex-col px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
>
<p v-if="promptTextReal">
{{ promptTextReal }}

View File

@@ -1,5 +1,5 @@
<template>
<section class="w-full flex flex-wrap gap-2 justify-end px-2 pb-2">
<section class="w-full flex gap-2 justify-end px-2 pb-2">
<Button :disabled variant="textonly" autofocus @click="$emit('cancel')">
{{ cancelTextX }}
</Button>

View File

@@ -1,45 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import ConfirmationDialogContent from './ConfirmationDialogContent.vue'
type Props = ComponentProps<typeof ConfirmationDialogContent>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
describe('ConfirmationDialogContent', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
function mountComponent(props: Partial<Props> = {}) {
return mount(ConfirmationDialogContent, {
global: {
plugins: [PrimeVue, i18n]
},
props: {
message: 'Test message',
type: 'default',
onConfirm: vi.fn(),
...props
} as Props
})
}
it('renders long messages without breaking layout', () => {
const longFilename =
'workflow_checkpoint_' + 'a'.repeat(200) + '.safetensors'
const wrapper = mountComponent({ message: longFilename })
expect(wrapper.text()).toContain(longFilename)
})
})

View File

@@ -1,24 +1,21 @@
<template>
<section class="m-2 mt-4 flex flex-col gap-6 whitespace-pre-wrap break-words">
<div>
<span>{{ message }}</span>
<ul v-if="itemList?.length" class="m-0 mt-2 flex flex-col gap-2 pl-4">
<li v-for="item of itemList" :key="item">
{{ item }}
</li>
</ul>
<Message
v-if="hint"
class="mt-2"
icon="pi pi-info-circle"
severity="secondary"
size="small"
variant="simple"
>
{{ hint }}
</Message>
</div>
<div class="flex shrink-0 flex-wrap justify-end gap-4">
<section class="prompt-dialog-content m-2 mt-4 flex flex-col gap-6">
<span>{{ message }}</span>
<ul v-if="itemList?.length" class="m-0 flex flex-col gap-2 pl-4">
<li v-for="item of itemList" :key="item">
{{ item }}
</li>
</ul>
<Message
v-if="hint"
icon="pi pi-info-circle"
severity="secondary"
size="small"
variant="simple"
>
{{ hint }}
</Message>
<div class="flex justify-end gap-4">
<div
v-if="type === 'overwriteBlueprint'"
class="flex flex-col justify-start gap-1"
@@ -154,3 +151,9 @@ const onConfirm = () => {
useDialogStore().closeDialog()
}
</script>
<style lang="css" scoped>
.prompt-dialog-content {
white-space: pre-wrap;
}
</style>

View File

@@ -13,7 +13,7 @@
class="about-badge inline-flex items-center no-underline"
:title="badge.url"
>
<Tag class="mr-2" :severity="badge.severity">
<Tag class="mr-2">
<template #icon>
<i :class="[badge.icon, 'mr-2 text-xl']" />
</template>

View File

@@ -1,105 +0,0 @@
<template>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="-translate-y-3 opacity-0"
enter-to-class="translate-y-0 opacity-100"
>
<div v-if="isVisible" class="flex justify-end w-full pointer-events-none">
<div
role="alert"
aria-live="assertive"
data-testid="error-overlay"
class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-interface-stroke bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
>
<!-- Header -->
<div class="flex h-12 items-center gap-2 px-4">
<span class="flex-1 text-sm font-bold text-destructive-background">
{{ errorCountLabel }}
</span>
<Button
variant="muted-textonly"
size="icon-sm"
:aria-label="t('g.close')"
@click="dismiss"
>
<i class="icon-[lucide--x] block size-5 leading-none" />
</Button>
</div>
<!-- Body -->
<div class="px-4 pb-3">
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
<li
v-for="(message, idx) in groupedErrorMessages"
:key="idx"
class="flex items-baseline gap-2 text-sm leading-snug text-muted-foreground min-w-0"
>
<span
class="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground"
/>
<span class="break-words line-clamp-3 whitespace-pre-wrap">{{
message
}}</span>
</li>
</ul>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-3">
<Button variant="muted-textonly" size="unset" @click="dismiss">
{{ t('g.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="seeErrors">
{{ t('errorOverlay.seeErrors') }}
</Button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionStore } from '@/stores/executionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
const { t } = useI18n()
const executionStore = useExecutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionStore)
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
const errorCountLabel = computed(() =>
t(
'errorOverlay.errorCount',
{ count: totalErrorCount.value },
totalErrorCount.value
)
)
const isVisible = computed(
() => isErrorOverlayOpen.value && totalErrorCount.value > 0
)
function dismiss() {
executionStore.dismissErrorOverlay()
}
function seeErrors() {
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionStore.dismissErrorOverlay()
}
</script>

View File

@@ -1,80 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import GradientSlider from './GradientSlider.vue'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import { interpolateStops, stopsToGradient } from './gradients'
const TEST_STOPS: ColorStop[] = [
{ offset: 0, color: [0, 0, 0] },
{ offset: 1, color: [255, 255, 255] }
]
function mountSlider(props: {
stops?: ColorStop[]
modelValue: number
min?: number
max?: number
step?: number
}) {
return mount(GradientSlider, {
props: { stops: TEST_STOPS, ...props }
})
}
describe('GradientSlider', () => {
it('passes min, max, step to SliderRoot', () => {
const wrapper = mountSlider({
modelValue: 50,
min: -100,
max: 100,
step: 5
})
const thumb = wrapper.find('[role="slider"]')
expect(thumb.attributes('aria-valuemin')).toBe('-100')
expect(thumb.attributes('aria-valuemax')).toBe('100')
})
it('renders slider root with track and thumb', () => {
const wrapper = mountSlider({ modelValue: 0 })
expect(wrapper.find('[data-slider-impl]').exists()).toBe(true)
expect(wrapper.find('[role="slider"]').exists()).toBe(true)
})
it('does not render SliderRange', () => {
const wrapper = mountSlider({ modelValue: 50 })
expect(wrapper.find('[data-slot="slider-range"]').exists()).toBe(false)
})
})
describe('stopsToGradient', () => {
it('returns transparent for empty stops', () => {
expect(stopsToGradient([])).toBe('transparent')
})
})
describe('interpolateStops', () => {
it('returns transparent for empty stops', () => {
expect(interpolateStops([], 0.5)).toBe('transparent')
})
it('returns start color at t=0', () => {
expect(interpolateStops(TEST_STOPS, 0)).toBe('rgb(0,0,0)')
})
it('returns end color at t=1', () => {
expect(interpolateStops(TEST_STOPS, 1)).toBe('rgb(255,255,255)')
})
it('returns midpoint color at t=0.5', () => {
expect(interpolateStops(TEST_STOPS, 0.5)).toBe('rgb(128,128,128)')
})
it('clamps values below 0', () => {
expect(interpolateStops(TEST_STOPS, -1)).toBe('rgb(0,0,0)')
})
it('clamps values above 1', () => {
expect(interpolateStops(TEST_STOPS, 2)).toBe('rgb(255,255,255)')
})
})

View File

@@ -1,90 +0,0 @@
<script setup lang="ts">
import { SliderRoot, SliderThumb, SliderTrack } from 'reka-ui'
import { computed, ref } from 'vue'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import {
interpolateStops,
stopsToGradient
} from '@/components/gradientslider/gradients'
import { cn } from '@/utils/tailwindUtil'
const {
stops,
min = 0,
max = 100,
step = 1,
disabled = false,
ariaLabel
} = defineProps<{
stops: ColorStop[]
min?: number
max?: number
step?: number
disabled?: boolean
ariaLabel?: string
}>()
const modelValue = defineModel<number>({ required: true })
const sliderValue = computed({
get: () => [modelValue.value],
set: (v: number[]) => {
if (v.length) modelValue.value = v[0]
}
})
const gradient = computed(() => stopsToGradient(stops))
const thumbColor = computed(() => {
const t = max === min ? 0 : (modelValue.value - min) / (max - min)
return interpolateStops(stops, t)
})
const pressed = ref(false)
</script>
<template>
<SliderRoot
v-model="sliderValue"
:min
:max
:step
:disabled
:class="
cn(
'relative flex w-full touch-none items-center select-none',
'data-[disabled]:opacity-50'
)
"
:style="{ '--reka-slider-thumb-transform': 'translate(-50%, -50%)' }"
@slide-start="pressed = true"
@slide-move="pressed = true"
@slide-end="pressed = false"
>
<SliderTrack
:class="
cn(
'relative h-2.5 w-full grow cursor-pointer overflow-visible rounded-full',
'before:absolute before:-inset-2 before:block before:bg-transparent'
)
"
:style="{ background: gradient }"
>
<SliderThumb
:class="
cn(
'block size-4 shrink-0 cursor-grab rounded-full shadow-md ring-1 ring-black/25 top-1/2',
'transition-[color,box-shadow,background-color]',
'before:absolute before:-inset-1.5 before:block before:rounded-full before:bg-transparent',
'hover:ring-2 hover:ring-black/40 focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:outline-hidden',
'disabled:pointer-events-none disabled:opacity-50',
{ 'cursor-grabbing': pressed }
)
"
:style="{ backgroundColor: thumbColor }"
:aria-label
/>
</SliderTrack>
</SliderRoot>
</template>

View File

@@ -1,40 +0,0 @@
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
export function stopsToGradient(stops: ColorStop[]): string {
if (!stops.length) return 'transparent'
const colors = stops.map(
({ offset, color: [r, g, b] }) => `rgb(${r},${g},${b}) ${offset * 100}%`
)
return `linear-gradient(to right, ${colors.join(', ')})`
}
export function interpolateStops(stops: ColorStop[], t: number): string {
if (!stops.length) return 'transparent'
const clamped = Math.max(0, Math.min(1, t))
if (clamped <= stops[0].offset) {
const [r, g, b] = stops[0].color
return `rgb(${r},${g},${b})`
}
for (let i = 0; i < stops.length - 1; i++) {
const {
offset: o1,
color: [r1, g1, b1]
} = stops[i]
const {
offset: o2,
color: [r2, g2, b2]
} = stops[i + 1]
if (clamped >= o1 && clamped <= o2) {
const f = o2 === o1 ? 0 : (clamped - o1) / (o2 - o1)
const r = Math.round(r1 + (r2 - r1) * f)
const g = Math.round(g1 + (g2 - g1) * f)
const b = Math.round(b1 + (b2 - b1) * f)
return `rgb(${r},${g},${b})`
}
}
const [r, g, b] = stops[stops.length - 1].color
return `rgb(${r},${g},${b})`
}

View File

@@ -152,7 +152,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'

View File

@@ -1,104 +0,0 @@
<template>
<div class="flex items-center gap-1">
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-[14rem] flex-col items-stretch font-inter">
<Button
data-testid="docked-job-history-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="sm"
@click="onToggleDockedJobHistory"
>
<span class="flex items-center gap-2">
<i
class="icon-[lucide--panel-left-close] size-4 text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.dockedJobHistory')
}}</span>
</span>
<i
v-if="isQueuePanelV2Enabled"
class="icon-[lucide--check] size-4"
/>
</Button>
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
<template v-if="showClearHistoryAction">
<div class="my-1 border-t border-interface-stroke" />
<Button
data-testid="clear-history-action"
class="h-auto min-h-0 w-full items-start justify-start whitespace-normal"
variant="textonly"
size="sm"
@click="onClearHistoryFromMenu(close)"
>
<i
class="icon-[lucide--trash-2] size-4 shrink-0 self-center text-destructive-background"
/>
<span
class="flex flex-col items-start break-words text-left leading-tight"
>
<span class="text-sm font-light">
{{ t('sideToolbar.queueProgressOverlay.clearHistory') }}
</span>
<span class="text-xs text-text-secondary font-light">
{{
t(
'sideToolbar.queueProgressOverlay.clearHistoryMenuAssetsNote'
)
}}
</span>
</span>
</Button>
</template>
</div>
</template>
</Popover>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
const emit = defineEmits<{
(e: 'clearHistory'): void
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const showClearHistoryAction = computed(() => !isCloud)
const onClearHistoryFromMenu = (close: () => void) => {
close()
emit('clearHistory')
}
const onToggleDockedJobHistory = async () => {
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled.value)
}
</script>

View File

@@ -51,7 +51,6 @@ import type {
} from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
@@ -84,7 +83,6 @@ const emit = defineEmits<{
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
@@ -104,9 +102,9 @@ const onMenuItem = (item: JobListItem, event: Event) => {
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
const onJobMenuAction = async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
})
}
</script>

View File

@@ -1,40 +1,28 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent, h } from 'vue'
import { defineComponent } from 'vue'
const popoverCloseSpy = vi.fn()
const popoverToggleSpy = vi.fn()
const popoverHideSpy = vi.fn()
vi.mock('@/components/ui/Popover.vue', () => {
vi.mock('primevue/popover', () => {
const PopoverStub = defineComponent({
name: 'Popover',
setup(_, { slots }) {
return () =>
h('div', [
slots.button?.(),
slots.default?.({
close: () => {
popoverCloseSpy()
}
})
])
setup(_, { slots, expose }) {
const toggle = (event: Event) => {
popoverToggleSpy(event)
}
const hide = () => {
popoverHideSpy()
}
expose({ toggle, hide })
return () => slots.default?.()
}
})
return { default: PopoverStub }
})
const mockGetSetting = vi.fn((key: string) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const mockSetSetting = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting,
set: mockSetSetting
})
}))
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
@@ -55,8 +43,7 @@ const i18n = createI18n({
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
moreOptions: 'More options',
clearHistory: 'Clear history',
dockedJobHistory: 'Docked Job History'
clearHistory: 'Clear history'
}
}
}
@@ -79,11 +66,6 @@ const mountHeader = (props = {}) =>
})
describe('QueueOverlayHeader', () => {
beforeEach(() => {
popoverCloseSpy.mockClear()
mockSetSetting.mockClear()
})
it('renders header title and concurrent indicator when enabled', () => {
const wrapper = mountHeader({ concurrentWorkflowCount: 3 })
@@ -120,33 +102,19 @@ describe('QueueOverlayHeader', () => {
)
})
it('emits clear history from the menu', async () => {
it('toggles popover and emits clear history', async () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
const wrapper = mountHeader()
expect(wrapper.find('button[aria-label="More options"]').exists()).toBe(
true
)
const moreButton = wrapper.get('button[aria-label="More options"]')
await moreButton.trigger('click')
expect(popoverToggleSpy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith('More')
const clearHistoryButton = wrapper.get(
'[data-testid="clear-history-action"]'
)
const clearHistoryButton = wrapper.get('button[aria-label="Clear history"]')
await clearHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(popoverHideSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
it('toggles docked job history setting from the menu', async () => {
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', false)
})
})

View File

@@ -28,7 +28,6 @@
</span>
<Button
v-if="queuedCount > 0"
v-tooltip.top="clearAllJobsTooltip"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@@ -37,17 +36,62 @@
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
<JobHistoryActionsMenu @clear-history="$emit('clearHistory')" />
<div v-if="!isCloud" class="flex items-center gap-1">
<Button
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
@click="onMoreClick"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</Button>
<Popover
ref="morePopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<Button
class="w-full justify-start"
variant="textonly"
size="sm"
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
@click="onClearHistoryFromMenu"
>
<i class="icon-[lucide--file-x-2] size-4 text-muted" />
<span>{{
t('sideToolbar.queueProgressOverlay.clearHistory')
}}</span>
</Button>
</div>
</Popover>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Popover from 'primevue/popover'
import type { PopoverMethods } from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
defineProps<{
headerTitle: string
@@ -56,13 +100,21 @@ defineProps<{
queuedCount: number
}>()
defineEmits<{
const emit = defineEmits<{
(e: 'clearHistory'): void
(e: 'clearQueued'): void
}>()
const { t } = useI18n()
const clearAllJobsTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearAllJobsTooltip'))
)
const morePopoverRef = ref<PopoverMethods | null>(null)
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const onMoreClick = (event: MouseEvent) => {
morePopoverRef.value?.toggle(event)
}
const onClearHistoryFromMenu = () => {
morePopoverRef.value?.hide()
emit('clearHistory')
}
</script>

View File

@@ -7,7 +7,6 @@ import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import { i18n } from '@/i18n'
import type { JobStatus } from '@/platform/remote/comfyui/jobs/jobTypes'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
@@ -21,12 +20,7 @@ const QueueOverlayExpandedStub = defineComponent({
required: true
}
},
template: `
<div>
<div data-testid="expanded-title">{{ headerTitle }}</div>
<button data-testid="show-assets-button" @click="$emit('show-assets')" />
</div>
`
template: '<div data-testid="expanded-title">{{ headerTitle }}</div>'
})
function createTask(id: string, status: JobStatus): TaskItemImpl {
@@ -47,11 +41,10 @@ const mountComponent = (
stubActions: false
})
const queueStore = useQueueStore(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
queueStore.runningTasks = runningTasks
queueStore.pendingTasks = pendingTasks
const wrapper = mount(QueueProgressOverlay, {
return mount(QueueProgressOverlay, {
props: {
expanded: true
},
@@ -67,8 +60,6 @@ const mountComponent = (
}
}
})
return { wrapper, sidebarTabStore }
}
describe('QueueProgressOverlay', () => {
@@ -77,7 +68,7 @@ describe('QueueProgressOverlay', () => {
})
it('shows expanded header with running and queued labels', () => {
const { wrapper } = mountComponent(
const wrapper = mountComponent(
[
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
@@ -91,10 +82,7 @@ describe('QueueProgressOverlay', () => {
})
it('shows only running label when queued count is zero', () => {
const { wrapper } = mountComponent(
[createTask('running-1', 'in_progress')],
[]
)
const wrapper = mountComponent([createTask('running-1', 'in_progress')], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'1 running'
@@ -102,22 +90,10 @@ describe('QueueProgressOverlay', () => {
})
it('shows job queue title when there are no active jobs', () => {
const { wrapper } = mountComponent([], [])
const wrapper = mountComponent([], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'Job Queue'
)
})
it('toggles the assets sidebar tab when show-assets is clicked', async () => {
const { wrapper, sidebarTabStore } = mountComponent([], [])
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
await wrapper.get('[data-testid="show-assets-button"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
await wrapper.get('[data-testid="show-assets-button"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
})

View File

@@ -22,7 +22,7 @@
:queued-count="queuedCount"
:displayed-job-groups="displayedJobGroups"
:has-failed-jobs="hasFailedJobs"
@show-assets="toggleAssetsSidebar"
@show-assets="openAssetsSidebar"
@clear-history="onClearHistoryFromMenu"
@clear-queued="cancelQueuedWorkflows"
@cancel-item="onCancelItem"
@@ -59,10 +59,10 @@ import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
@@ -71,6 +71,7 @@ import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -96,9 +97,9 @@ const queueStore = useQueueStore()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const sidebarTabStore = useSidebarTabStore()
const dialogStore = useDialogStore()
const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const {
@@ -203,22 +204,22 @@ const {
const displayedJobGroups = computed(() => groupedJobItems.value)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const jobId = item.taskRef?.jobId
if (!jobId) return
const promptId = item.taskRef?.promptId
if (!promptId) return
if (item.state === 'running' || item.state === 'initialization') {
// Running/initializing jobs: interrupt execution
// Cloud backend uses deleteItem, local uses interrupt
if (isCloud) {
await api.deleteItem('queue', jobId)
await api.deleteItem('queue', promptId)
} else {
await api.interrupt(jobId)
await api.interrupt(promptId)
}
executionStore.clearInitializationByJobId(jobId)
executionStore.clearInitializationByPromptId(promptId)
await queueStore.update()
} else if (item.state === 'pending') {
// Pending jobs: remove from queue
await api.deleteItem('queue', jobId)
await api.deleteItem('queue', promptId)
await queueStore.update()
}
})
@@ -242,21 +243,17 @@ const viewAllJobs = () => {
setExpanded(true)
}
const toggleAssetsSidebar = () => {
sidebarTabStore.toggleSidebarTab('assets')
}
const openAssetsSidebar = () => {
sidebarTabStore.activeSidebarTabId = 'assets'
}
const focusAssetInSidebar = async (item: JobListItem) => {
const task = item.taskRef
const jobId = task?.jobId
const promptId = task?.promptId
const preview = task?.previewOutput
if (!jobId || !preview) return
if (!promptId || !preview) return
const assetId = String(jobId)
const assetId = String(promptId)
openAssetsSidebar()
await nextTick()
await assetsStore.updateHistory()
@@ -278,41 +275,62 @@ const inspectJobAsset = wrapWithErrorHandlingAsync(
)
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
// Capture pending jobIds before clearing
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
// Capture pending promptIds before clearing
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
// Clear initialization state for removed jobs
executionStore.clearInitializationByJobIds(pendingJobIds)
// Clear initialization state for removed prompts
executionStore.clearInitializationByPromptIds(pendingPromptIds)
})
const interruptAll = wrapWithErrorHandlingAsync(async () => {
const tasks = queueStore.runningTasks
const jobIds = tasks
.map((task) => task.jobId)
const promptIds = tasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
if (!jobIds.length) return
if (!promptIds.length) return
// Cloud backend supports cancelling specific jobs via /queue delete,
// while /interrupt always targets the "first" job. Use the targeted API
// on cloud to ensure we cancel the workflow the user clicked.
if (isCloud) {
await Promise.all(jobIds.map((id) => api.deleteItem('queue', id)))
executionStore.clearInitializationByJobIds(jobIds)
await Promise.all(promptIds.map((id) => api.deleteItem('queue', id)))
executionStore.clearInitializationByPromptIds(promptIds)
await queueStore.update()
return
}
await Promise.all(jobIds.map((id) => api.interrupt(id)))
executionStore.clearInitializationByJobIds(jobIds)
await Promise.all(promptIds.map((id) => api.interrupt(id)))
executionStore.clearInitializationByPromptIds(promptIds)
await queueStore.update()
})
const showClearHistoryDialog = () => {
dialogStore.showDialog({
key: 'queue-clear-history',
component: QueueClearHistoryDialog,
dialogComponentProps: {
headless: true,
closable: false,
closeOnEscape: true,
dismissableMask: true,
pt: {
root: {
class: 'max-w-[360px] w-auto bg-transparent border-none shadow-none'
},
content: {
class: '!p-0 bg-transparent'
}
}
}
})
}
const onClearHistoryFromMenu = () => {
showQueueClearHistoryDialog()
showClearHistoryDialog()
}
</script>

View File

@@ -1,110 +0,0 @@
<template>
<div class="flex flex-col gap-4 px-3 pb-4">
<div
v-for="group in displayedJobGroups"
:key="group.key"
class="flex flex-col gap-2"
>
<div class="text-xs leading-none text-text-secondary">
{{ group.label }}
</div>
<AssetsListItem
v-for="job in group.items"
:key="job.id"
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="hoveredJobId = job.id"
@mouseleave="onJobLeave(job.id)"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="$emit('cancelItem', job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="$emit('deleteItem', job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="$emit('viewItem', job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
defineProps<{ displayedJobGroups: JobGroup[] }>()
defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
(e: 'viewItem', item: JobListItem): void
}>()
const { t } = useI18n()
const hoveredJobId = ref<string | null>(null)
const onJobLeave = (jobId: string) => {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
const isCancelable = (job: JobListItem) =>
job.showClear !== false && isActiveJobState(job.state)
const isFailedDeletable = (job: JobListItem) =>
job.showClear !== false && job.state === 'failed'
const getJobIconClass = (job: JobListItem): string | undefined => {
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
return 'animate-spin'
}
return undefined
}
</script>

View File

@@ -1,81 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
const buttonStub = {
props: {
disabled: {
type: Boolean,
default: false
}
},
template: `
<div
class="button-stub"
:data-disabled="String(disabled)"
>
<slot />
</div>
`
}
const createEntries = (): MenuEntry[] => [
{ key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
{
key: 'disabled',
label: 'Disabled action',
disabled: true,
onClick: vi.fn()
},
{ kind: 'divider', key: 'divider-1' }
]
const mountComponent = (entries: MenuEntry[]) =>
mount(JobContextMenu, {
props: { entries },
global: {
stubs: {
Popover: {
template: '<div class="popover-stub"><slot /></div>'
},
Button: buttonStub
}
}
})
describe('JobContextMenu', () => {
it('passes disabled state to action buttons', () => {
const wrapper = mountComponent(createEntries())
const buttons = wrapper.findAll('.button-stub')
expect(buttons).toHaveLength(2)
expect(buttons[0].attributes('data-disabled')).toBe('false')
expect(buttons[1].attributes('data-disabled')).toBe('true')
})
it('emits action for enabled entries', async () => {
const entries = createEntries()
const wrapper = mountComponent(entries)
await wrapper.findAll('.button-stub')[0].trigger('click')
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
})
it('does not emit action for disabled entries', async () => {
const wrapper = mountComponent([
{
key: 'disabled',
label: 'Disabled action',
disabled: true,
onClick: vi.fn()
}
])
await wrapper.get('.button-stub').trigger('click')
expect(wrapper.emitted('action')).toBeUndefined()
})
})

View File

@@ -26,7 +26,6 @@
variant="textonly"
size="sm"
:aria-label="entry.label"
:disabled="entry.disabled"
@click="onEntry(entry)"
>
<i
@@ -69,7 +68,6 @@ function hide() {
}
function onEntry(entry: MenuEntry) {
if (entry.kind === 'divider' || entry.disabled) return
emit('action', entry)
}

View File

@@ -37,7 +37,7 @@ function resetStores() {
queue.runningTasks = []
queue.historyTasks = []
exec.nodeProgressStatesByJob = {}
exec.nodeProgressStatesByPrompt = {}
}
function makeTask(
@@ -145,10 +145,10 @@ export const Queued: Story = {
makePendingTask('job-older-2', 101, Date.now() - 30_000)
)
// Queued at (in metadata on job tuple)
// Queued at (in metadata on prompt[4])
// One running workflow
exec.nodeProgressStatesByJob = {
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 1,
@@ -198,7 +198,7 @@ export const QueuedParallel: Story = {
]
// Two parallel workflows running
exec.nodeProgressStatesByJob = {
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 1,
@@ -248,7 +248,7 @@ export const Running: Story = {
makeHistoryTask('hist-r3', 252, 60, true)
]
exec.nodeProgressStatesByJob = {
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 5,
@@ -293,7 +293,7 @@ export const QueuedZeroAheadSingleRunning: Story = {
queue.runningTasks = [makeRunningTaskWithStart('running-1', 505, 20)]
exec.nodeProgressStatesByJob = {
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 1,
@@ -341,7 +341,7 @@ export const QueuedZeroAheadMultiRunning: Story = {
makeRunningTaskWithStart('running-b', 507, 10)
]
exec.nodeProgressStatesByJob = {
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 2,

View File

@@ -139,7 +139,7 @@ const copyJobId = () => void copyToClipboard(jobIdValue.value)
const taskForJob = computed(() => {
const pid = props.jobId
const findIn = (arr: TaskItemImpl[]) =>
arr.find((t) => String(t.jobId ?? '') === String(pid))
arr.find((t) => String(t.promptId ?? '') === String(pid))
return (
findIn(queueStore.pendingTasks) ||
findIn(queueStore.runningTasks) ||
@@ -151,7 +151,9 @@ const taskForJob = computed(() => {
const jobState = computed(() => {
const task = taskForJob.value
if (!task) return null
const isInitializing = executionStore.isJobInitializing(String(task?.jobId))
const isInitializing = executionStore.isPromptInitializing(
String(task?.promptId)
)
return jobStateFromTask(task, isInitializing)
})

View File

@@ -1,199 +0,0 @@
<template>
<div class="flex min-w-0 items-center gap-2">
<SearchBox
v-if="showSearch"
:model-value="searchQuery"
class="min-w-0 flex-1"
:placeholder="searchPlaceholderText"
@update:model-value="onSearchQueryUpdate"
/>
<div
class="flex shrink-0 items-center gap-2"
:class="{ 'ml-2': !showSearch }"
>
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="filterTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
>
<i class="icon-[lucide--list-filter] size-4" />
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-[12rem] flex-col items-stretch">
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('all', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] size-4"
/>
</Button>
<div class="mx-2 mt-1 h-px" />
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('current', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</Button>
</div>
</template>
</Popover>
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="sortTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-[12rem] flex-col items-stretch">
<template v-for="(mode, index) in jobSortModes" :key="mode">
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectSortMode(mode, close)"
>
<span>{{ sortLabel(mode) }}</span>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] size-4 text-text-secondary"
/>
</Button>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
/>
</template>
</div>
</template>
</Popover>
<Button
v-if="showAssetsAction"
v-tooltip.top="showAssetsTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
@click="emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'
import type { JobSortMode } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
const {
hideShowAssetsAction = false,
showSearch = false,
searchPlaceholder
} = defineProps<{
hideShowAssetsAction?: boolean
showSearch?: boolean
searchPlaceholder?: string
}>()
const selectedWorkflowFilter = defineModel<'all' | 'current'>(
'selectedWorkflowFilter',
{ required: true }
)
const selectedSortMode = defineModel<JobSortMode>('selectedSortMode', {
required: true
})
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const emit = defineEmits<{
(e: 'showAssets'): void
}>()
const { t } = useI18n()
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
)
const sortTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
)
const showAssetsTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
)
const showAssetsAction = computed(() => !hideShowAssetsAction)
const searchPlaceholderText = computed(
() => searchPlaceholder ?? t('sideToolbar.queueProgressOverlay.searchJobs')
)
const selectWorkflowFilter = (value: 'all' | 'current') => {
selectedWorkflowFilter.value = value
}
const onSelectWorkflowFilter = (
value: 'all' | 'current',
close: () => void
) => {
selectWorkflowFilter(value)
close()
}
const selectSortMode = (value: JobSortMode) => {
selectedSortMode.value = value
}
const onSelectSortMode = (value: JobSortMode, close: () => void) => {
selectSortMode(value)
close()
}
const onSearchQueryUpdate = (value: string | undefined) => {
searchQuery.value = value ?? ''
}
const sortLabel = (mode: JobSortMode) => {
if (mode === 'mostRecent') {
return t('queue.jobList.sortMostRecent')
}
if (mode === 'totalGenerationTime') {
return t('queue.jobList.sortTotalGenerationTime')
}
return ''
}
</script>

View File

@@ -1,45 +0,0 @@
<template>
<div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap">
<Button
v-for="tab in visibleJobTabs"
:key="tab"
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
size="sm"
@click="$emit('update:selectedJobTab', tab)"
>
{{ tabLabel(tab) }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { jobTabs } from '@/composables/queue/useJobList'
import type { JobTab } from '@/composables/queue/useJobList'
const { selectedJobTab, hasFailedJobs } = defineProps<{
selectedJobTab: JobTab
hasFailedJobs: boolean
}>()
defineEmits<{
(e: 'update:selectedJobTab', value: JobTab): void
}>()
const { t } = useI18n()
const visibleJobTabs = computed(() =>
hasFailedJobs ? jobTabs : jobTabs.filter((tab) => tab !== 'Failed')
)
const tabLabel = (tab: JobTab) => {
if (tab === 'All') return t('g.all')
if (tab === 'Completed') return t('g.completed')
return t('g.failed')
}
</script>

View File

@@ -76,24 +76,4 @@ describe('JobFiltersBar', () => {
expect(wrapper.emitted('showAssets')).toHaveLength(1)
})
it('hides the assets icon button when hideShowAssetsAction is true', () => {
const wrapper = mount(JobFiltersBar, {
props: {
selectedJobTab: 'All',
selectedWorkflowFilter: 'all',
selectedSortMode: 'mostRecent',
hasFailedJobs: false,
hideShowAssetsAction: true
},
global: {
plugins: [i18n],
directives: { tooltip: () => undefined }
}
})
expect(
wrapper.find('button[aria-label="Show assets panel"]').exists()
).toBe(false)
})
})

View File

@@ -1,46 +1,225 @@
<template>
<div class="flex items-center justify-between gap-2 px-3">
<JobFilterTabs
:selected-job-tab="selectedJobTab"
:has-failed-jobs="hasFailedJobs"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
/>
<JobFilterActions
:selected-workflow-filter="selectedWorkflowFilter"
:selected-sort-mode="selectedSortMode"
:hide-show-assets-action="hideShowAssetsAction"
@show-assets="$emit('showAssets')"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
"
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
/>
<div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap">
<Button
v-for="tab in visibleJobTabs"
:key="tab"
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
size="sm"
class="px-3"
@click="$emit('update:selectedJobTab', tab)"
>
{{ tabLabel(tab) }}
</Button>
</div>
</div>
<div class="ml-2 flex shrink-0 items-center gap-2">
<Button
v-if="showWorkflowFilter"
v-tooltip.top="filterTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
@click="onFilterClick"
>
<i class="icon-[lucide--list-filter] size-4" />
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
<Popover
v-if="showWorkflowFilter"
ref="filterPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<Button
class="w-full justify-between"
variant="textonly"
size="sm"
@click="selectWorkflowFilter('all')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] size-4"
/>
</Button>
<div class="mx-2 mt-1 h-px" />
<Button
class="w-full justify-between"
variant="textonly"
@click="selectWorkflowFilter('current')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</Button>
</div>
</Popover>
<Button
v-tooltip.top="sortTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
@click="onSortClick"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
<Popover
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<template v-for="(mode, index) in jobSortModes" :key="mode">
<Button
class="w-full justify-between"
variant="textonly"
size="sm"
@click="selectSortMode(mode)"
>
<span>{{ sortLabel(mode) }}</span>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] size-4 text-text-secondary"
/>
</Button>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
/>
</template>
</div>
</Popover>
<Button
v-tooltip.top="showAssetsTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
@click="$emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
hasFailedJobs,
hideShowAssetsAction
} = defineProps<{
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
const props = defineProps<{
selectedJobTab: JobTab
selectedWorkflowFilter: 'all' | 'current'
selectedSortMode: JobSortMode
hasFailedJobs: boolean
hideShowAssetsAction?: boolean
}>()
defineEmits<{
const emit = defineEmits<{
(e: 'showAssets'): void
(e: 'update:selectedJobTab', value: JobTab): void
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
(e: 'update:selectedSortMode', value: JobSortMode): void
}>()
const { t } = useI18n()
const filterPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
const sortPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
)
const sortTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
)
const showAssetsTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
)
// This can be removed when cloud implements /jobs and we switch to it.
const showWorkflowFilter = !isCloud
const visibleJobTabs = computed(() =>
props.hasFailedJobs ? jobTabs : jobTabs.filter((tab) => tab !== 'Failed')
)
const onFilterClick = (event: Event) => {
if (filterPopoverRef.value) {
filterPopoverRef.value.toggle(event)
}
}
const selectWorkflowFilter = (value: 'all' | 'current') => {
filterPopoverRef.value?.hide()
emit('update:selectedWorkflowFilter', value)
}
const onSortClick = (event: Event) => {
if (sortPopoverRef.value) {
sortPopoverRef.value.toggle(event)
}
}
const selectSortMode = (value: JobSortMode) => {
sortPopoverRef.value?.hide()
emit('update:selectedSortMode', value)
}
const tabLabel = (tab: JobTab) => {
if (tab === 'All') return t('g.all')
if (tab === 'Completed') return t('g.completed')
return t('g.failed')
}
const sortLabel = (mode: JobSortMode) => {
if (mode === 'mostRecent') {
return t('queue.jobList.sortMostRecent')
}
if (mode === 'totalGenerationTime') {
return t('queue.jobList.sortTotalGenerationTime')
}
return ''
}
</script>

View File

@@ -8,13 +8,13 @@ import { useJobErrorReporting } from '@/components/queue/job/useJobErrorReportin
import type { ExecutionError } from '@/platform/remote/comfyui/jobs/jobTypes'
const createTaskWithError = (
jobId: string,
promptId: string,
errorMessage?: string,
executionError?: ExecutionError,
createTime?: number
): TaskItemImpl =>
({
jobId,
promptId,
errorMessage,
executionError,
createTime: createTime ?? Date.now()

View File

@@ -19,8 +19,8 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import TabError from './TabError.vue'
import TabInfo from './info/TabInfo.vue'
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
import TabNodes from './parameters/TabNodes.vue'
@@ -41,7 +41,7 @@ const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionStore)
const { hasAnyError } = storeToRefs(executionStore)
const { findParentGroup } = useGraphHierarchy()
@@ -96,31 +96,30 @@ type RightSidePanelTabList = Array<{
icon?: string
}>
const hasDirectNodeError = computed(() =>
selectedNodes.value.some((node) =>
executionStore.activeGraphErrorNodeIds.has(String(node.id))
)
//FIXME all errors if nothing selected?
const selectedNodeErrors = computed(() =>
selectedNodes.value
.map((node) => executionStore.getNodeErrors(`${node.id}`))
.filter((nodeError) => !!nodeError)
)
const hasContainerInternalError = computed(() => {
if (allErrorExecutionIds.value.length === 0) return false
return selectedNodes.value.some((node) => {
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
return executionStore.hasInternalErrorForNode(node.id)
})
})
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
return hasDirectNodeError.value || hasContainerInternalError.value
})
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []
if (
selectedNodeErrors.value.length &&
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
) {
list.push({
label: () => t('g.error'),
value: 'error',
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
})
}
if (
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
hasRelevantErrors.value
hasAnyError.value &&
!hasSelection.value &&
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
) {
list.push({
label: () => t('rightSidePanel.errors'),
@@ -316,9 +315,9 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
<!-- Panel Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<TabErrors v-if="activeTab === 'errors'" />
<template v-else-if="!hasSelection">
<TabGlobalParameters v-if="activeTab === 'parameters'" />
<template v-if="!hasSelection">
<TabErrors v-if="activeTab === 'errors'" />
<TabGlobalParameters v-else-if="activeTab === 'parameters'" />
<TabNodes v-else-if="activeTab === 'nodes'" />
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
</template>
@@ -327,6 +326,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
:node="selectedSingleNode"
/>
<template v-else>
<TabError v-if="activeTab === 'error'" :errors="selectedNodeErrors" />
<TabSubgraphInputs
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
:node="selectedSingleNode as SubgraphNode"

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { NodeError } from '@/schemas/apiSchema'
const { t } = useI18n()
defineProps<{
errors: NodeError[]
}>()
const { copyToClipboard } = useCopyToClipboard()
</script>
<template>
<div class="m-4">
<Button class="w-full" @click="copyToClipboard(JSON.stringify(errors))">
{{ t('g.copy') }}
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div
v-for="(error, index) in errors.flatMap((ne) => ne.errors)"
:key="index"
class="px-2"
>
<h3 class="text-error" v-text="error.message" />
<div class="text-muted-foreground" v-text="error.details" />
</div>
</template>

View File

@@ -1,13 +1,10 @@
<template>
<div class="overflow-hidden">
<!-- Card Header -->
<div
v-if="card.nodeId && !compact"
class="flex flex-wrap items-center gap-2 py-2"
>
<!-- Card Header (Node ID & Actions) -->
<div v-if="card.nodeId" class="flex flex-wrap items-center gap-2 py-2">
<span
v-if="showNodeIdBadge"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-[10px] font-mono text-muted-foreground font-bold"
>
#{{ card.nodeId }}
</span>
@@ -22,7 +19,7 @@
variant="secondary"
size="sm"
class="rounded-lg text-sm shrink-0"
@click.stop="handleEnterSubgraph"
@click.stop="emit('enterSubgraph', card.nodeId ?? '')"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
@@ -30,8 +27,7 @@
variant="textonly"
size="icon-sm"
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
@click.stop="emit('locateNode', card.nodeId ?? '')"
>
<i class="icon-[lucide--locate] size-3.5" />
</Button>
@@ -47,8 +43,8 @@
>
<!-- Error Message -->
<p
v-if="error.message && !compact"
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5 max-h-[4lh] overflow-y-auto"
v-if="error.message"
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5"
>
{{ error.message }}
</p>
@@ -73,7 +69,7 @@
<Button
variant="secondary"
size="sm"
class="w-full justify-center gap-2 h-8 text-xs"
class="w-full justify-center gap-2 h-8 text-[11px]"
@click="handleCopyError(error)"
>
<i class="icon-[lucide--copy] size-3.5" />
@@ -92,15 +88,9 @@ import { cn } from '@/utils/tailwindUtil'
import type { ErrorCardData, ErrorItem } from './types'
const {
card,
showNodeIdBadge = false,
compact = false
} = defineProps<{
const { card, showNodeIdBadge = false } = defineProps<{
card: ErrorCardData
showNodeIdBadge?: boolean
/** Hide card header and error message (used in single-node selection mode) */
compact?: boolean
}>()
const emit = defineEmits<{
@@ -111,18 +101,6 @@ const emit = defineEmits<{
const { t } = useI18n()
function handleLocateNode() {
if (card.nodeId) {
emit('locateNode', card.nodeId)
}
}
function handleEnterSubgraph() {
if (card.nodeId) {
emit('enterSubgraph', card.nodeId)
}
}
function handleCopyError(error: ErrorItem) {
emit(
'copyToClipboard',

View File

@@ -17,7 +17,6 @@ vi.mock('@/scripts/app', () => ({
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn()
}))

View File

@@ -55,9 +55,8 @@
:key="card.id"
:card="card"
:show-node-id-badge="showNodeIdBadge"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@locate-node="focusNode"
@enter-subgraph="enterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
@@ -98,16 +97,16 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
@@ -120,10 +119,10 @@ const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { staticUrls } = useExternalLink()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const searchQuery = ref('')
const settingStore = useSettingStore()
const showNodeIdBadge = computed(
() =>
@@ -131,30 +130,17 @@ const showNodeIdBadge = computed(
NodeBadgeMode.None
)
const {
allErrorGroups,
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache
} = useErrorGroups(searchQuery, t)
const { filteredGroups } = useErrorGroups(searchQuery, t)
const collapseState = reactive<Record<string, boolean>>({})
/**
* When an external trigger (e.g. "See Error" button in SectionWidgets)
* sets focusedErrorNodeId, expand only the group containing the target
* node and collapse all others so the user sees the relevant errors
* immediately.
*/
watch(
() => rightSidePanelStore.focusedErrorNodeId,
(graphNodeId) => {
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
for (const group of filteredGroups.value) {
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
(card) => card.graphNodeId === graphNodeId
)
collapseState[group.title] = !hasMatch
}
@@ -163,14 +149,6 @@ watch(
{ immediate: true }
)
function handleLocateNode(nodeId: string) {
focusNode(nodeId, errorNodeCache.value)
}
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
@@ -184,6 +162,6 @@ async function contactSupport() {
is_external: true,
source: 'error_dialog'
})
useCommandStore().execute('Comfy.ContactSupport')
await useCommandStore().execute('Comfy.ContactSupport')
}
</script>

View File

@@ -1,34 +1,16 @@
import { computed, reactive } from 'vue'
import { computed } from 'vue'
import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { isCloud } from '@/platform/distribution/types'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getNodeByExecutionId,
getRootParentNode
} from '@/utils/graphTraversalUtil'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { st } from '@/i18n'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type { ErrorCardData, ErrorGroup } from './types'
import { isNodeExecutionId } from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
const SINGLE_GROUP_KEY = '__single__'
const KNOWN_PROMPT_ERROR_TYPES = new Set([
'prompt_no_outputs',
'no_prompt',
'server_error'
])
interface GroupEntry {
priority: number
cards: Map<string, ErrorCardData>
@@ -43,26 +25,20 @@ interface ErrorSearchItem {
searchableDetails: string
}
/**
* Resolve display info for a node by its execution ID.
* For group node internals, resolves the parent group node's title instead.
*/
function resolveNodeInfo(nodeId: string) {
const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt'])
function resolveNodeInfo(nodeId: string): {
title: string
graphNodeId: string | undefined
} {
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
const parentNode = getRootParentNode(app.rootGraph, nodeId)
const isParentGroupNode = parentNode ? isGroupNode(parentNode) : false
return {
title: isParentGroupNode
? parentNode?.title || ''
: resolveNodeDisplayName(graphNode, {
emptyLabel: '',
untitledLabel: '',
st
}),
graphNodeId: graphNode ? String(graphNode.id) : undefined,
isParentGroupNode
title: resolveNodeDisplayName(graphNode, {
emptyLabel: '',
untitledLabel: '',
st
}),
graphNodeId: graphNode ? String(graphNode.id) : undefined
}
}
@@ -79,56 +55,93 @@ function getOrCreateGroup(
return entry.cards
}
function createErrorCard(
nodeId: string,
classType: string,
idPrefix: string
): ErrorCardData {
const nodeInfo = resolveNodeInfo(nodeId)
return {
id: `${idPrefix}-${nodeId}`,
title: classType,
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId) && !nodeInfo.isParentGroupNode,
errors: []
}
}
/**
* In single-node mode, regroup cards by error message instead of class_type.
* This lets the user see "what kinds of errors this node has" at a glance.
*/
function regroupByErrorMessage(
groupsMap: Map<string, GroupEntry>
): Map<string, GroupEntry> {
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
Array.from(g.cards.values())
)
const cardErrorPairs = allCards.flatMap((card) =>
card.errors.map((error) => ({ card, error }))
)
const messageMap = new Map<string, GroupEntry>()
for (const { card, error } of cardErrorPairs) {
addCardErrorToGroup(messageMap, card, error)
}
return messageMap
}
function addCardErrorToGroup(
messageMap: Map<string, GroupEntry>,
card: ErrorCardData,
error: ErrorItem
function processPromptError(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>,
t: (key: string) => string
) {
const group = getOrCreateGroup(messageMap, error.message, 1)
if (!group.has(card.id)) {
group.set(card.id, { ...card, errors: [] })
if (!executionStore.lastPromptError) return
const error = executionStore.lastPromptError
const groupTitle = error.message
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
cards.set('__prompt__', {
id: '__prompt__',
title: groupTitle,
errors: [
{
message: isKnown
? t(`rightSidePanel.promptErrors.${error.type}.desc`)
: error.message
}
]
})
}
function processNodeErrors(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>
) {
if (!executionStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
executionStore.lastNodeErrors
)) {
const cards = getOrCreateGroup(groupsMap, nodeError.class_type, 1)
if (!cards.has(nodeId)) {
const nodeInfo = resolveNodeInfo(nodeId)
cards.set(nodeId, {
id: `node-${nodeId}`,
title: nodeError.class_type,
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId),
errors: []
})
}
const card = cards.get(nodeId)
if (!card) continue
card.errors.push(
...nodeError.errors.map((e) => ({
message: e.message,
details: e.details ?? undefined
}))
)
}
group.get(card.id)?.errors.push(error)
}
function processExecutionError(
groupsMap: Map<string, GroupEntry>,
executionStore: ReturnType<typeof useExecutionStore>
) {
if (!executionStore.lastExecutionError) return
const e = executionStore.lastExecutionError
const nodeId = String(e.node_id)
const cards = getOrCreateGroup(groupsMap, e.node_type, 1)
if (!cards.has(nodeId)) {
const nodeInfo = resolveNodeInfo(nodeId)
cards.set(nodeId, {
id: `exec-${nodeId}`,
title: e.node_type,
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId),
errors: []
})
}
const card = cards.get(nodeId)
if (!card) return
card.errors.push({
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true
})
}
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
@@ -144,14 +157,27 @@ function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
})
}
function searchErrorGroups(groups: ErrorGroup[], query: string) {
function buildErrorGroups(
executionStore: ReturnType<typeof useExecutionStore>,
t: (key: string) => string
): ErrorGroup[] {
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap, executionStore, t)
processNodeErrors(groupsMap, executionStore)
processExecutionError(groupsMap, executionStore)
return toSortedGroups(groupsMap)
}
function searchErrorGroups(groups: ErrorGroup[], query: string): ErrorGroup[] {
if (!query) return groups
const searchableList: ErrorSearchItem[] = []
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi]
const group = groups[gi]!
for (let ci = 0; ci < group.cards.length; ci++) {
const card = group.cards[ci]
const card = group.cards[ci]!
searchableList.push({
groupIndex: gi,
cardIndex: ci,
@@ -193,194 +219,18 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionStore = useExecutionStore()
const canvasStore = useCanvasStore()
const collapseState = reactive<Record<string, boolean>>({})
const selectedNodeInfo = computed(() => {
const items = canvasStore.selectedItems
const nodeIds = new Set<string>()
const containerIds = new Set<string>()
for (const item of items) {
if (!isLGraphNode(item)) continue
nodeIds.add(String(item.id))
if (item instanceof SubgraphNode || isGroupNode(item)) {
containerIds.add(String(item.id))
}
}
return {
nodeIds: nodeIds.size > 0 ? nodeIds : null,
containerIds
}
})
const isSingleNodeSelected = computed(
() =>
selectedNodeInfo.value.nodeIds?.size === 1 &&
selectedNodeInfo.value.containerIds.size === 0
const errorGroups = computed<ErrorGroup[]>(() =>
buildErrorGroups(executionStore, t)
)
const errorNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
for (const execId of executionStore.allErrorExecutionIds) {
const node = getNodeByExecutionId(app.rootGraph, execId)
if (node) map.set(execId, node)
}
return map
})
function isErrorInSelection(executionNodeId: string): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
const graphNode = errorNodeCache.value.get(executionNodeId)
if (graphNode && nodeIds.has(String(graphNode.id))) return true
for (const containerId of selectedNodeInfo.value.containerIds) {
if (executionNodeId.startsWith(`${containerId}:`)) return true
}
return false
}
function addNodeErrorToGroup(
groupsMap: Map<string, GroupEntry>,
nodeId: string,
classType: string,
idPrefix: string,
errors: ErrorItem[],
filterBySelection = false
) {
if (filterBySelection && !isErrorInSelection(nodeId)) return
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
const cards = getOrCreateGroup(groupsMap, groupKey, 1)
if (!cards.has(nodeId)) {
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
}
cards.get(nodeId)?.errors.push(...errors)
}
function processPromptError(groupsMap: Map<string, GroupEntry>) {
if (selectedNodeInfo.value.nodeIds || !executionStore.lastPromptError)
return
const error = executionStore.lastPromptError
const groupTitle = error.message
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
// For server_error, resolve the i18n key based on the environment
let errorTypeKey = error.type
if (error.type === 'server_error') {
errorTypeKey = isCloud ? 'server_error_cloud' : 'server_error_local'
}
const i18nKey = `rightSidePanel.promptErrors.${errorTypeKey}.desc`
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
cards.set(PROMPT_CARD_ID, {
id: PROMPT_CARD_ID,
title: groupTitle,
errors: [
{
message: isKnown ? t(i18nKey) : error.message
}
]
})
}
function processNodeErrors(
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
executionStore.lastNodeErrors
)) {
addNodeErrorToGroup(
groupsMap,
nodeId,
nodeError.class_type,
'node',
nodeError.errors.map((e) => ({
message: e.message,
details: e.details ?? undefined
})),
filterBySelection
)
}
}
function processExecutionError(
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionStore.lastExecutionError) return
const e = executionStore.lastExecutionError
addNodeErrorToGroup(
groupsMap,
String(e.node_id),
e.node_type,
'exec',
[
{
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true
}
],
filterBySelection
)
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap)
processNodeErrors(groupsMap)
processExecutionError(groupsMap)
return toSortedGroups(groupsMap)
})
const tabErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap)
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)
return isSingleNodeSelected.value
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
})
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = searchQuery.value.trim()
return searchErrorGroups(tabErrorGroups.value, query)
})
const groupedErrorMessages = computed<string[]>(() => {
const messages = new Set<string>()
for (const group of allErrorGroups.value) {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
}
}
return Array.from(messages)
return searchErrorGroups(errorGroups.value, query)
})
return {
allErrorGroups,
tabErrorGroups,
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache,
groupedErrorMessages
errorGroups,
filteredGroups
}
}

View File

@@ -5,15 +5,17 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type {
LGraphGroup,
LGraphNode,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -108,24 +110,9 @@ const targetNode = computed<LGraphNode | null>(() => {
return allSameNode ? widgets.value[0].node : null
})
const hasDirectError = computed(() => {
if (!targetNode.value) return false
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
})
const hasContainerInternalError = computed(() => {
if (!targetNode.value) return false
const isContainer =
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
if (!isContainer) return false
return executionStore.hasInternalErrorForNode(targetNode.value.id)
})
const nodeHasError = computed(() => {
if (!targetNode.value) return false
if (canvasStore.selectedItems.length === 1) return false
return hasDirectError.value || hasContainerInternalError.value
if (canvasStore.selectedItems.length > 0 || !targetNode.value) return false
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
})
const parentGroup = computed<LGraphGroup | null>(() => {
@@ -220,10 +207,7 @@ defineExpose({
</span>
</span>
<Button
v-if="
nodeHasError &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
"
v-if="nodeHasError"
variant="secondary"
size="sm"
class="shrink-0 rounded-lg text-sm"
@@ -233,9 +217,9 @@ defineExpose({
</Button>
<Button
v-if="!isEmpty"
variant="muted-textonly"
variant="textonly"
size="icon-sm"
class="subbutton shrink-0 size-8 hover:text-base-foreground"
class="subbutton shrink-0 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
:title="t('rightSidePanel.resetAllParameters')"
:aria-label="t('rightSidePanel.resetAllParameters')"
@click.stop="handleResetAllWidgets"
@@ -244,9 +228,9 @@ defineExpose({
</Button>
<Button
v-if="canShowLocateButton"
variant="muted-textonly"
variant="textonly"
size="icon-sm"
class="subbutton shrink-0 mr-3 size-8 hover:text-base-foreground"
class="subbutton shrink-0 mr-3 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
:title="t('rightSidePanel.locateNode')"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { isEqual } from 'es-toolkit'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import {
demoteWidget,
@@ -123,6 +123,12 @@ function handleResetToDefault() {
if (!hasDefault.value) return
emit('resetToDefault', defaultValue.value)
}
const buttonClasses = cn([
'border-none bg-transparent',
'w-full flex items-center gap-2 rounded px-3 py-2 text-sm',
'cursor-pointer transition-all hover:bg-secondary-background-hover active:scale-95'
])
</script>
<template>
@@ -131,10 +137,8 @@ function handleResetToDefault() {
class="text-muted-foreground bg-transparent hover:text-base-foreground hover:bg-secondary-background-hover active:scale-95 transition-all"
>
<template #default="{ close }">
<Button
variant="textonly"
size="unset"
class="w-full flex items-center gap-2 rounded px-3 py-2 text-sm transition-all active:scale-95"
<button
:class="buttonClasses"
@click="
() => {
handleRename()
@@ -144,13 +148,11 @@ function handleResetToDefault() {
>
<i class="icon-[lucide--edit] size-4" />
<span>{{ t('g.rename') }}</span>
</Button>
</button>
<Button
<button
v-if="hasParents"
variant="textonly"
size="unset"
class="w-full flex items-center gap-2 rounded px-3 py-2 text-sm transition-all active:scale-95"
:class="buttonClasses"
@click="
() => {
if (isShownOnParents) handleHideInput()
@@ -167,12 +169,10 @@ function handleResetToDefault() {
<i class="icon-[lucide--eye] size-4" />
<span>{{ t('rightSidePanel.showInput') }}</span>
</template>
</Button>
</button>
<Button
variant="textonly"
size="unset"
class="w-full flex items-center gap-2 rounded px-3 py-2 text-sm transition-all active:scale-95"
<button
:class="buttonClasses"
@click="
() => {
handleToggleFavorite()
@@ -181,20 +181,18 @@ function handleResetToDefault() {
"
>
<template v-if="isFavorited">
<i class="icon-[lucide--star] size-4" />
<i class="icon-[lucide--star]" />
<span>{{ t('rightSidePanel.removeFavorite') }}</span>
</template>
<template v-else>
<i class="icon-[lucide--star] size-4" />
<i class="icon-[lucide--star]" />
<span>{{ t('rightSidePanel.addFavorite') }}</span>
</template>
</Button>
</button>
<Button
<button
v-if="hasDefault"
variant="textonly"
size="unset"
class="w-full flex items-center gap-2 rounded px-3 py-2 text-sm transition-all active:scale-95"
:class="cn(buttonClasses, isCurrentValueDefault && 'opacity-50')"
:disabled="isCurrentValueDefault"
@click="
() => {
@@ -205,7 +203,7 @@ function handleResetToDefault() {
>
<i class="icon-[lucide--rotate-ccw] size-4" />
<span>{{ t('rightSidePanel.resetToDefault') }}</span>
</Button>
</button>
</template>
</MoreButton>
</template>

View File

@@ -1,7 +1,23 @@
<template>
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="!isInFolderView && isQueuePanelV2Enabled && activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
<ActiveMediaAssetCard
v-for="job in activeJobItems"
:key="job.id"
:job="job"
/>
</div>
<!-- Assets Header -->
<div v-if="assets.length" class="px-2 2xl:px-4">
<div
v-if="assets.length"
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
>
<div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
@@ -43,18 +59,25 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
import { useJobList } from '@/composables/queue/useJobList'
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,
isSelected,
isInFolderView = false,
assetType = 'output',
showOutputCount,
getOutputCount
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
isInFolderView?: boolean
assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
@@ -69,9 +92,19 @@ 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 }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
)
const assetItems = computed<AssetGridItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,

View File

@@ -80,7 +80,7 @@ const sampleAssets: AssetItem[] = [
size: 1887437,
tags: [],
user_metadata: {
jobId: 'job-running-1',
promptId: 'job-running-1',
nodeId: 12,
executionTimeInSeconds: 1.84
}

View File

@@ -1,9 +1,6 @@
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
@@ -13,63 +10,141 @@ vi.mock('vue-i18n', () => ({
})
}))
vi.mock('@/composables/queue/useJobActions', () => ({
useJobActions: () => ({
cancelAction: { variant: 'ghost', label: 'Cancel', icon: 'pi pi-times' },
canCancelJob: ref(false),
runCancelJob: vi.fn()
})
}))
const mockJobItems = ref<
Array<{
id: string
title: string
meta: string
state: string
createTime?: number
}>
>([])
vi.mock('@/composables/queue/useJobList', () => ({
useJobList: () => ({
jobItems: mockJobItems
})
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
isAssetDeleting: () => false
})
}))
const VirtualGridStub = defineComponent({
name: 'VirtualGrid',
props: {
items: {
type: Array,
default: () => []
}
},
template:
'<div><slot v-for="item in items" :key="item.key" name="item" :item="item" /></div>'
})
const buildAsset = (id: string, name: string): AssetItem =>
({
id,
name,
tags: []
}) satisfies AssetItem
const buildOutputItem = (asset: AssetItem): OutputStackListItem => ({
key: `asset-${asset.id}`,
asset
})
const mountListView = (assetItems: OutputStackListItem[] = []) =>
mount(AssetsSidebarListView, {
props: {
assetItems,
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {},
assetType: 'output'
},
global: {
stubs: {
VirtualGrid: VirtualGridStub
}
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => key === 'Comfy.Queue.QPOV2'
})
}))
vi.mock('@/utils/queueUtil', () => ({
isActiveJobState: (state: string) =>
state === 'pending' || state === 'running'
}))
vi.mock('@/utils/queueDisplay', () => ({
iconForJobState: () => 'pi pi-spinner'
}))
vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: () => undefined
}))
vi.mock('@/platform/assets/utils/mediaIconUtil', () => ({
iconForMediaType: () => 'pi pi-file'
}))
vi.mock('@/utils/formatUtil', () => ({
formatDuration: (d: number) => `${d}s`,
formatSize: (s: number) => `${s}B`,
getMediaTypeFromFilename: () => 'image',
truncateFilename: (name: string) => name
}))
describe('AssetsSidebarListView', () => {
it('shows generated assets header when there are assets', () => {
const wrapper = mountListView([buildOutputItem(buildAsset('a1', 'x.png'))])
expect(wrapper.text()).toContain('sideToolbar.generatedAssetsHeader')
beforeEach(() => {
vi.clearAllMocks()
mockJobItems.value = []
})
it('does not show assets header when there are no assets', () => {
const wrapper = mountListView([])
const defaultProps = {
assetItems: [],
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {}
}
expect(wrapper.text()).not.toContain('sideToolbar.generatedAssetsHeader')
it('displays active jobs in oldest-first order (FIFO)', () => {
mockJobItems.value = [
{
id: 'newest',
title: 'Newest Job',
meta: '',
state: 'pending',
createTime: 3000
},
{
id: 'middle',
title: 'Middle Job',
meta: '',
state: 'running',
createTime: 2000
},
{
id: 'oldest',
title: 'Oldest Job',
meta: '',
state: 'pending',
createTime: 1000
}
]
const wrapper = mount(AssetsSidebarListView, {
props: defaultProps,
shallow: true
})
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(jobListItems).toHaveLength(3)
const displayedTitles = jobListItems.map((item) =>
item.props('primaryText')
)
expect(displayedTitles).toEqual(['Oldest Job', 'Middle Job', 'Newest Job'])
})
it('excludes completed and failed jobs from active jobs section', () => {
mockJobItems.value = [
{ id: 'pending', title: 'Pending', meta: '', state: 'pending' },
{ id: 'completed', title: 'Completed', meta: '', state: 'completed' },
{ id: 'failed', title: 'Failed', meta: '', state: 'failed' },
{ id: 'running', title: 'Running', meta: '', state: 'running' }
]
const wrapper = mount(AssetsSidebarListView, {
props: defaultProps,
shallow: true
})
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(jobListItems).toHaveLength(2)
const displayedTitles = jobListItems.map((item) =>
item.props('primaryText')
)
expect(displayedTitles).toContain('Running')
expect(displayedTitles).toContain('Pending')
expect(displayedTitles).not.toContain('Completed')
expect(displayedTitles).not.toContain('Failed')
})
})

View File

@@ -1,6 +1,48 @@
<template>
<div class="flex h-full flex-col">
<div v-if="assetItems.length" class="px-2">
<div
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
:key="job.id"
:class="
cn(
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-default'
)
"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="onJobEnter(job.id)"
@mouseleave="onJobLeave(job.id)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="canCancelJob"
:variant="cancelAction.variant"
size="icon"
:aria-label="cancelAction.label"
@click.stop="runCancelJob()"
>
<i :class="cancelAction.icon" class="size-4" />
</Button>
</template>
</AssetsListItem>
</div>
<div
v-if="assetItems.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
@@ -77,25 +119,31 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { useAssetsStore } from '@/stores/assetsStore'
import { isActiveJobState } from '@/utils/queueUtil'
import {
formatDuration,
formatSize,
getMediaTypeFromFilename,
truncateFilename
} from '@/utils/formatUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assetItems,
@@ -122,7 +170,24 @@ 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)
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
)
const hoveredJob = computed(() =>
hoveredJobId.value
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
null)
: null
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
const listGridStyle = {
display: 'grid',
@@ -175,6 +240,16 @@ function getAssetCardClass(selected: boolean): string {
)
}
function onJobEnter(jobId: string) {
hoveredJobId.value = jobId
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
function onAssetEnter(assetId: string) {
hoveredAssetId.value = assetId
}
@@ -184,4 +259,13 @@ function onAssetLeave(assetId: string) {
hoveredAssetId.value = null
}
}
function getJobIconClass(job: JobListItem): string | undefined {
const classes = []
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
classes.push('animate-spin')
}
return classes.length ? classes.join(' ') : undefined
}
</script>

View File

@@ -1,7 +1,6 @@
<template>
<SidebarTabTemplate
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets.title')"
v-bind="$attrs"
>
<template #alt-title>
<div
@@ -10,7 +9,7 @@
>
<div class="flex items-center gap-2">
<span class="font-bold">{{ $t('assetBrowser.jobId') }}:</span>
<span class="text-sm">{{ folderJobId?.substring(0, 8) }}</span>
<span class="text-sm">{{ folderPromptId?.substring(0, 8) }}</span>
<button
class="m-0 cursor-pointer border-0 bg-transparent p-0 outline-0"
role="button"
@@ -53,7 +52,31 @@
class="pb-1 px-2 2xl:px-4"
:show-generation-time-sort="activeTab === 'output'"
/>
<Divider type="dashed" class="my-2" />
<div
v-if="isQueuePanelV2Enabled && !isInFolderView"
class="flex items-center justify-between px-2 py-2 2xl:px-4"
>
<span class="text-sm text-muted-foreground">
{{ activeJobsLabel }}
</span>
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
</span>
<Button
variant="destructive"
size="icon"
:aria-label="
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
"
:disabled="queuedCount === 0"
@click="handleClearQueue"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
</div>
<Divider v-else type="dashed" class="my-2" />
</template>
<template #body>
<div
@@ -102,6 +125,7 @@
v-else
:assets="displayAssets"
:is-selected="isSelected"
:is-in-folder-view="isInFolderView"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@@ -199,9 +223,9 @@ import {
useDebounceFn,
useElementHover,
useResizeObserver,
useStorage,
useTimeoutFn
useStorage
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import { useToast } from 'primevue/usetoast'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
@@ -233,25 +257,36 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import { useExecutionStore } from '@/stores/executionStore'
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const { activeJobsCount } = storeToRefs(queueStore)
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output')
const folderJobId = ref<string | null>(null)
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const expectedFolderCount = ref(0)
const isInFolderView = computed(() => folderJobId.value !== null)
const isInFolderView = computed(() => folderPromptId.value !== null)
const viewMode = useStorage<'list' | 'grid'>(
'Comfy.Assets.Sidebar.ViewMode',
'grid'
)
const isListView = computed(() => viewMode.value === 'list')
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isListView = computed(
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
)
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
@@ -283,6 +318,16 @@ const formattedExecutionTime = computed(() => {
return formatDuration(folderExecutionTime.value * 1000)
})
const queuedCount = computed(() => queueStore.pendingTasks.length)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobs',
{ count: n(count) },
count
)
})
const toast = useToast()
const inputAssets = useMediaAssets('input')
@@ -408,12 +453,17 @@ const isFolderLoading = computed(
const showLoadingState = computed(
() =>
(loading.value || isFolderLoading.value) && displayAssets.value.length === 0
(loading.value || isFolderLoading.value) &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
)
const showEmptyState = computed(
() =>
!loading.value && !isFolderLoading.value && displayAssets.value.length === 0
!loading.value &&
!isFolderLoading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
)
watch(visibleAssets, (newAssets) => {
@@ -480,20 +530,10 @@ watch(
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
const assetList = assets ?? visibleAssets.value
const index = assetList.findIndex((a) => a.id === asset.id)
emit('assetSelected', asset)
handleAssetClick(asset, index, assetList)
}
const { start: scheduleCleanup, stop: cancelCleanup } = useTimeoutFn(
() => {
contextMenuAsset.value = null
},
0,
{ immediate: false }
)
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
cancelCleanup()
contextMenuAsset.value = asset
void nextTick(() => {
contextMenuRef.value?.show(event)
@@ -501,7 +541,10 @@ function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
}
function handleContextMenuHide() {
scheduleCleanup()
// Delay clearing to allow command callbacks to emit before component unmounts
requestAnimationFrame(() => {
contextMenuAsset.value = null
})
}
const handleBulkDownload = (assets: AssetItem[]) => {
@@ -515,6 +558,16 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
}
}
const handleClearQueue = async () => {
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByPromptIds(pendingPromptIds)
}
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
await addMultipleToWorkflow(assets)
clearSelection()
@@ -575,14 +628,14 @@ const enterFolderView = async (asset: AssetItem) => {
return
}
const { jobId, executionTimeInSeconds } = metadata
const { promptId, executionTimeInSeconds } = metadata
if (!jobId) {
if (!promptId) {
console.warn('Missing required folder view data')
return
}
folderJobId.value = jobId
folderPromptId.value = promptId
folderExecutionTime.value = executionTimeInSeconds
expectedFolderCount.value = metadata.outputCount ?? 0
@@ -600,7 +653,7 @@ const enterFolderView = async (asset: AssetItem) => {
}
const exitFolderView = () => {
folderJobId.value = null
folderPromptId.value = null
folderExecutionTime.value = undefined
expectedFolderCount.value = 0
folderAssets.value = []
@@ -626,9 +679,9 @@ const handleEmptySpaceClick = () => {
}
const copyJobId = async () => {
if (folderJobId.value) {
if (folderPromptId.value) {
try {
await navigator.clipboard.writeText(folderJobId.value)
await navigator.clipboard.writeText(folderPromptId.value)
toast.add({
severity: 'success',
summary: t('mediaAsset.jobIdToast.copied'),

View File

@@ -1,188 +0,0 @@
<template>
<SidebarTabTemplate :title="$t('queue.jobHistory')">
<template #alt-title>
<div class="ml-auto flex shrink-0 items-center">
<JobHistoryActionsMenu @clear-history="showQueueClearHistoryDialog" />
</div>
</template>
<template #header>
<div class="flex flex-col gap-2 pb-1">
<div class="px-3 py-2">
<JobFilterTabs
v-model:selected-job-tab="selectedJobTab"
:has-failed-jobs="hasFailedJobs"
/>
</div>
<JobFilterActions
v-model:selected-workflow-filter="selectedWorkflowFilter"
v-model:selected-sort-mode="selectedSortMode"
v-model:search-query="searchQuery"
class="px-3"
:hide-show-assets-action="true"
:show-search="true"
:search-placeholder="t('sideToolbar.queueProgressOverlay.searchJobs')"
/>
</div>
<div
class="flex items-center justify-between px-3 pb-1 text-xs leading-none text-text-primary"
>
<span class="text-text-secondary">{{ activeQueueSummary }}</span>
<div class="flex items-center gap-2">
<span class="text-xs text-base-foreground">
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
</span>
<Button
variant="destructive"
size="icon"
:aria-label="
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
"
:disabled="queuedCount === 0"
@click="clearQueuedWorkflows"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
</div>
</template>
<template #body>
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="onViewItem"
@menu="onMenuItem"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<ResultGallery
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
</template>
</SidebarTabTemplate>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
import JobAssetsList from '@/components/queue/job/JobAssetsList.vue'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
const { t, n } = useI18n()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
searchQuery,
hasFailedJobs,
filteredTasks,
groupedJobItems
} = useJobList()
const displayedJobGroups = computed(() => groupedJobItems.value)
const runningCount = computed(() => queueStore.runningTasks.length)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const runningJobsLabel = computed(() =>
t('sideToolbar.queueProgressOverlay.runningJobsLabel', {
count: n(runningCount.value)
})
)
const queuedJobsLabel = computed(() =>
t('sideToolbar.queueProgressOverlay.queuedJobsLabel', {
count: n(queuedCount.value)
})
)
const activeQueueSummary = computed(() => {
if (runningCount.value === 0 && queuedCount.value === 0) {
return t('sideToolbar.queueProgressOverlay.noActiveJobs')
}
if (queuedCount.value === 0) {
return runningJobsLabel.value
}
if (runningCount.value === 0) {
return queuedJobsLabel.value
}
return t('sideToolbar.queueProgressOverlay.runningQueuedSummary', {
running: runningJobsLabel.value,
queued: queuedJobsLabel.value
})
})
const clearQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
})
const {
galleryActiveIndex,
galleryItems,
onViewItem: openResultGallery
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await openResultGallery(item)
})
const onInspectAsset = (item: JobListItem) => {
void onViewItem(item)
}
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { jobMenuEntries, cancelJob } = useJobMenu(
() => currentMenuItem.value,
onInspectAsset
)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await cancelJob(item)
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
})
</script>

View File

@@ -113,7 +113,7 @@ describe('NodeLibrarySidebarTabV2', () => {
const wrapper = mountComponent()
const triggers = wrapper.findAllComponents(TabsTrigger)
expect(triggers).toHaveLength(3)
expect(triggers.length).toBe(3)
})
it('should render search box', () => {

View File

@@ -94,7 +94,7 @@
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { useLocalStorage } from '@vueuse/core'
import {
DropdownMenuContent,
@@ -114,7 +114,6 @@ import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBoxV2.vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'
import {
DEFAULT_SORTING_ID,
DEFAULT_TAB_ID,
@@ -149,7 +148,15 @@ const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
custom: 'alphabetical'
}
)
const sortOrder = usePerTabState(selectedTab, sortOrderByTab)
const sortOrder = computed({
get: () => sortOrderByTab.value[selectedTab.value],
set: (value) => {
sortOrderByTab.value = {
...sortOrderByTab.value,
[selectedTab.value]: value
}
}
})
const sortingOptions = computed(() =>
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
@@ -167,7 +174,12 @@ const expandedKeysByTab = ref<Record<TabId, string[]>>({
all: [],
custom: []
})
const expandedKeys = usePerTabState(selectedTab, expandedKeysByTab)
const expandedKeys = computed({
get: () => expandedKeysByTab.value[selectedTab.value],
set: (value) => {
expandedKeysByTab.value[selectedTab.value] = value
}
})
const nodeDefStore = useNodeDefStore()
const { startDrag } = useNodeDragToCanvas()

View File

@@ -10,7 +10,6 @@
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
:root="favoritesRoot"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
@@ -27,7 +26,6 @@
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
:root="section.root"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>

View File

@@ -1,7 +1,13 @@
<template>
<div
class="group relative flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border aspect-square"
:data-node-name="node.data?.display_name"
:class="
cn(
'flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content',
'bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border',
'aspect-square'
)
"
:data-node-name="nodeDef?.display_name"
draggable="true"
@click="handleClick"
@dragstart="handleDragStart"
@@ -12,12 +18,11 @@
<div class="flex flex-1 items-center justify-center">
<i :class="cn(nodeIcon, 'size-14 text-muted-foreground')" />
</div>
<TextTickerMultiLine
class="shrink-0 h-8 w-full text-xs font-bold text-foreground leading-4"
<span
class="shrink-0 h-8 text-sm font-bold text-center text-foreground line-clamp-2 leading-4"
>
{{ node.data?.display_name }}
</TextTickerMultiLine>
{{ nodeDef?.display_name }}
</span>
</div>
<Teleport v-if="showPreview" to="body">
@@ -25,10 +30,7 @@
:ref="(el) => (previewRef = el as HTMLElement)"
:style="nodePreviewStyle"
>
<NodePreviewCard
:node-def="node.data!"
:show-inputs-and-outputs="false"
/>
<NodePreviewCard :node-def="nodeDef!" :show-inputs-and-outputs="false" />
</div>
</Teleport>
</template>
@@ -37,7 +39,6 @@
import { kebabCase } from 'es-toolkit/string'
import { computed, inject } from 'vue'
import TextTickerMultiLine from '@/components/common/TextTickerMultiLine.vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { SidebarContainerKey } from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
@@ -53,9 +54,10 @@ const emit = defineEmits<{
click: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
const panelRef = inject(SidebarContainerKey, undefined)
const nodeDef = computed(() => node.data)
const panelRef = inject(SidebarContainerKey, undefined)
const {
previewRef,
showPreview,
@@ -67,13 +69,13 @@ const {
} = useNodePreviewAndDrag(nodeDef, { panelRef })
const nodeIcon = computed(() => {
const nodeName = node.data?.name
const nodeName = nodeDef.value?.name
const iconName = nodeName ? kebabCase(nodeName) : 'node'
return `icon-[comfy--${iconName}]`
})
function handleClick() {
if (!node.data) return
if (!nodeDef.value) return
emit('click', node)
}
</script>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import {
PopoverArrow,
PopoverContent,
@@ -15,16 +14,10 @@ defineOptions({
inheritAttrs: false
})
const {
entries,
icon,
to,
showArrow = true
} = defineProps<{
entries?: MenuItem[]
defineProps<{
entries?: { label: string; action?: () => void; icon?: string }[][]
icon?: string
to?: string | HTMLElement
showArrow?: boolean
}>()
</script>
@@ -45,42 +38,38 @@ const {
v-bind="$attrs"
class="z-1700 rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
>
<slot :close>
<slot>
<div class="flex flex-col p-1">
<template v-for="item in entries ?? []" :key="item.label">
<section
v-for="(entryGroup, index) in entries ?? []"
:key="index"
class="flex flex-col border-b-2 last:border-none border-border-subtle"
>
<div
v-if="item.separator"
class="border-b w-full border-border-subtle"
/>
<div
v-else
v-for="{ label, action, icon } in entryGroup"
:key="label"
:class="
cn(
'flex flex-row gap-4 p-2 rounded-sm my-1',
item.disabled
? 'opacity-50 pointer-events-none'
: item.command &&
'cursor-pointer hover:bg-secondary-background-hover'
action &&
'cursor-pointer hover:bg-secondary-background-hover'
)
"
@click="
(e) => {
if (!item.command || item.disabled) return
item.command({ originalEvent: e, item })
() => {
if (!action) return
action()
close()
}
"
>
<i v-if="item.icon" :class="item.icon" />
{{ item.label }}
<i v-if="icon" :class="icon" />
{{ label }}
</div>
</template>
</section>
</div>
</slot>
<PopoverArrow
v-if="showArrow"
class="fill-base-background stroke-border-subtle"
/>
<PopoverArrow class="fill-base-background stroke-border-subtle" />
</PopoverContent>
</PopoverPortal>
</PopoverRoot>

View File

@@ -9,7 +9,6 @@ import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
@@ -52,17 +51,6 @@ export const useFirebaseAuthActions = () => {
}
const logout = wrapWithErrorHandlingAsync(async () => {
const workflowStore = useWorkflowStore()
if (workflowStore.modifiedWorkflows.length > 0) {
const dialogService = useDialogService()
const confirmed = await dialogService.confirm({
title: t('auth.signOut.unsavedChangesTitle'),
message: t('auth.signOut.unsavedChangesMessage'),
type: 'dirtyClose'
})
if (!confirmed) return
}
await authStore.logout()
toastStore.add({
severity: 'success',

View File

@@ -2,16 +2,8 @@ import { nextTick } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type {
LGraph,
LGraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import {
getNodeByExecutionId,
getRootParentNode
} from '@/utils/graphTraversalUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { useLitegraphService } from '@/services/litegraphService'
async function navigateToGraph(targetGraph: LGraph) {
@@ -37,40 +29,20 @@ async function navigateToGraph(targetGraph: LGraph) {
export function useFocusNode() {
const canvasStore = useCanvasStore()
/* Locate and focus a node on the canvas by its execution ID. */
async function focusNode(
nodeId: string,
executionIdMap?: Map<string, LGraphNode>
) {
async function focusNode(nodeId: string) {
if (!canvasStore.canvas) return
// For group node internals, locate the root parent group node instead
const parentNode = getRootParentNode(app.rootGraph, nodeId)
if (parentNode && isGroupNode(parentNode) && parentNode.graph) {
await navigateToGraph(parentNode.graph as LGraph)
canvasStore.canvas?.animateToBounds(parentNode.boundingRect)
return
}
const graphNode = executionIdMap
? executionIdMap.get(nodeId)
: getNodeByExecutionId(app.rootGraph, nodeId)
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
}
async function enterSubgraph(
nodeId: string,
executionIdMap?: Map<string, LGraphNode>
) {
async function enterSubgraph(nodeId: string) {
if (!canvasStore.canvas) return
const graphNode = executionIdMap
? executionIdMap.get(nodeId)
: getNodeByExecutionId(app.rootGraph, nodeId)
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)

View File

@@ -100,7 +100,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
expect(options[0]?.action).toBe(mocks.convertToSubgraph)
})
it('includes convert and unpack but hides add to library when multiple items with subgraphs are selected', () => {
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: true,
@@ -109,11 +109,16 @@ describe('useSelectionMenuOptions - subgraph options', () => {
const labels = options.map((option) => option.label)
expect(labels).toContain('contextMenu.Convert to Subgraph')
expect(labels).not.toContain('contextMenu.Add Subgraph to Library')
expect(labels).toContain('contextMenu.Add Subgraph to Library')
expect(labels).toContain('contextMenu.Unpack Subgraph')
const convertOption = options.find(
(option) => option.label === 'contextMenu.Convert to Subgraph'
)
expect(convertOption?.action).toBe(mocks.convertToSubgraph)
})
it('shows add to library and unpack when a single subgraph is selected', () => {
it('hides convert option when only a single subgraph is selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: true,
@@ -126,10 +131,5 @@ describe('useSelectionMenuOptions - subgraph options', () => {
'contextMenu.Add Subgraph to Library',
'contextMenu.Unpack Subgraph'
])
const addToLibraryOption = options.find(
(option) => option.label === 'contextMenu.Add Subgraph to Library'
)
expect(addToLibraryOption?.action).toBe(mocks.addSubgraphToLibrary)
})
})

View File

@@ -85,18 +85,18 @@ export function useSelectionMenuOptions() {
}
if (hasSubgraphs) {
if (!hasMultipleSelection) {
options.push({
options.push(
{
label: t('contextMenu.Add Subgraph to Library'),
icon: 'icon-[lucide--folder-plus]',
action: addSubgraphToLibrary
})
}
options.push({
label: t('contextMenu.Unpack Subgraph'),
icon: 'icon-[lucide--expand]',
action: unpackSubgraph
})
},
{
label: t('contextMenu.Unpack Subgraph'),
icon: 'icon-[lucide--expand]',
action: unpackSubgraph
}
)
}
return options

View File

@@ -1,90 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => ({
publishSubgraph: vi.fn(),
selectedItems: [] as unknown[]
}))
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
useSelectedLiteGraphItems: () => ({
getSelectedNodes: vi.fn(() => [])
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: vi.fn(),
get selectedItems() {
return mocks.selectedItems
},
updateSelectedItems: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
})
}))
vi.mock('@/stores/imagePreviewStore', () => ({
useNodeOutputStore: () => ({
revokeSubgraphPreviews: vi.fn()
})
}))
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: () => ({
publishSubgraph: mocks.publishSubgraph
})
}))
function createSubgraphNode(): SubgraphNode {
const node = Object.create(SubgraphNode.prototype)
return node
}
describe('useSubgraphOperations', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.selectedItems = []
})
it('addSubgraphToLibrary calls publishSubgraph when single SubgraphNode selected', async () => {
mocks.selectedItems = [createSubgraphNode()]
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { addSubgraphToLibrary } = useSubgraphOperations()
await addSubgraphToLibrary()
expect(mocks.publishSubgraph).toHaveBeenCalledOnce()
})
it('addSubgraphToLibrary does not call publishSubgraph when no items selected', async () => {
mocks.selectedItems = []
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { addSubgraphToLibrary } = useSubgraphOperations()
await addSubgraphToLibrary()
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
it('addSubgraphToLibrary does not call publishSubgraph when multiple items selected', async () => {
mocks.selectedItems = [createSubgraphNode(), createSubgraphNode()]
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { addSubgraphToLibrary } = useSubgraphOperations()
await addSubgraphToLibrary()
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
})

View File

@@ -3,7 +3,9 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
/**
* Composable for handling subgraph-related operations
@@ -13,7 +15,8 @@ export function useSubgraphOperations() {
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const nodeOutputStore = useNodeOutputStore()
const subgraphStore = useSubgraphStore()
const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const convertToSubgraph = () => {
const canvas = canvasStore.getCanvas()
@@ -70,13 +73,48 @@ export function useSubgraphOperations() {
const addSubgraphToLibrary = async () => {
const selectedItems = Array.from(canvasStore.selectedItems)
const subgraphNodes = selectedItems.filter(
(item): item is SubgraphNode => item instanceof SubgraphNode
)
if (subgraphNodes.length !== 1) {
// Handle single node selection like BookmarkButton.vue
if (selectedItems.length === 1) {
const item = selectedItems[0]
if (isLGraphNode(item)) {
const nodeDef = nodeDefStore.fromLGraphNode(item)
if (nodeDef) {
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
return
}
}
}
// Handle multiple nodes - convert to subgraph first then bookmark
const selectedNodes = getSelectedNodes()
if (selectedNodes.length === 0) {
return
}
await subgraphStore.publishSubgraph()
// Check if selection contains subgraph nodes
const hasSubgraphs = selectedNodes.some(
(node) => node instanceof SubgraphNode
)
if (!hasSubgraphs) {
// Convert regular nodes to subgraph first
convertToSubgraph()
return
}
// For subgraph nodes, bookmark them
let bookmarkedCount = 0
for (const node of selectedNodes) {
if (node instanceof SubgraphNode) {
const nodeDef = nodeDefStore.fromLGraphNode(node)
if (nodeDef) {
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
bookmarkedCount++
}
}
}
}
const isSubgraphSelected = (): boolean => {

View File

@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import type { TaskItemImpl } from '@/stores/queueStore'
import { isActiveJobState } from '@/utils/queueUtil'
export type JobAction = {
@@ -19,7 +18,7 @@ export function useJobActions(
) {
const { t } = useI18n()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { cancelJob, removeFailedJob } = useJobMenu()
const { cancelJob } = useJobMenu()
const cancelAction: JobAction = {
icon: 'icon-[lucide--x]',
@@ -27,12 +26,6 @@ export function useJobActions(
variant: 'destructive'
}
const deleteAction: JobAction = {
icon: 'icon-[lucide--circle-minus]',
label: t('queue.jobMenu.removeJob'),
variant: 'destructive'
}
const jobRef = computed(() => toValue(job) ?? null)
const canCancelJob = computed(() => {
@@ -44,15 +37,6 @@ export function useJobActions(
return currentJob.showClear !== false && isActiveJobState(currentJob.state)
})
const canDeleteJob = computed(() => {
const currentJob = jobRef.value
if (!currentJob) {
return false
}
return currentJob.state === 'failed'
})
const runCancelJob = wrapWithErrorHandlingAsync(async () => {
const currentJob = jobRef.value
if (!currentJob) {
@@ -62,22 +46,9 @@ export function useJobActions(
await cancelJob(currentJob)
})
const runDeleteJob = wrapWithErrorHandlingAsync(async () => {
const currentJob = jobRef.value
const task = currentJob?.taskRef as TaskItemImpl | undefined
if (!task) {
return
}
await removeFailedJob(task)
})
return {
cancelAction,
canCancelJob,
runCancelJob,
deleteAction,
canDeleteJob,
runDeleteJob
runCancelJob
}
}

View File

@@ -6,11 +6,10 @@ import type { Ref } from 'vue'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobState } from '@/types/queue'
import type { BuildJobDisplayCtx } from '@/utils/queueDisplay'
import { buildJobDisplay } from '@/utils/queueDisplay'
import type { TaskItemImpl } from '@/stores/queueStore'
type TestTask = {
jobId: string
promptId: string
queueIndex: number
mockState: JobState
executionTime?: number
@@ -70,7 +69,7 @@ vi.mock('@/composables/queue/useQueueProgress', () => ({
vi.mock('@/utils/queueDisplay', () => ({
buildJobDisplay: vi.fn(
(task: TaskItemImpl, state: JobState, options: BuildJobDisplayCtx) => ({
primary: `Job ${task.jobId}`,
primary: `Job ${task.promptId}`,
secondary: `${state} meta`,
iconName: `${state}-icon`,
iconImageUrl: undefined,
@@ -109,21 +108,21 @@ vi.mock('@/stores/queueStore', () => ({
}))
let executionStoreMock: {
activeJobId: string | null
activePromptId: string | null
executingNode: null | { title?: string; type?: string }
isJobInitializing: (jobId?: string | number) => boolean
isPromptInitializing: (promptId?: string | number) => boolean
}
let isJobInitializingMock: (jobId?: string | number) => boolean
let isPromptInitializingMock: (promptId?: string | number) => boolean
const ensureExecutionStore = () => {
if (!isJobInitializingMock) {
isJobInitializingMock = vi.fn(() => false)
if (!isPromptInitializingMock) {
isPromptInitializingMock = vi.fn(() => false)
}
if (!executionStoreMock) {
executionStoreMock = reactive({
activeJobId: null as string | null,
activePromptId: null as string | null,
executingNode: null as null | { title?: string; type?: string },
isJobInitializing: (jobId?: string | number) =>
isJobInitializingMock(jobId)
isPromptInitializing: (promptId?: string | number) =>
isPromptInitializingMock(promptId)
})
}
return executionStoreMock
@@ -173,7 +172,8 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
const createTask = (
overrides: Partial<TestTask> & { mockState?: JobState } = {}
): TestTask => ({
jobId: overrides.jobId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
promptId:
overrides.promptId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
queueIndex: overrides.queueIndex ?? 0,
mockState: overrides.mockState ?? 'pending',
executionTime: overrides.executionTime,
@@ -201,7 +201,7 @@ const resetStores = () => {
queueStore.historyTasks = []
const executionStore = ensureExecutionStore()
executionStore.activeJobId = null
executionStore.activePromptId = null
executionStore.executingNode = null
const jobPreviewStore = ensureJobPreviewStore()
@@ -219,9 +219,9 @@ const resetStores = () => {
localeRef.value = 'en-US'
tMock.mockClear()
if (isJobInitializingMock) {
vi.mocked(isJobInitializingMock).mockReset()
vi.mocked(isJobInitializingMock).mockReturnValue(false)
if (isPromptInitializingMock) {
vi.mocked(isPromptInitializingMock).mockReset()
vi.mocked(isPromptInitializingMock).mockReturnValue(false)
}
}
@@ -255,82 +255,10 @@ describe('useJobList', () => {
return api!
}
it('tracks recently added pending jobs and clears the hint after expiry', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ jobId: '1', queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
await flush()
jobItems.value
expect(buildJobDisplay).toHaveBeenCalledWith(
expect.anything(),
'pending',
expect.objectContaining({ showAddedHint: true })
)
vi.mocked(buildJobDisplay).mockClear()
await vi.advanceTimersByTimeAsync(3000)
await flush()
jobItems.value
expect(buildJobDisplay).toHaveBeenCalledWith(
expect.anything(),
'pending',
expect.objectContaining({ showAddedHint: false })
)
})
it('removes pending hint immediately when the task leaves the queue', async () => {
vi.useFakeTimers()
const taskId = '2'
queueStoreMock.pendingTasks = [
createTask({ jobId: taskId, queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
await flush()
jobItems.value
queueStoreMock.pendingTasks = []
await flush()
expect(vi.getTimerCount()).toBe(0)
vi.mocked(buildJobDisplay).mockClear()
queueStoreMock.pendingTasks = [
createTask({ jobId: taskId, queueIndex: 2, mockState: 'pending' })
]
await flush()
jobItems.value
expect(buildJobDisplay).toHaveBeenCalledWith(
expect.anything(),
'pending',
expect.objectContaining({ showAddedHint: true })
)
})
it('cleans up timeouts on unmount', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ jobId: '3', queueIndex: 1, mockState: 'pending' })
]
initComposable()
await flush()
expect(vi.getTimerCount()).toBeGreaterThan(0)
wrapper?.unmount()
wrapper = null
await flush()
expect(vi.getTimerCount()).toBe(0)
})
it('sorts all tasks by create time', async () => {
queueStoreMock.pendingTasks = [
createTask({
jobId: 'p',
promptId: 'p',
queueIndex: 1,
mockState: 'pending',
createTime: 3000
@@ -338,7 +266,7 @@ describe('useJobList', () => {
]
queueStoreMock.runningTasks = [
createTask({
jobId: 'r',
promptId: 'r',
queueIndex: 5,
mockState: 'running',
createTime: 2000
@@ -346,7 +274,7 @@ describe('useJobList', () => {
]
queueStoreMock.historyTasks = [
createTask({
jobId: 'h',
promptId: 'h',
queueIndex: 3,
mockState: 'completed',
createTime: 1000,
@@ -357,7 +285,7 @@ describe('useJobList', () => {
const { allTasksSorted } = initComposable()
await flush()
expect(allTasksSorted.value.map((task) => task.jobId)).toEqual([
expect(allTasksSorted.value.map((task) => task.promptId)).toEqual([
'p',
'r',
'h'
@@ -366,9 +294,9 @@ describe('useJobList', () => {
it('filters by job tab and resets failed tab when failures disappear', async () => {
queueStoreMock.historyTasks = [
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' }),
createTask({ jobId: 'f', queueIndex: 2, mockState: 'failed' }),
createTask({ jobId: 'p', queueIndex: 1, mockState: 'pending' })
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' }),
createTask({ promptId: 'f', queueIndex: 2, mockState: 'failed' }),
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
]
const instance = initComposable()
@@ -376,15 +304,15 @@ describe('useJobList', () => {
instance.selectedJobTab.value = 'Completed'
await flush()
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['c'])
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['c'])
instance.selectedJobTab.value = 'Failed'
await flush()
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['f'])
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['f'])
expect(instance.hasFailedJobs.value).toBe(true)
queueStoreMock.historyTasks = [
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' })
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' })
]
await flush()
@@ -395,13 +323,13 @@ describe('useJobList', () => {
it('filters by active workflow when requested', async () => {
queueStoreMock.pendingTasks = [
createTask({
jobId: 'wf-1',
promptId: 'wf-1',
queueIndex: 2,
mockState: 'pending',
workflowId: 'workflow-1'
}),
createTask({
jobId: 'wf-2',
promptId: 'wf-2',
queueIndex: 1,
mockState: 'pending',
workflowId: 'workflow-2'
@@ -418,72 +346,28 @@ describe('useJobList', () => {
workflowStoreMock.activeWorkflow = { activeState: { id: 'workflow-1' } }
await flush()
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['wf-1'])
})
it('filters jobs by search query', async () => {
vi.useFakeTimers()
queueStoreMock.historyTasks = [
createTask({
jobId: 'alpha',
queueIndex: 2,
mockState: 'completed',
createTime: 2000,
executionEndTimestamp: 2000
}),
createTask({
jobId: 'beta',
queueIndex: 1,
mockState: 'failed',
createTime: 1000,
executionEndTimestamp: 1000
})
]
const instance = initComposable()
await flush()
expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
'alpha',
'beta'
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual([
'wf-1'
])
instance.searchQuery.value = 'beta'
await vi.advanceTimersByTimeAsync(200)
await flush()
expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
'beta'
])
instance.searchQuery.value = 'failed meta'
await vi.advanceTimersByTimeAsync(200)
await flush()
expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
'beta'
])
instance.searchQuery.value = 'does-not-exist'
await vi.advanceTimersByTimeAsync(200)
await flush()
expect(instance.filteredTasks.value).toEqual([])
})
it('hydrates job items with active progress and compute hours', async () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'active',
promptId: 'active',
queueIndex: 3,
mockState: 'running',
executionTime: 7_200_000
}),
createTask({
jobId: 'other',
promptId: 'other',
queueIndex: 2,
mockState: 'running',
executionTime: 3_600_000
})
]
executionStoreMock.activeJobId = 'active'
executionStoreMock.activePromptId = 'active'
executionStoreMock.executingNode = { title: 'Render Node' }
totalPercent.value = 80
currentNodePercent.value = 40
@@ -506,7 +390,7 @@ describe('useJobList', () => {
it('assigns preview urls for running jobs when previews enabled', async () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'live-preview',
promptId: 'live-preview',
queueIndex: 1,
mockState: 'running'
})
@@ -525,7 +409,7 @@ describe('useJobList', () => {
it('omits preview urls when previews are disabled', async () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'disabled-preview',
promptId: 'disabled-preview',
queueIndex: 1,
mockState: 'running'
})
@@ -566,28 +450,28 @@ describe('useJobList', () => {
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
queueStoreMock.historyTasks = [
createTask({
jobId: 'today-small',
promptId: 'today-small',
queueIndex: 4,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 2_000
}),
createTask({
jobId: 'today-large',
promptId: 'today-large',
queueIndex: 3,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 5_000
}),
createTask({
jobId: 'yesterday',
promptId: 'yesterday',
queueIndex: 2,
mockState: 'failed',
executionEndTimestamp: Date.now() - 86_400_000,
executionTime: 1_000
}),
createTask({
jobId: 'undated',
promptId: 'undated',
queueIndex: 1,
mockState: 'pending'
})

View File

@@ -1,4 +1,3 @@
import { refDebounced } from '@vueuse/core'
import { orderBy } from 'es-toolkit/array'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -128,7 +127,7 @@ export function useJobList() {
watch(
() =>
queueStore.pendingTasks
.map((task) => taskIdToKey(task.jobId))
.map((task) => taskIdToKey(task.promptId))
.filter((id): id is string => !!id),
(pendingIds) => {
const pendingSet = new Set(pendingIds)
@@ -159,7 +158,7 @@ export function useJobList() {
const shouldShowAddedHint = (task: TaskItemImpl, state: JobState) => {
if (state !== 'pending') return false
const id = taskIdToKey(task.jobId)
const id = taskIdToKey(task.promptId)
if (!id) return false
return recentlyAddedPendingIds.value.has(id)
}
@@ -184,8 +183,8 @@ export function useJobList() {
})
const undatedLabel = computed(() => t('queue.jobList.undated') || 'Undated')
const isJobInitializing = (jobId: string | number | undefined) =>
executionStore.isJobInitializing(jobId)
const isJobInitializing = (promptId: string | number | undefined) =>
executionStore.isPromptInitializing(promptId)
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
@@ -198,8 +197,6 @@ export function useJobList() {
const selectedJobTab = ref<JobTab>('All')
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
const selectedSortMode = ref<JobSortMode>('mostRecent')
const searchQuery = ref('')
const debouncedSearchQuery = refDebounced(searchQuery, 150)
const mostRecentTimestamp = (task: TaskItemImpl) => task.createTime ?? 0
@@ -215,7 +212,7 @@ export function useJobList() {
const tasksWithJobState = computed<TaskWithState[]>(() =>
allTasksSorted.value.map((task) => ({
task,
state: jobStateFromTask(task, isJobInitializing(task?.jobId))
state: jobStateFromTask(task, isJobInitializing(task?.promptId))
}))
)
@@ -251,20 +248,17 @@ export function useJobList() {
return entries
})
const normalizedSearchQuery = computed(() =>
debouncedSearchQuery.value.trim().toLocaleLowerCase()
)
const filteredTasks = computed<TaskItemImpl[]>(() =>
searchableTaskEntries.value.map(({ task }) => task)
filteredTaskEntries.value.map(({ task }) => task)
)
const jobItems = computed<JobListItem[]>(() => {
return filteredTaskEntries.value.map(({ task, state }) => {
const isActive =
String(task.jobId ?? '') === String(executionStore.activeJobId ?? '')
String(task.promptId ?? '') ===
String(executionStore.activePromptId ?? '')
const showAddedHint = shouldShowAddedHint(task, state)
const promptKey = taskIdToKey(task.jobId)
const promptKey = taskIdToKey(task.promptId)
const promptPreviewUrl =
state === 'running' && jobPreviewStore.isPreviewEnabled && promptKey
? jobPreviewStore.previewsByPromptId[promptKey]
@@ -283,7 +277,7 @@ export function useJobList() {
})
return {
id: String(task.jobId),
id: String(task.promptId),
title: display.primary,
meta: display.secondary,
state,
@@ -314,31 +308,11 @@ export function useJobList() {
return m
})
const searchableTaskEntries = computed<TaskWithState[]>(() => {
if (!normalizedSearchQuery.value) return filteredTaskEntries.value
return filteredTaskEntries.value.filter(({ task }) => {
const taskId = String(task.jobId ?? '').toLocaleLowerCase()
const item = jobItemById.value.get(String(task.jobId))
if (!item) {
return taskId.includes(normalizedSearchQuery.value)
}
const title = item.title.toLocaleLowerCase()
const meta = item.meta.toLocaleLowerCase()
return (
title.includes(normalizedSearchQuery.value) ||
meta.includes(normalizedSearchQuery.value) ||
taskId.includes(normalizedSearchQuery.value)
)
})
})
const groupedJobItems = computed<JobGroup[]>(() => {
const groups: JobGroup[] = []
const index = new Map<string, number>()
const localeValue = locale.value
for (const { task, state } of searchableTaskEntries.value) {
for (const { task, state } of filteredTaskEntries.value) {
let ts: number | undefined
if (state === 'completed' || state === 'failed') {
ts = task.executionEndTimestamp
@@ -360,7 +334,7 @@ export function useJobList() {
groupIdx = groups.length - 1
index.set(key, groupIdx)
}
const ji = jobItemById.value.get(String(task.jobId))
const ji = jobItemById.value.get(String(task.promptId))
if (ji) groups[groupIdx].items.push(ji)
}
@@ -383,7 +357,6 @@ export function useJobList() {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
searchQuery,
hasFailedJobs,
// data sources
allTasksSorted,

View File

@@ -72,7 +72,8 @@ const interruptMock = vi.fn()
const deleteItemMock = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
interrupt: (runningJobId: string | null) => interruptMock(runningJobId),
interrupt: (runningPromptId: string | null) =>
interruptMock(runningPromptId),
deleteItem: (type: string, id: string) => deleteItemMock(type, id)
}
}))
@@ -119,7 +120,7 @@ vi.mock('@/stores/queueStore', () => ({
}))
const executionStoreMock = {
clearInitializationByJobId: vi.fn()
clearInitializationByPromptId: vi.fn()
}
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStoreMock
@@ -742,17 +743,6 @@ describe('useJobMenu', () => {
'd3',
'delete'
])
expect(
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
).toBe(false)
expect(
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
).toBe(false)
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
false
)
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
await inspectEntry?.onClick?.()
expect(inspectSpy).toHaveBeenCalledWith(currentItem.value)
@@ -770,7 +760,6 @@ describe('useJobMenu', () => {
await nextTick()
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
expect(inspectEntry?.onClick).toBeUndefined()
expect(inspectEntry?.disabled).toBe(true)
})
it('omits delete asset entry when no preview exists', async () => {
@@ -778,15 +767,6 @@ describe('useJobMenu', () => {
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
await nextTick()
expect(
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
).toBe(true)
expect(
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
).toBe(true)
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
true
)
expect(jobMenuEntries.value.some((entry) => entry.key === 'delete')).toBe(
false
)

View File

@@ -29,7 +29,6 @@ export type MenuEntry =
key: string
label: string
icon?: string
disabled?: boolean
onClick?: () => void | Promise<void>
}
| { kind: 'divider'; key: string }
@@ -84,7 +83,7 @@ export function useJobMenu(
} else if (target.state === 'pending') {
await api.deleteItem('queue', target.id)
}
executionStore.clearInitializationByJobId(target.id)
executionStore.clearInitializationByPromptId(target.id)
await queueStore.update()
}
@@ -217,11 +216,10 @@ export function useJobMenu(
}
}
const removeFailedJob = async (task?: TaskItemImpl | null) => {
const target =
task ?? (currentMenuItem()?.taskRef as TaskItemImpl | undefined)
if (!target) return
await queueStore.delete(target)
const removeFailedJob = async () => {
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
if (!task) return
await queueStore.delete(task)
}
const jobMenuOpenWorkflowLabel = computed(() =>
@@ -241,14 +239,13 @@ export function useJobMenu(
const item = currentMenuItem()
const state = item?.state
if (!state) return []
const hasPreviewAsset = !!item?.taskRef?.previewOutput
const hasDeletableAsset = !!item?.taskRef?.previewOutput
if (state === 'completed') {
return [
{
key: 'inspect-asset',
label: st('queue.jobMenu.inspectAsset', 'Inspect asset'),
icon: 'icon-[lucide--zoom-in]',
disabled: !hasPreviewAsset || !onInspectAsset,
onClick: onInspectAsset
? () => {
const item = currentMenuItem()
@@ -263,14 +260,12 @@ export function useJobMenu(
'Add to current workflow'
),
icon: 'icon-[comfy--node]',
disabled: !hasPreviewAsset,
onClick: addOutputLoaderNode
},
{
key: 'download',
label: st('queue.jobMenu.download', 'Download'),
icon: 'icon-[lucide--download]',
disabled: !hasPreviewAsset,
onClick: downloadPreviewAsset
},
{ kind: 'divider', key: 'd1' },
@@ -294,7 +289,7 @@ export function useJobMenu(
onClick: copyJobId
},
{ kind: 'divider', key: 'd3' },
...(hasPreviewAsset
...(hasDeletableAsset
? [
{
key: 'delete',
@@ -370,7 +365,6 @@ export function useJobMenu(
jobMenuEntries,
openJobWorkflow,
copyJobId,
cancelJob,
removeFailedJob
cancelJob
}
}

View File

@@ -1,32 +0,0 @@
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
import { useDialogStore } from '@/stores/dialogStore'
export const useQueueClearHistoryDialog = () => {
const dialogStore = useDialogStore()
const showQueueClearHistoryDialog = () => {
dialogStore.showDialog({
key: 'queue-clear-history',
component: QueueClearHistoryDialog,
dialogComponentProps: {
headless: true,
closable: false,
closeOnEscape: true,
dismissableMask: true,
pt: {
root: {
class: 'max-w-90 w-auto bg-transparent border-none shadow-none'
},
content: {
class: 'bg-transparent',
style: 'padding: 0'
}
}
}
})
}
return {
showQueueClearHistoryDialog
}
}

View File

@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
const { mockGetSetting, mockActiveJobsCount } = vi.hoisted(() => ({
const { mockGetSetting, mockPendingTasks } = vi.hoisted(() => ({
mockGetSetting: vi.fn(),
mockActiveJobsCount: { value: 0 }
mockPendingTasks: [] as unknown[]
}))
vi.mock('@/platform/settings/settingStore', () => ({
@@ -19,14 +19,14 @@ vi.mock('@/components/sidebar/tabs/AssetsSidebarTab.vue', () => ({
vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => ({
activeJobsCount: mockActiveJobsCount.value
pendingTasks: mockPendingTasks
})
}))
describe('useAssetsSidebarTab', () => {
it('hides icon badge when QPO V2 is disabled', () => {
mockGetSetting.mockReturnValue(false)
mockActiveJobsCount.value = 3
mockPendingTasks.splice(0, mockPendingTasks.length, {}, {})
const sidebarTab = useAssetsSidebarTab()
@@ -34,22 +34,13 @@ describe('useAssetsSidebarTab', () => {
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
})
it('shows active job count when QPO V2 is enabled', () => {
it('shows pending task count when QPO V2 is enabled', () => {
mockGetSetting.mockReturnValue(true)
mockActiveJobsCount.value = 3
mockPendingTasks.splice(0, mockPendingTasks.length, {}, {}, {})
const sidebarTab = useAssetsSidebarTab()
expect(typeof sidebarTab.iconBadge).toBe('function')
expect((sidebarTab.iconBadge as () => string | null)()).toBe('3')
})
it('hides badge when no active jobs', () => {
mockGetSetting.mockReturnValue(true)
mockActiveJobsCount.value = 0
const sidebarTab = useAssetsSidebarTab()
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
})
})

View File

@@ -22,8 +22,9 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => {
}
const queueStore = useQueueStore()
const count = queueStore.activeJobsCount
return count > 0 ? count.toString() : null
return queueStore.pendingTasks.length > 0
? queueStore.pendingTasks.length.toString()
: null
}
}
}

View File

@@ -1,16 +0,0 @@
import { markRaw } from 'vue'
import JobHistorySidebarTab from '@/components/sidebar/tabs/JobHistorySidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useJobHistorySidebarTab = (): SidebarTabExtension => {
return {
id: 'job-history',
icon: 'icon-[lucide--history]',
title: 'queue.jobHistory',
tooltip: 'queue.jobHistory',
label: 'queue.jobHistory',
component: markRaw(JobHistorySidebarTab),
type: 'vue'
}
}

View File

@@ -17,7 +17,7 @@ const executionStore = reactive<{
executingNode: unknown
executingNodeProgress: number
nodeProgressStates: Record<string, unknown>
activeJob: {
activePrompt: {
workflow: {
changeTracker: {
activeState: {
@@ -32,7 +32,7 @@ const executionStore = reactive<{
executingNode: null,
executingNodeProgress: 0,
nodeProgressStates: {},
activeJob: null
activePrompt: null
})
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStore
@@ -76,7 +76,7 @@ describe('useBrowserTabTitle', () => {
executionStore.executingNode = null
executionStore.executingNodeProgress = 0
executionStore.nodeProgressStates = {}
executionStore.activeJob = null
executionStore.activePrompt = null
// reset setting and workflow stores
vi.mocked(settingStore.get).mockReturnValue('Enabled')
@@ -187,7 +187,7 @@ describe('useBrowserTabTitle', () => {
executionStore.nodeProgressStates = {
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
}
executionStore.activeJob = {
executionStore.activePrompt = {
workflow: {
changeTracker: {
activeState: {

View File

@@ -77,7 +77,7 @@ export const useBrowserTabTitle = () => {
const [nodeId, state] = runningNodes[0]
const progress = Math.round((state.value / state.max) * 100)
const nodeType =
executionStore.activeJob?.workflow?.changeTracker?.activeState.nodes.find(
executionStore.activePrompt?.workflow?.changeTracker?.activeState.nodes.find(
(n) => String(n.id) === nodeId
)?.type || 'Node'

View File

@@ -23,13 +23,7 @@ vi.mock('vue-i18n', async () => {
vi.mock('@/scripts/app', () => {
const mockGraphClear = vi.fn()
const mockCanvas = {
subgraph: undefined,
selectedItems: new Set(),
copyToClipboard: vi.fn(),
pasteFromClipboard: vi.fn(),
selectItems: vi.fn()
}
const mockCanvas = { subgraph: undefined }
return {
app: {
@@ -111,8 +105,7 @@ vi.mock('@/stores/subgraphStore', () => ({
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => ({
getCanvas: () => app.canvas,
canvas: app.canvas
getCanvas: () => app.canvas
})),
useTitleEditorStore: vi.fn(() => ({
titleEditorTarget: null
@@ -307,48 +300,6 @@ describe('useCoreCommands', () => {
})
})
describe('Canvas clipboard commands', () => {
function findCommand(id: string) {
return useCoreCommands().find((cmd) => cmd.id === id)!
}
beforeEach(() => {
app.canvas.selectedItems = new Set()
vi.mocked(app.canvas.copyToClipboard).mockClear()
vi.mocked(app.canvas.pasteFromClipboard).mockClear()
vi.mocked(app.canvas.selectItems).mockClear()
})
it('should copy selected items when selection exists', async () => {
app.canvas.selectedItems = new Set([
{}
]) as typeof app.canvas.selectedItems
await findCommand('Comfy.Canvas.CopySelected').function()
expect(app.canvas.copyToClipboard).toHaveBeenCalledWith()
})
it('should not copy when no items are selected', async () => {
await findCommand('Comfy.Canvas.CopySelected').function()
expect(app.canvas.copyToClipboard).not.toHaveBeenCalled()
})
it('should paste from clipboard', async () => {
await findCommand('Comfy.Canvas.PasteFromClipboard').function()
expect(app.canvas.pasteFromClipboard).toHaveBeenCalledWith()
})
it('should select all items', async () => {
await findCommand('Comfy.Canvas.SelectAll').function()
// No arguments means "select all items on canvas"
expect(app.canvas.selectItems).toHaveBeenCalledWith()
})
})
describe('Subgraph metadata commands', () => {
beforeEach(() => {
mockSubgraph.extra = {}

View File

@@ -312,7 +312,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Interrupt',
category: 'essentials' as const,
function: async () => {
await api.interrupt(executionStore.activeJobId)
await api.interrupt(executionStore.activePromptId)
toastStore.add({
severity: 'info',
summary: t('g.interrupted'),
@@ -884,32 +884,6 @@ export function useCoreCommands(): ComfyCommand[] {
window.open(staticUrls.forum, '_blank')
}
},
{
id: 'Comfy.Canvas.CopySelected',
icon: 'icon-[lucide--copy]',
label: 'Copy',
function: () => {
if (app.canvas.selectedItems?.size) {
app.canvas.copyToClipboard()
}
}
},
{
id: 'Comfy.Canvas.PasteFromClipboard',
icon: 'icon-[lucide--clipboard-paste]',
label: 'Paste',
function: () => {
app.canvas.pasteFromClipboard()
}
},
{
id: 'Comfy.Canvas.SelectAll',
icon: 'icon-[lucide--lasso-select]',
label: 'Select All',
function: () => {
app.canvas.selectItems()
}
},
{
id: 'Comfy.Canvas.DeleteSelectedItems',
icon: 'pi pi-trash',

View File

@@ -1,73 +0,0 @@
import { ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { usePerTabState } from './usePerTabState'
type TabId = 'a' | 'b' | 'c'
describe('usePerTabState', () => {
function setup(initialTab: TabId = 'a') {
const selectedTab = ref<TabId>(initialTab)
const stateByTab = ref<Record<TabId, string[]>>({
a: [],
b: [],
c: []
})
const state = usePerTabState(selectedTab, stateByTab)
return { selectedTab, stateByTab, state }
}
it('should return state for the current tab', () => {
const { selectedTab, stateByTab, state } = setup()
stateByTab.value.a = ['key1', 'key2']
stateByTab.value.b = ['key3']
expect(state.value).toEqual(['key1', 'key2'])
selectedTab.value = 'b'
expect(state.value).toEqual(['key3'])
})
it('should set state only for the current tab', () => {
const { stateByTab, state } = setup()
state.value = ['new-key1', 'new-key2']
expect(stateByTab.value.a).toEqual(['new-key1', 'new-key2'])
expect(stateByTab.value.b).toEqual([])
expect(stateByTab.value.c).toEqual([])
})
it('should preserve state when switching tabs', () => {
const { selectedTab, stateByTab, state } = setup()
state.value = ['a-key']
selectedTab.value = 'b'
state.value = ['b-key']
selectedTab.value = 'c'
state.value = ['c-key']
expect(stateByTab.value.a).toEqual(['a-key'])
expect(stateByTab.value.b).toEqual(['b-key'])
expect(stateByTab.value.c).toEqual(['c-key'])
selectedTab.value = 'a'
expect(state.value).toEqual(['a-key'])
})
it('should not share state between tabs', () => {
const { selectedTab, state } = setup()
state.value = ['only-a']
selectedTab.value = 'b'
expect(state.value).toEqual([])
selectedTab.value = 'c'
expect(state.value).toEqual([])
selectedTab.value = 'a'
expect(state.value).toEqual(['only-a'])
})
})

View File

@@ -1,14 +0,0 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
export function usePerTabState<K extends string, V>(
selectedTab: Ref<K>,
stateByTab: Ref<Record<K, V>>
) {
return computed({
get: () => stateByTab.value[selectedTab.value],
set: (value) => {
stateByTab.value[selectedTab.value] = value
}
})
}

View File

@@ -14,14 +14,6 @@ export const CORE_MENU_COMMANDS = [
]
],
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
[
['Edit'],
[
'Comfy.Canvas.CopySelected',
'Comfy.Canvas.PasteFromClipboard',
'Comfy.Canvas.SelectAll'
]
],
[['Edit'], ['Comfy.ClearWorkflow']],
[['Edit'], ['Comfy.OpenClipspace']],
[

View File

@@ -5,8 +5,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
@@ -125,94 +125,6 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.widgets[0].computedHeight = 10
expect(subgraphNode.widgets[0].value).toBe('value')
})
test('Proxy widget label shows widgetName, not "nodeId: widgetName"', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
expect(proxyWidget.label).toBe('seed')
expect(proxyWidget.name).toBe('1: seed')
})
test('Proxy widget label reflects linked widget label', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
expect(proxyWidget.label).toBe('seed')
innerNodes[0].widgets![0].label = 'My Inner Label'
// Trigger re-resolve of linked widget
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('My Inner Label')
})
test('Proxy widget user rename takes priority over linked widget label', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
proxyWidget.label = 'My Custom Seed'
expect(proxyWidget.label).toBe('My Custom Seed')
innerNodes[0].widgets![0].label = 'Inner Override'
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('My Custom Seed')
})
test('Proxy widget label resets to linked widget on undefined', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
proxyWidget.label = 'Custom'
expect(proxyWidget.label).toBe('Custom')
proxyWidget.label = undefined
innerNodes[0].widgets![0].label = 'Inner Label'
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('Inner Label')
})
test('Proxy widget labels are correct when loaded from serialized data', () => {
// Intentionally constructs SubgraphNode via constructor (not setupSubgraph)
// to exercise the deserialization/onConfigure path from blueprint JSON.
const subgraph = createTestSubgraph()
const innerNode = new LGraphNode('InnerNode')
subgraph.add(innerNode)
innerNode.addWidget('text', 'seed', 'value', () => {})
innerNode.addWidget('text', 'steps', 'value', () => {})
const parentGraph = new LGraph()
const subgraphNode = new SubgraphNode(parentGraph, subgraph, {
id: 1,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
properties: {
proxyWidgets: [
['1', 'seed'],
['1', 'steps']
]
},
flags: {},
mode: 0,
order: 0
})
expect(subgraphNode.widgets).toHaveLength(2)
expect(subgraphNode.widgets[0].label).toBe('seed')
expect(subgraphNode.widgets[0].name).toBe('1: seed')
expect(subgraphNode.widgets[1].label).toBe('steps')
expect(subgraphNode.widgets[1].name).toBe('1: steps')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})

View File

@@ -154,6 +154,7 @@ function newProxyWidget(
computedHeight: undefined,
isProxyWidget: true,
last_y: undefined,
label: name,
name,
node: subgraphNode,
onRemove: undefined,
@@ -201,15 +202,12 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
* and the value used as 'this' if property is a get/set method
* @param {unknown} value - only used on set calls. The thing being assigned
*/
let userLabel: string | undefined
const handler = {
get(_t: IBaseWidget, property: string, receiver: object) {
let redirectedTarget: object = backingWidget
let redirectedReceiver = receiver
if (property == '_overlay') return overlay
else if (property == 'value') redirectedReceiver = backingWidget
else if (property == 'label')
return userLabel ?? linkedWidget?.label ?? overlay.widgetName
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
redirectedTarget = overlay
redirectedReceiver = overlay
@@ -217,10 +215,6 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
return Reflect.get(redirectedTarget, property, redirectedReceiver)
},
set(_t: IBaseWidget, property: string, value: unknown) {
if (property == 'label') {
userLabel = value as string | undefined
return true
}
let redirectedTarget: object = backingWidget
if (property == 'computedHeight') {
if (overlay.widgetName.startsWith('$$') && linkedNode) {

View File

@@ -277,7 +277,6 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
) {
const input = this.inputs[slot]
if (contype !== LiteGraph.INPUT || !this.graph || !input) return
if (app.configuringGraph) return
const [matchKey, matchGroup] = Object.entries(
this.comfyDynamic.matchType
).find(([, group]) => input.name in group) ?? ['', undefined]

Some files were not shown because too many files have changed in this diff Show More