Compare commits

..

19 Commits

Author SHA1 Message Date
Benjamin Lu
6f52357f2a Minor dry nit 2025-12-13 19:47:04 -08:00
Benjamin Lu
eb5327b4aa Remove unused allTasksSorted 2025-12-13 19:42:33 -08:00
Benjamin Lu
7c5859e186 Cleanup listeners on unmount 2025-12-13 19:33:49 -08:00
Benjamin Lu
d48aabbe4c Fix aria for QIPS 2025-12-13 19:33:33 -08:00
Benjamin Lu
04de43f90c Translate hours text in popover 2025-12-13 18:37:20 -08:00
Benjamin Lu
51d2046c7d Ensure don't round to 0 hours 2025-12-13 18:18:54 -08:00
Benjamin Lu
fdaba3a293 Merge branch 'queue-overlay-additions' of https://github.com/Comfy-Org/ComfyUI_frontend into queue-overlay-additions 2025-12-13 17:59:21 -08:00
Benjamin Lu
b10e67f6aa Use usei18n 2025-12-13 17:58:52 -08:00
Benjamin Lu
66f4dac7ca Simplify docked by using model macro 2025-12-13 17:48:47 -08:00
Benjamin Lu
53dbca9fea Add queue overlay tests and stories (#7342)
## Summary
- add Playwright queue list fixture and coverage for toggle/count
display
- update queue overlay unit tests plus storybook stories for inline
progress and job item
- adjust useJobList expectations for ordered tasks

main <-- https://github.com/Comfy-Org/ComfyUI_frontend/pull/7336 <--
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7338 <--
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7342

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7342-Add-queue-overlay-tests-and-stories-2c66d73d365081ae8e32d6e34f87e1d9)
by [Unito](https://www.unito.io)
2025-12-13 18:48:06 -07:00
Benjamin Lu
18303fafce Merge branch 'queue-overlay-additions' of https://github.com/Comfy-Org/ComfyUI_frontend into queue-overlay-additions 2025-12-13 17:36:24 -08:00
Benjamin Lu
73678e0220 Restore Panel 2025-12-13 17:33:06 -08:00
github-actions
24ab32d9b4 [automated] Update test expectations 2025-12-14 01:09:51 +00:00
Benjamin Lu
d7d03bed8b Remove unused i18n string 2025-12-13 16:58:05 -08:00
Benjamin Lu
4a8975ad27 Merge remote-tracking branch 'origin/queue-overlay-deletions' into queue-overlay-additions 2025-12-13 15:49:43 -08:00
Benjamin Lu
c6737730f6 Merge branch 'main' into queue-overlay-deletions 2025-12-13 15:31:03 -08:00
Benjamin Lu
ee476842a3 Remove useless watcheffect 2025-12-11 12:29:22 -08:00
Benjamin Lu
9863aa6321 Add queue overlay inline progress and controls 2025-12-10 19:36:16 -08:00
Benjamin Lu
f548af2f1d Remove queue overlay active state and filters 2025-12-10 18:42:54 -08:00
74 changed files with 1425 additions and 2910 deletions

View File

@@ -1,92 +0,0 @@
{
"id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae",
"revision": 0,
"last_node_id": 17,
"last_link_id": 9,
"nodes": [
{
"id": 17,
"type": "VAEDecode",
"pos": [
318.8446183157076,
355.3961392345528
],
"size": [
225,
102
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
}
],
"links": [],
"groups": [
{
"id": 4,
"title": "Outer Group",
"bounding": [
-46.25245366331014,
-150.82497138023245,
1034.4034361963616,
1007.338460439933
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Inner Group",
"bounding": [
80.96059074101554,
28.123757436778178,
718.286373661183,
691.2397164539732
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.7121393732101533,
"offset": [
289.18242848011835,
367.0747755524199
]
},
"frontendVersion": "1.35.5",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"workflowRendererVersion": "Vue"
},
"version": 0.4
}

View File

@@ -13,6 +13,7 @@ import { ComfyTemplates } from '../helpers/templates'
import { ComfyMouse } from './ComfyMouse'
import { VueNodeHelpers } from './VueNodeHelpers'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import { QueueList } from './components/QueueList'
import { SettingDialog } from './components/SettingDialog'
import {
NodeLibrarySidebarTab,
@@ -126,20 +127,6 @@ class ConfirmDialog {
const loc = this[locator]
await expect(loc).toBeVisible()
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() => window['app']?.extensionManager?.workflow?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}
@@ -165,6 +152,7 @@ export class ComfyPage {
// Components
public readonly searchBox: ComfyNodeSearchBox
public readonly queueList: QueueList
public readonly menu: ComfyMenu
public readonly actionbar: ComfyActionbar
public readonly templates: ComfyTemplates
@@ -197,6 +185,7 @@ export class ComfyPage {
this.visibleToasts = page.locator('.p-toast-message:visible')
this.searchBox = new ComfyNodeSearchBox(page)
this.queueList = new QueueList(page)
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
@@ -256,9 +245,6 @@ export class ComfyPage {
await this.page.evaluate(async () => {
await window['app'].extensionManager.workflow.syncWorkflows()
})
// Wait for Vue to re-render the workflow list
await this.nextFrame()
}
async setupUser(username: string) {
@@ -1653,55 +1639,6 @@ export class ComfyPage {
}, focusMode)
await this.nextFrame()
}
/**
* Get the position of a group by title.
* @param title The title of the group to find
* @returns The group's canvas position
* @throws Error if group not found
*/
async getGroupPosition(title: string): Promise<Position> {
const pos = await this.page.evaluate((title) => {
const groups = window['app'].graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
return { x: group.pos[0], y: group.pos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
/**
* Drag a group by its title.
* @param options.name The title of the group to drag
* @param options.deltaX Horizontal drag distance in screen pixels
* @param options.deltaY Vertical drag distance in screen pixels
*/
async dragGroup(options: {
name: string
deltaX: number
deltaY: number
}): Promise<void> {
const { name, deltaX, deltaY } = options
const screenPos = await this.page.evaluate((title) => {
const app = window['app']
const groups = app.graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
// Position in the title area of the group
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, name)
if (!screenPos) throw new Error(`Group "${name}" not found`)
await this.dragAndDrop(screenPos, {
x: screenPos.x + deltaX,
y: screenPos.y + deltaY
})
}
}
export const testComfySnapToGridGridSize = 50

View File

@@ -0,0 +1,57 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
export class QueueList {
constructor(public readonly page: Page) {}
get toggleButton() {
return this.page.getByTestId('queue-toggle-button')
}
get inlineProgress() {
return this.page.getByTestId('queue-inline-progress')
}
get overlay() {
return this.page.getByTestId('queue-overlay')
}
get closeButton() {
return this.page.getByTestId('queue-overlay-close-button')
}
get jobItems() {
return this.page.getByTestId('queue-job-item')
}
get clearHistoryButton() {
return this.page.getByRole('button', { name: /Clear History/i })
}
async open() {
if (!(await this.overlay.isVisible())) {
await this.toggleButton.click()
await expect(this.overlay).toBeVisible()
}
}
async close() {
if (await this.overlay.isVisible()) {
await this.closeButton.click()
await expect(this.overlay).not.toBeVisible()
}
}
async getJobCount(state?: string) {
if (state) {
return await this.page
.locator(`[data-testid="queue-job-item"][data-job-state="${state}"]`)
.count()
}
return await this.jobItems.count()
}
getJobAction(actionKey: string) {
return this.page.getByTestId(`job-action-${actionKey}`)
}
}

View File

@@ -1,7 +1,11 @@
import { test as base } from '@playwright/test'
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
export type WsMessage = { type: 'status'; data: StatusWsMessage }
export const webSocketFixture = base.extend<{
ws: { trigger(data: any, url?: string): Promise<void> }
ws: { trigger(data: WsMessage, url?: string): Promise<void> }
}>({
ws: [
async ({ page }, use) => {

View File

@@ -1,9 +1,9 @@
import type { Response } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { StatusWsMessage } from '../../src/schemas/apiSchema.ts'
import { comfyPageFixture } from '../fixtures/ComfyPage.ts'
import { webSocketFixture } from '../fixtures/ws.ts'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import type { WsMessage } from '../fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
@@ -61,7 +61,7 @@ test.describe('Actionbar', () => {
// Trigger a status websocket message
const triggerStatus = async (queueSize: number) => {
await ws.trigger({
const message = {
type: 'status',
data: {
status: {
@@ -70,7 +70,9 @@ test.describe('Actionbar', () => {
}
}
}
} as StatusWsMessage)
} satisfies WsMessage
await ws.trigger(message)
}
// Extract the width from the queue response

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,157 @@
import { expect, mergeTests } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture } from '../../fixtures/ComfyPage'
import { webSocketFixture } from '../../fixtures/ws'
import type { WsMessage } from '../../fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
type QueueState = {
running: QueueJob[]
pending: QueueJob[]
}
type QueueJob = [
string,
string,
Record<string, unknown>,
Record<string, unknown>,
string[]
]
type QueueController = {
state: QueueState
sync: (
ws: { trigger(data: WsMessage, url?: string): Promise<void> },
nextState: Partial<QueueState>
) => Promise<void>
}
test.describe('Queue UI', () => {
let queue: QueueController
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.route('**/api/prompt', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
prompt_id: 'mock-prompt-id',
number: 1,
node_errors: {}
})
})
})
// Mock history to avoid pulling real data
await comfyPage.page.route('**/api/history**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ History: [] })
})
})
queue = await createQueueController(comfyPage)
})
test('toggles overlay and updates count from status events', async ({
comfyPage,
ws
}) => {
await queue.sync(ws, { running: [], pending: [] })
await expect(comfyPage.queueList.toggleButton).toContainText('0')
await expect(comfyPage.queueList.toggleButton).toContainText(/queued/i)
await expect(comfyPage.queueList.overlay).toBeHidden()
await queue.sync(ws, {
pending: [queueJob('1', 'mock-pending', 'client-a')]
})
await expect(comfyPage.queueList.toggleButton).toContainText('1')
await expect(comfyPage.queueList.toggleButton).toContainText(/queued/i)
await comfyPage.queueList.open()
await expect(comfyPage.queueList.overlay).toBeVisible()
await expect(comfyPage.queueList.jobItems).toHaveCount(1)
await comfyPage.queueList.close()
await expect(comfyPage.queueList.overlay).toBeHidden()
})
test('displays running and pending jobs via status updates', async ({
comfyPage,
ws
}) => {
await queue.sync(ws, {
running: [queueJob('2', 'mock-running', 'client-b')],
pending: [queueJob('3', 'mock-pending', 'client-c')]
})
await comfyPage.queueList.open()
await expect(comfyPage.queueList.jobItems).toHaveCount(2)
const firstJob = comfyPage.queueList.jobItems.first()
await firstJob.hover()
const cancelAction = firstJob
.getByTestId('job-action-cancel-running')
.or(firstJob.getByTestId('job-action-cancel-hover'))
await expect(cancelAction).toBeVisible()
})
})
const queueJob = (
queueIndex: string,
promptId: string,
clientId: string
): QueueJob => [
queueIndex,
promptId,
{ client_id: clientId },
{ class_type: 'Note' },
['output']
]
const createQueueController = async (
comfyPage: ComfyPage
): Promise<QueueController> => {
const state: QueueState = { running: [], pending: [] }
// Single queue handler reads the latest in-memory state
await comfyPage.page.route('**/api/queue', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
queue_running: state.running,
queue_pending: state.pending
})
})
})
const sync = async (
ws: { trigger(data: WsMessage, url?: string): Promise<void> },
nextState: Partial<QueueState>
) => {
if (nextState.running) state.running = nextState.running
if (nextState.pending) state.pending = nextState.pending
const total = state.running.length + state.pending.length
const queueResponse = comfyPage.page.waitForResponse('**/api/queue')
await ws.trigger({
type: 'status',
data: {
status: { exec_info: { queue_remaining: total } }
}
})
await queueResponse
}
return { state, sync }
}

View File

@@ -32,42 +32,4 @@ test.describe('Vue Node Groups', () => {
'vue-groups-fit-to-contents.png'
)
})
test('should move nested groups together when dragging outer group', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('groups/nested-groups-1-inner-node')
// Get initial positions with null guards
const outerInitial = await comfyPage.getGroupPosition('Outer Group')
const innerInitial = await comfyPage.getGroupPosition('Inner Group')
const initialOffsetX = innerInitial.x - outerInitial.x
const initialOffsetY = innerInitial.y - outerInitial.y
// Drag the outer group
const dragDelta = { x: 100, y: 80 }
await comfyPage.dragGroup({
name: 'Outer Group',
deltaX: dragDelta.x,
deltaY: dragDelta.y
})
// Use retrying assertion to wait for positions to update
await expect(async () => {
const outerFinal = await comfyPage.getGroupPosition('Outer Group')
const innerFinal = await comfyPage.getGroupPosition('Inner Group')
const finalOffsetX = innerFinal.x - outerFinal.x
const finalOffsetY = innerFinal.y - outerFinal.y
// Both groups should have moved
expect(outerFinal.x).not.toBe(outerInitial.x)
expect(innerFinal.x).not.toBe(innerInitial.x)
// The relative offset should be maintained (inner group moved with outer)
expect(finalOffsetX).toBeCloseTo(initialOffsetX, 0)
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
}).toPass({ timeout: 5000 })
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -1,97 +0,0 @@
# 5. Remove Import Map for Vue Extensions
Date: 2025-12-13
## Status
Accepted
## Context
ComfyUI frontend previously used a Vite plugin (`generateImportMapPlugin`) to inject an HTML import map exposing shared modules to extensions. This allowed Vue-based extensions to mark dependencies as external in their Vite configs:
```typescript
// Extension vite.config.ts (old pattern)
rollupOptions: {
external: ['vue', 'vue-i18n', 'pinia', /^primevue\/?.*/, ...]
}
```
The import map resolved bare specifiers like `import { ref } from 'vue'` at runtime by mapping them to pre-built ESM files served from `/assets/lib/`.
**Modules exposed via import map:**
- `vue` (vue.esm-browser.prod.js)
- `vue-i18n` (vue-i18n.esm-browser.prod.js)
- `primevue/*` (all PrimeVue components)
- `@primevue/themes/*`
- `@primevue/forms/*`
**Problems with import map approach:**
1. **Blocked tree shaking**: Vue and PrimeVue loaded as remote modules at runtime, preventing bundler optimizations. The entire Vue runtime was loaded even if only a few APIs were used.
2. **Poor code splitting**: PrimeVue's component library split into hundreds of small chunks, each requiring a separate network request on mount. This significantly impacted initial page load.
3. **Cold start performance**: Each externalized module required a separate HTTP request and browser module resolution step. This compounded on lower-end systems and slower networks.
4. **Version alignment complexity**: Extensions relied on the frontend's Vue version at runtime. Subtle version mismatches between build-time types and runtime code caused debugging difficulties.
5. **Incompatible with Cloud distribution**: The Cloud deployment model requires fully bundled, optimized assets. Import maps added a layer of indirection incompatible with our CDN and caching strategy.
## Decision
Remove the `generateImportMapPlugin` and require Vue-based extensions to bundle their own Vue instance.
**Implementation (PR #6899):**
- Deleted `build/plugins/generateImportMapPlugin.ts`
- Removed plugin configuration from `vite.config.mts`
- Removed `fast-glob` dependency used by the plugin
**Extension migration path:**
1. Remove `external: ['vue', ...]` from Vite rollup options
2. Vue and related dependencies will be bundled into the extension output
3. No code changes required in extension source files
The import map was already disabled for Cloud builds (PR #6559) before complete removal. Removal aligns all distribution channels on the same bundling strategy.
## Consequences
### Positive
- **Improved page load**: Full tree shaking and optimal code splitting now apply to Vue and PrimeVue
- **Faster development**: No import map generation step; simplified build pipeline
- **Better debugging**: Extension's bundled Vue matches build-time expectations exactly
- **Cloud compatibility**: All assets fully bundled and CDN-optimizable
- **Consistent behavior**: Same bundling strategy across desktop, localhost, and cloud distributions
- **Reduced network requests**: Fewer module fetches on initial page load
### Negative
- **Breaking change for existing extensions**: Extensions using `external: ['vue']` pattern fail with "Failed to resolve module specifier 'vue'" error
- **Larger extension bundles**: Each extension now includes its own Vue instance (~30KB gzipped)
- **Potential version fragmentation**: Different extensions may bundle different Vue versions (mitigated by Vue's stable API)
### Migration Impact
Extensions affected must update their build configuration. The migration is straightforward:
```diff
// vite.config.ts
rollupOptions: {
- external: ['vue', 'vue-i18n', 'primevue', ...]
}
```
Affected versions:
- **v1.32.x - v1.33.8**: Import map present, external pattern works
- **v1.33.9+**: Import map removed, bundling required
## Notes
- [ComfyUI_frontend_vue_basic](https://github.com/jtydhr88/ComfyUI_frontend_vue_basic) has been updated to demonstrate the new bundled pattern
- Issue #7267 documents the user-facing impact and migration discussion
- Future Extension API v2 (Issue #4668) may provide alternative mechanisms for shared dependencies

View File

@@ -14,7 +14,6 @@ An Architecture Decision Record captures an important architectural decision mad
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
## Creating a New ADR

View File

@@ -5,6 +5,8 @@
<title>ComfyUI</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" />
<link rel="stylesheet" type="text/css" href="user.css" />
<link rel="stylesheet" type="text/css" href="api/userdata/user.css" />
<!-- Fullscreen mode on mobile browsers -->
<meta name="mobile-web-app-capable" content="yes">

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.36.1",
"version": "1.35.6",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -1,34 +1,14 @@
<template>
<div
v-if="!workspaceStore.focusMode"
class="ml-1 flex gap-x-0.5 pt-1"
@mouseenter="isTopMenuHovered = true"
@mouseleave="isTopMenuHovered = false"
>
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div v-if="!workspaceStore.focusMode" class="ml-1 flex flex-col gap-1 pt-1">
<div class="flex gap-x-0.5">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div class="mx-1 flex flex-col items-end gap-1">
<div
v-if="managerState.shouldShowManagerButtons.value && isDesktop"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<IconButton
v-tooltip.bottom="customNodesManagerTooltipConfig"
type="transparent"
size="sm"
class="text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('menu.customNodesManager')"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
</IconButton>
</div>
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
ref="actionbarContainerRef"
class="actionbar-container relative pointer-events-auto flex h-12 items-center overflow-hidden rounded-lg border border-interface-stroke px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
@@ -36,26 +16,11 @@
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
>
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
>
{{ queuedCount }}
</span>
</IconButton>
<ComfyActionbar
v-model:docked="isActionbarDocked"
v-model:queue-overlay-expanded="isQueueOverlayExpanded"
:top-menu-container="actionbarContainerRef"
/>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
@@ -70,16 +35,22 @@
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
</div>
<QueueProgressOverlay
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
</div>
<div>
<QueueInlineProgressSummary
v-if="!isActionbarFloating"
class="pr-1"
:hidden="isQueueOverlayExpanded"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -87,41 +58,39 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import IconButton from '@/components/button/IconButton.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useQueueStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const settingsStore = useSettingStore()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const isQueueOverlayExpanded = ref(false)
const queueStore = useQueueStore()
const isTopMenuHovered = ref(false)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
const actionbarContainerRef = ref<HTMLElement>()
const isActionbarDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const actionbarPosition = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const isActionbarEnabled = computed(
() => actionbarPosition.value !== 'Disabled'
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
@@ -134,24 +103,10 @@ onMounted(() => {
legacyCommandsContainerRef.value.appendChild(app.menu.element)
}
})
const toggleQueueOverlay = () => {
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
}
const openCustomNodeManager = async () => {
try {
await managerState.openManager({
initialTab: ManagerTab.All,
showToastOnLegacyError: false
})
} catch (error) {
try {
toastErrorHandler(error)
} catch (toastError) {
console.error(error)
console.error(toastError)
}
}
}
</script>
<style scoped>
.actionbar-container {
background-color: var(--comfy-menu-bg);
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex h-full items-center">
<div
v-if="isDragging && !isDocked"
v-if="isDragging && !docked"
:class="actionbarClass"
@mouseenter="onMouseEnterDropZone"
@mouseleave="onMouseLeaveDropZone"
@@ -9,46 +9,101 @@
{{ t('actionbar.dockToTop') }}
</div>
<Panel
class="pointer-events-auto"
:style="style"
<div
ref="actionbarWrapperRef"
:class="panelClass"
:pt="{
header: { class: 'hidden' },
content: { class: isDocked ? 'p-0' : 'p-1' }
}"
:style="style"
class="flex flex-col items-stretch"
>
<div ref="panelRef" class="flex items-center select-none">
<span
ref="dragHandleRef"
:class="
cn(
'drag-handle cursor-grab w-3 h-max mr-2',
isDragging && 'cursor-grabbing'
)
"
/>
<Suspense @resolve="comfyRunButtonResolved">
<ComfyRunButton />
</Suspense>
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
size="sm"
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
<Panel
:class="
cn(
panelRootClass,
isDragging ? 'pointer-events-none' : 'pointer-events-auto'
)
"
:pt="{
header: { class: 'hidden' },
content: { class: 'p-0' }
}"
>
<div
ref="panelRef"
:class="cn('flex flex-col', docked ? 'p-0' : 'p-1')"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<div class="flex items-center select-none">
<span
ref="dragHandleRef"
:class="
cn(
'drag-handle cursor-grab w-3 h-max mr-2',
isDragging && 'cursor-grabbing'
)
"
/>
<Suspense @resolve="comfyRunButtonResolved">
<ComfyRunButton />
</Suspense>
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
size="sm"
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<IconTextButton
v-tooltip.bottom="queueHistoryTooltipConfig"
size="sm"
type="secondary"
icon-position="right"
data-testid="queue-toggle-button"
class="ml-2 h-8 border-0 px-3 text-sm font-medium text-base-foreground cursor-pointer"
:aria-pressed="props.queueOverlayExpanded"
:aria-label="queueToggleLabel"
:label="queueToggleLabel"
@click="toggleQueueOverlay"
>
<!-- Custom implementation for static 1-2 digit shifts -->
<span class="flex items-center gap-1">
<span
class="inline-flex min-w-[2ch] justify-center tabular-nums text-center"
>
{{ queuedCount }}
</span>
<span>{{ queuedSuffix }}</span>
</span>
<template #icon>
<i class="icon-[lucide--chevron-down] size-4" />
</template>
</IconTextButton>
</div>
</div>
</Panel>
<div v-if="isFloating" class="flex justify-end pt-1 pr-1">
<QueueInlineProgressSummary
class="pr-1"
:hidden="props.queueOverlayExpanded"
/>
</div>
</Panel>
</div>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="props.queueOverlayExpanded"
data-testid="queue-inline-progress"
/>
</Teleport>
</div>
</template>
<script lang="ts" setup>
import {
unrefElement,
useDraggable,
useEventListener,
useLocalStorage,
@@ -58,34 +113,59 @@ import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const props = defineProps<{
queueOverlayExpanded: boolean
topMenuContainer?: HTMLElement | null
}>()
const emit = defineEmits<{
(e: 'update:queueOverlayExpanded', value: boolean): void
}>()
const { t } = useI18n()
const settingsStore = useSettingStore()
const executionStore = useExecutionStore()
const commandStore = useCommandStore()
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const queueStore = useQueueStore()
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const tabContainer = document.querySelector('.workflow-tabs-container')
const actionbarWrapperRef = ref<HTMLElement | null>(null)
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const docked = defineModel<boolean>('docked', { default: false })
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
const { x, y, style, isDragging } = useDraggable(panelRef, {
const wrapperElement = computed(() => {
const element = unrefElement(actionbarWrapperRef)
return element instanceof HTMLElement ? element : null
})
const panelElement = computed(() => {
const element = unrefElement(panelRef)
return element instanceof HTMLElement ? element : null
})
const { x, y, style, isDragging } = useDraggable(wrapperElement, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body,
@@ -98,6 +178,33 @@ const { x, y, style, isDragging } = useDraggable(panelRef, {
}
})
// Queue and Execution logic
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueToggleLabel = computed(() =>
t('sideToolbar.queueProgressOverlay.toggleLabel', {
count: queuedCount.value
})
)
const queuedSuffix = computed(() =>
t('sideToolbar.queueProgressOverlay.queuedSuffix')
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const toggleQueueOverlay = () => {
emit('update:queueOverlayExpanded', !props.queueOverlayExpanded)
}
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
// Update storedPosition when x or y changes
watchDebounced(
[x, y],
@@ -109,11 +216,12 @@ watchDebounced(
// Set initial position to bottom center
const setInitialPosition = () => {
if (panelRef.value) {
const containerEl = wrapperElement.value
if (containerEl) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
const menuWidth = containerEl.offsetWidth
const menuHeight = containerEl.offsetHeight
if (menuWidth === 0 || menuHeight === 0) {
return
@@ -189,11 +297,12 @@ watch(
)
const adjustMenuPosition = () => {
if (panelRef.value) {
const containerEl = wrapperElement.value
if (containerEl) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
const menuWidth = containerEl.offsetWidth
const menuHeight = containerEl.offsetHeight
// Calculate distances to all edges
const distanceLeft = lastDragState.value.x
@@ -264,31 +373,27 @@ const onMouseLeaveDropZone = () => {
watch(isDragging, (dragging) => {
if (dragging) {
// Starting to drag - undock if docked
if (isDocked.value) {
isDocked.value = false
if (docked.value) {
docked.value = false
}
} else {
// Stopped dragging - dock if mouse is over drop zone
if (isMouseOverDropZone.value) {
isDocked.value = true
docked.value = true
}
// Reset drop zone state
isMouseOverDropZone.value = false
}
})
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
const isFloating = computed(() => visible.value && !docked.value)
const inlineProgressTarget = computed(() => {
if (!visible.value) return null
if (isFloating.value) return panelElement.value
return props.topMenuContainer ?? null
})
const actionbarClass = computed(() =>
cn(
'w-[200px] border-dashed border-blue-500 opacity-80',
'w-[300px] border-dashed border-blue-500 opacity-80',
'm-1.5 flex items-center justify-center self-stretch',
'rounded-md before:w-50 before:-ml-50 before:h-full',
'pointer-events-auto',
@@ -298,11 +403,21 @@ const actionbarClass = computed(() =>
)
const panelClass = computed(() =>
cn(
'actionbar pointer-events-auto z-1300',
isDragging.value && 'select-none pointer-events-none',
isDocked.value
? 'p-0 static mr-2 border-none bg-transparent'
: 'fixed shadow-interface'
'actionbar z-1300 overflow-hidden rounded-[var(--p-panel-border-radius)]',
docked.value ? 'p-0 static mr-2 border-none bg-transparent' : 'fixed',
isDragging.value ? 'select-none pointer-events-none' : 'pointer-events-auto'
)
)
const panelRootClass = computed(() =>
cn(
'relative overflow-hidden rounded-[var(--p-panel-border-radius)]',
docked.value
? 'border-none shadow-none bg-transparent'
: 'border border-interface-stroke shadow-interface'
)
)
defineExpose({
isFloating
})
</script>

View File

@@ -7,14 +7,15 @@
@click="onClick"
>
<slot v-if="iconPosition !== 'right'" name="icon"></slot>
<span>{{ label }}</span>
<slot v-if="hasDefaultSlot"></slot>
<span v-else>{{ label }}</span>
<slot v-if="iconPosition === 'right'" name="icon"></slot>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { computed, useSlots } from 'vue'
import type { BaseButtonProps } from '@/types/buttonTypes'
import {
@@ -46,6 +47,9 @@ const {
onClick
} = defineProps<IconTextButtonProps>()
const slots = useSlots()
const hasDefaultSlot = computed(() => Boolean(slots.default?.().length))
const buttonStyle = computed(() => {
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
const sizeClasses = getButtonSizeClasses(size)

View File

@@ -117,16 +117,7 @@ onBeforeUnmount(() => {
.scroll-container {
height: 100%;
overflow-y: auto;
/* Firefox */
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
scrollbar-width: thin;
scrollbar-color: var(--dialog-surface) transparent;
}
</style>

View File

@@ -15,7 +15,9 @@
<script setup lang="ts">
import Tag from 'primevue/tag'
import { isStaging } from '@/config/staging'
// Global variable from vite build defined in global.d.ts
// eslint-disable-next-line no-undef
const isStaging = !__USE_PROD_CONFIG__
</script>
<style scoped>

View File

@@ -0,0 +1,33 @@
<template>
<div
v-if="shouldShow"
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 bottom-0 h-[3px]"
>
<div
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${totalPercent}%` }"
/>
<div
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${currentNodePercent}%` }"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
const props = defineProps<{
hidden?: boolean
}>()
const { totalPercent, currentNodePercent } = useQueueProgress()
const shouldShow = computed(
() =>
!props.hidden && (totalPercent.value > 0 || currentNodePercent.value > 0)
)
</script>

View File

@@ -0,0 +1,211 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import QueueInlineProgressSummary from './QueueInlineProgressSummary.vue'
import { useExecutionStore } from '@/stores/executionStore'
import { ChangeTracker } from '@/scripts/changeTracker'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeProgressState, ProgressWsMessage } from '@/schemas/apiSchema'
type SeedOptions = {
promptId: string
nodes: Record<NodeId, boolean>
runningNodeId?: NodeId
runningNodeTitle?: string
runningNodeType?: string
currentValue?: number
currentMax?: number
}
function createWorkflow({
promptId,
nodes,
runningNodeId,
runningNodeTitle,
runningNodeType
}: SeedOptions): ComfyWorkflow {
const workflow = new ComfyWorkflow({
path: `${ComfyWorkflow.basePath}${promptId}.json`,
modified: Date.now(),
size: -1
})
const workflowState: ComfyWorkflowJSON = {
last_node_id: Object.keys(nodes).length,
last_link_id: 0,
nodes: Object.keys(nodes).map((id, index) => ({
id,
type: id === runningNodeId ? (runningNodeType ?? 'Node') : 'Node',
title: id === runningNodeId ? (runningNodeTitle ?? '') : `Node ${id}`,
pos: [index * 120, 0],
size: [240, 120],
flags: {},
order: index,
mode: 0,
properties: {},
widgets_values: []
})),
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}
workflow.changeTracker = new ChangeTracker(workflow, workflowState)
return workflow
}
function resetExecutionStore() {
const exec = useExecutionStore()
exec.activePromptId = null
exec.queuedPrompts = {}
exec.nodeProgressStates = {}
exec.nodeProgressStatesByPrompt = {}
exec._executingNodeProgress = null
exec.lastExecutionError = null
exec.lastNodeErrors = null
exec.initializingPromptIds = new Set()
exec.promptIdToWorkflowId = new Map()
}
function seedExecutionState({
promptId,
nodes,
runningNodeId,
runningNodeTitle,
runningNodeType,
currentValue = 0,
currentMax = 100
}: SeedOptions) {
resetExecutionStore()
const exec = useExecutionStore()
const workflow = runningNodeId
? createWorkflow({
promptId,
nodes,
runningNodeId,
runningNodeTitle,
runningNodeType
})
: undefined
exec.activePromptId = promptId
exec.queuedPrompts = {
[promptId]: {
nodes,
...(workflow ? { workflow } : {})
}
}
const nodeProgress: Record<string, NodeProgressState> = runningNodeId
? {
[String(runningNodeId)]: {
value: currentValue,
max: currentMax,
state: 'running',
node_id: runningNodeId,
display_node_id: runningNodeId,
prompt_id: promptId
}
}
: {}
exec.nodeProgressStates = nodeProgress
exec.nodeProgressStatesByPrompt = runningNodeId
? { [promptId]: nodeProgress }
: {}
exec._executingNodeProgress = runningNodeId
? ({
value: currentValue,
max: currentMax,
prompt_id: promptId,
node: runningNodeId
} satisfies ProgressWsMessage)
: null
}
const meta: Meta<typeof QueueInlineProgressSummary> = {
title: 'Queue/QueueInlineProgressSummary',
component: QueueInlineProgressSummary,
parameters: {
layout: 'padded',
backgrounds: {
default: 'light'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const RunningKSampler: Story = {
render: () => ({
components: { QueueInlineProgressSummary },
setup() {
seedExecutionState({
promptId: 'prompt-running',
nodes: { '1': true, '2': false, '3': false, '4': true },
runningNodeId: '2',
runningNodeTitle: 'KSampler',
runningNodeType: 'KSampler',
currentValue: 12,
currentMax: 100
})
return {}
},
template: `
<div style="background: var(--color-surface-primary); width: 420px; padding: 12px;">
<QueueInlineProgressSummary />
</div>
`
})
}
export const RunningWithFallbackName: Story = {
render: () => ({
components: { QueueInlineProgressSummary },
setup() {
seedExecutionState({
promptId: 'prompt-fallback',
nodes: { '10': true, '11': true, '12': false, '13': true },
runningNodeId: '12',
runningNodeTitle: '',
runningNodeType: 'custom_node',
currentValue: 78,
currentMax: 100
})
return {}
},
template: `
<div style="background: var(--color-surface-primary); width: 420px; padding: 12px;">
<QueueInlineProgressSummary />
</div>
`
})
}
export const ProgressWithoutCurrentNode: Story = {
render: () => ({
components: { QueueInlineProgressSummary },
setup() {
seedExecutionState({
promptId: 'prompt-progress-only',
nodes: { '21': true, '22': true, '23': true, '24': false }
})
return {}
},
template: `
<div style="background: var(--color-surface-primary); width: 420px; padding: 12px;">
<QueueInlineProgressSummary />
</div>
`
})
}

View File

@@ -0,0 +1,62 @@
<template>
<div v-if="shouldShow" class="flex justify-end">
<div
class="flex items-center gap-4 whitespace-nowrap text-[0.75rem] leading-[normal] drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
role="status"
aria-live="polite"
aria-atomic="true"
>
<div class="flex items-center gap-1 text-base-foreground">
<span class="font-normal">
{{ t('sideToolbar.queueProgressOverlay.inlineTotalLabel') }}:
</span>
<span class="w-[5ch] shrink-0 text-right font-bold tabular-nums">
{{ totalPercentFormatted }}
</span>
</div>
<div class="flex items-center gap-1 text-muted-foreground">
<span
class="w-[16ch] shrink-0 truncate text-right"
:title="currentNodeName"
>
{{ currentNodeName }}:
</span>
<span class="w-[5ch] shrink-0 text-right tabular-nums">
{{ currentNodePercentFormatted }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentNodeName } from '@/composables/queue/useCurrentNodeName'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useExecutionStore } from '@/stores/executionStore'
const props = defineProps<{
hidden?: boolean
}>()
const { t } = useI18n()
const executionStore = useExecutionStore()
const { currentNodeName } = useCurrentNodeName()
const {
totalPercent,
totalPercentFormatted,
currentNodePercent,
currentNodePercentFormatted
} = useQueueProgress()
const shouldShow = computed(
() =>
!props.hidden &&
(!executionStore.isIdle ||
totalPercent.value > 0 ||
currentNodePercent.value > 0)
)
</script>

View File

@@ -1,125 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import QueueOverlayActive from './QueueOverlayActive.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
total: 'Total: {percent}',
currentNode: 'Current node:',
running: 'running',
interruptAll: 'Interrupt all running jobs',
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
viewAllJobs: 'View all jobs',
cancelJobTooltip: 'Cancel job',
clearQueueTooltip: 'Clear queue'
}
}
}
}
})
const tooltipDirectiveStub = {
mounted: vi.fn(),
updated: vi.fn()
}
const SELECTORS = {
interruptAllButton: 'button[aria-label="Interrupt all running jobs"]',
clearQueuedButton: 'button[aria-label="Clear queued"]',
summaryRow: '.flex.items-center.gap-2',
currentNodeRow: '.flex.items-center.gap-1.text-text-secondary'
}
const COPY = {
viewAllJobs: 'View all jobs'
}
const mountComponent = (props: Record<string, unknown> = {}) =>
mount(QueueOverlayActive, {
props: {
totalProgressStyle: { width: '65%' },
currentNodeProgressStyle: { width: '40%' },
totalPercentFormatted: '65%',
currentNodePercentFormatted: '40%',
currentNodeName: 'Sampler',
runningCount: 1,
queuedCount: 2,
bottomRowClass: 'flex custom-bottom-row',
...props
},
global: {
plugins: [i18n],
directives: {
tooltip: tooltipDirectiveStub
}
}
})
describe('QueueOverlayActive', () => {
it('renders progress metrics and emits actions when buttons clicked', async () => {
const wrapper = mountComponent({ runningCount: 2, queuedCount: 3 })
const progressBars = wrapper.findAll('.absolute.inset-0')
expect(progressBars[0].attributes('style')).toContain('width: 65%')
expect(progressBars[1].attributes('style')).toContain('width: 40%')
const content = wrapper.text().replace(/\s+/g, ' ')
expect(content).toContain('Total: 65%')
const [runningSection, queuedSection] = wrapper.findAll(
SELECTORS.summaryRow
)
expect(runningSection.text()).toContain('2')
expect(runningSection.text()).toContain('running')
expect(queuedSection.text()).toContain('3')
expect(queuedSection.text()).toContain('queued')
const currentNodeSection = wrapper.find(SELECTORS.currentNodeRow)
expect(currentNodeSection.text()).toContain('Current node:')
expect(currentNodeSection.text()).toContain('Sampler')
expect(currentNodeSection.text()).toContain('40%')
const interruptButton = wrapper.get(SELECTORS.interruptAllButton)
await interruptButton.trigger('click')
expect(wrapper.emitted('interruptAll')).toHaveLength(1)
const clearQueuedButton = wrapper.get(SELECTORS.clearQueuedButton)
await clearQueuedButton.trigger('click')
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
const buttons = wrapper.findAll('button')
const viewAllButton = buttons.find((btn) =>
btn.text().includes(COPY.viewAllJobs)
)
expect(viewAllButton).toBeDefined()
await viewAllButton!.trigger('click')
expect(wrapper.emitted('viewAllJobs')).toHaveLength(1)
expect(wrapper.find('.custom-bottom-row').exists()).toBe(true)
})
it('hides action buttons when counts are zero', () => {
const wrapper = mountComponent({ runningCount: 0, queuedCount: 0 })
expect(wrapper.find(SELECTORS.interruptAllButton).exists()).toBe(false)
expect(wrapper.find(SELECTORS.clearQueuedButton).exists()).toBe(false)
})
it('builds tooltip configs with translated strings', () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
mountComponent()
expect(spy).toHaveBeenCalledWith('Cancel job')
expect(spy).toHaveBeenCalledWith('Clear queue')
})
})

View File

@@ -1,125 +0,0 @@
<template>
<div class="flex flex-col gap-3 p-2">
<div class="flex flex-col gap-1">
<div
class="relative h-2 w-full overflow-hidden rounded-full border border-interface-stroke bg-interface-panel-surface"
>
<div
class="absolute inset-0 h-full rounded-full transition-[width]"
:style="totalProgressStyle"
/>
<div
class="absolute inset-0 h-full rounded-full transition-[width]"
:style="currentNodeProgressStyle"
/>
</div>
<div class="flex items-start justify-end gap-4 text-[12px] leading-none">
<div class="flex items-center gap-1 text-text-primary opacity-90">
<i18n-t keypath="sideToolbar.queueProgressOverlay.total">
<template #percent>
<span class="font-bold">{{ totalPercentFormatted }}</span>
</template>
</i18n-t>
</div>
<div class="flex items-center gap-1 text-text-secondary">
<span>{{ t('sideToolbar.queueProgressOverlay.currentNode') }}</span>
<span class="inline-block max-w-[10rem] truncate">{{
currentNodeName
}}</span>
<span class="flex items-center gap-1">
<span>{{ currentNodePercentFormatted }}</span>
</span>
</div>
</div>
</div>
<div :class="bottomRowClass">
<div class="flex items-center gap-4 text-[12px] text-text-primary">
<div class="flex items-center gap-2">
<span class="opacity-90">
<span class="font-bold">{{ runningCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.running')
}}</span>
</span>
<IconButton
v-if="runningCount > 0"
v-tooltip.top="cancelJobTooltip"
type="secondary"
size="sm"
class="size-6 bg-destructive-background hover:bg-destructive-background-hover"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')"
>
<i
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
/>
</IconButton>
</div>
<div class="flex items-center gap-2">
<span class="opacity-90">
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</span>
<IconButton
v-if="queuedCount > 0"
v-tooltip.top="clearQueueTooltip"
type="secondary"
size="sm"
class="size-6 bg-secondary-background hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
/>
</IconButton>
</div>
</div>
<TextButton
class="h-6 min-w-[120px] flex-1 px-2 py-0 text-[12px]"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.viewAllJobs')"
@click="$emit('viewAllJobs')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{
totalProgressStyle: Record<string, string>
currentNodeProgressStyle: Record<string, string>
totalPercentFormatted: string
currentNodePercentFormatted: string
currentNodeName: string
runningCount: number
queuedCount: number
bottomRowClass: string
}>()
defineEmits<{
(e: 'interruptAll'): void
(e: 'clearQueued'): void
(e: 'viewAllJobs'): void
}>()
const { t } = useI18n()
const cancelJobTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.cancelJobTooltip'))
)
const clearQueueTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
)
</script>

View File

@@ -1,63 +1,39 @@
<template>
<div class="flex w-full flex-col gap-4">
<div class="flex w-full flex-col gap-2">
<QueueOverlayHeader
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
@clear-history="$emit('clearHistory')"
@close="$emit('close')"
/>
<div class="flex items-center justify-between px-3">
<IconTextButton
class="grow gap-1 p-2 text-center font-inter text-[12px] leading-none hover:opacity-90 justify-center"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.showAssets')"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
@click="$emit('showAssets')"
<div
class="flex h-8 items-center justify-between px-3 text-[12px] leading-none"
>
<span class="text-muted-foreground">
{{ activeJobsCount }}
{{ t('sideToolbar.queueProgressOverlay.activeJobsSuffix') }}
</span>
<div
v-if="queuedCount > 0"
class="inline-flex items-center gap-2 text-text-primary"
>
<template #icon>
<div
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
aria-hidden="true"
/>
</template>
</IconTextButton>
<div class="ml-4 inline-flex items-center">
<div
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
>
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</div>
<span class="opacity-90">
{{ t('sideToolbar.queueProgressOverlay.clearQueue') }}
</span>
<IconButton
v-if="queuedCount > 0"
class="group ml-2 size-6 bg-secondary-background hover:bg-destructive-background"
type="secondary"
type="transparent"
size="sm"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
class="size-8 rounded-lg bg-destructive-background text-base-foreground hover:bg-destructive-background-hover transition-colors"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueue')"
@click="$emit('clearQueued')"
>
<i
class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background"
/>
<i class="icon-[lucide--list-x] size-4" />
</IconButton>
</div>
</div>
<JobFiltersBar
:selected-job-tab="selectedJobTab"
:selected-workflow-filter="selectedWorkflowFilter"
:selected-sort-mode="selectedSortMode"
:has-failed-jobs="hasFailedJobs"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
"
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
/>
<div class="flex-1 min-h-0 overflow-y-auto">
<JobGroupsList
:displayed-job-groups="displayedJobGroups"
@@ -81,19 +57,12 @@ import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import type {
JobGroup,
JobListItem,
JobSortMode,
JobTab
} from '@/composables/queue/useJobList'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
import JobFiltersBar from './job/JobFiltersBar.vue'
import JobGroupsList from './job/JobGroupsList.vue'
defineProps<{
@@ -101,20 +70,14 @@ defineProps<{
showConcurrentIndicator: boolean
concurrentWorkflowCount: number
queuedCount: number
selectedJobTab: JobTab
selectedWorkflowFilter: 'all' | 'current'
selectedSortMode: JobSortMode
activeJobsCount: number
displayedJobGroups: JobGroup[]
hasFailedJobs: boolean
}>()
const emit = defineEmits<{
(e: 'showAssets'): void
(e: 'clearHistory'): void
(e: 'clearQueued'): void
(e: 'update:selectedJobTab', value: JobTab): void
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
(e: 'update:selectedSortMode', value: JobSortMode): void
(e: 'close'): void
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'viewItem', item: JobListItem): void

View File

@@ -36,7 +36,7 @@ const i18n = createI18n({
locale: 'en',
messages: {
en: {
g: { more: 'More' },
g: { more: 'More', close: 'Close' },
sideToolbar: {
queueProgressOverlay: {
running: 'running',
@@ -95,4 +95,13 @@ describe('QueueOverlayHeader', () => {
expect(popoverHideSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
it('emits close when close button is clicked', async () => {
const wrapper = mountHeader()
const closeButton = wrapper.get('button[aria-label="Close"]')
await closeButton.trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
})

View File

@@ -62,6 +62,19 @@
</IconTextButton>
</div>
</Popover>
<IconButton
v-tooltip.top="closeTooltipConfig"
type="transparent"
size="sm"
class="size-6 bg-transparent hover:bg-secondary-background hover:opacity-100"
:aria-label="t('g.close')"
data-testid="queue-overlay-close-button"
@click="onCloseClick"
>
<i
class="icon-[lucide--x] block size-4 leading-none text-text-secondary"
/>
</IconButton>
</div>
</div>
</template>
@@ -84,16 +97,19 @@ defineProps<{
const emit = defineEmits<{
(e: 'clearHistory'): void
(e: 'close'): void
}>()
const { t } = useI18n()
const morePopoverRef = ref<PopoverMethods | null>(null)
const closeTooltipConfig = computed(() => buildTooltipConfig(t('g.close')))
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const onMoreClick = (event: MouseEvent) => {
morePopoverRef.value?.toggle(event)
}
const onCloseClick = () => emit('close')
const onClearHistoryFromMenu = () => {
morePopoverRef.value?.hide()
emit('clearHistory')

View File

@@ -6,45 +6,26 @@
<div
class="pointer-events-auto flex w-[350px] min-w-[310px] max-h-[60vh] flex-col overflow-hidden rounded-lg border font-inter transition-colors duration-200 ease-in-out"
:class="containerClass"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
data-testid="queue-overlay"
>
<!-- Expanded state -->
<QueueOverlayExpanded
v-if="isExpanded"
v-model:selected-job-tab="selectedJobTab"
v-model:selected-workflow-filter="selectedWorkflowFilter"
v-model:selected-sort-mode="selectedSortMode"
class="flex-1 min-h-0"
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
:active-jobs-count="activeJobsCount"
:queued-count="queuedCount"
:displayed-job-groups="displayedJobGroups"
:has-failed-jobs="hasFailedJobs"
@show-assets="openAssetsSidebar"
@clear-history="onClearHistoryFromMenu"
@clear-queued="cancelQueuedWorkflows"
@close="closeOverlay"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="inspectJobAsset"
/>
<QueueOverlayActive
v-else-if="hasActiveJob"
:total-progress-style="totalProgressStyle"
:current-node-progress-style="currentNodeProgressStyle"
:total-percent-formatted="totalPercentFormatted"
:current-node-percent-formatted="currentNodePercentFormatted"
:current-node-name="currentNodeName"
:running-count="runningCount"
:queued-count="queuedCount"
:bottom-row-class="bottomRowClass"
@interrupt-all="interruptAll"
@clear-queued="cancelQueuedWorkflows"
@view-all-jobs="viewAllJobs"
/>
<QueueOverlayEmpty
v-else-if="completionSummary"
:summary="completionSummary"
@@ -63,7 +44,6 @@
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
import QueueOverlayEmpty from '@/components/queue/QueueOverlayEmpty.vue'
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
@@ -71,11 +51,9 @@ import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -84,17 +62,11 @@ import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
type OverlayState = 'hidden' | 'empty' | 'expanded'
const props = withDefaults(
defineProps<{
expanded?: boolean
menuHovered?: boolean
}>(),
{
menuHovered: false
}
)
const props = defineProps<{
expanded?: boolean
}>()
const emit = defineEmits<{
(e: 'update:expanded', value: boolean): void
@@ -110,14 +82,6 @@ const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const {
totalPercentFormatted,
currentNodePercentFormatted,
totalProgressStyle,
currentNodeProgressStyle
} = useQueueProgress()
const isHovered = ref(false)
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
const internalExpanded = ref(false)
const isExpanded = computed({
get: () =>
@@ -141,16 +105,12 @@ const activeJobsCount = computed(() => runningCount.value + queuedCount.value)
const overlayState = computed<OverlayState>(() => {
if (isExpanded.value) return 'expanded'
if (hasActiveJob.value) return 'active'
if (hasCompletionSummary.value) return 'empty'
return 'hidden'
})
const showBackground = computed(
() =>
overlayState.value === 'expanded' ||
overlayState.value === 'empty' ||
(overlayState.value === 'active' && isOverlayHovered.value)
() => overlayState.value === 'expanded' || overlayState.value === 'empty'
)
const isVisible = computed(() => overlayState.value !== 'hidden')
@@ -161,14 +121,6 @@ const containerClass = computed(() =>
: 'border-transparent bg-transparent shadow-none'
)
const bottomRowClass = computed(
() =>
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
overlayState.value === 'active' && isOverlayHovered.value
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none'
}`
)
const headerTitle = computed(() =>
hasActiveJob.value
? `${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
@@ -182,15 +134,7 @@ const showConcurrentIndicator = computed(
() => concurrentWorkflowCount.value > 1
)
const {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
hasFailedJobs,
filteredTasks,
groupedJobItems,
currentNodeName
} = useJobList()
const { orderedTasks, groupedJobItems } = useJobList()
const displayedJobGroups = computed(() => groupedJobItems.value)
@@ -209,17 +153,17 @@ const {
galleryActiveIndex,
galleryItems,
onViewItem: openResultGallery
} = useResultGallery(() => filteredTasks.value)
} = useResultGallery(() => orderedTasks.value)
const setExpanded = (expanded: boolean) => {
isExpanded.value = expanded
}
const openExpandedFromEmpty = () => {
setExpanded(true)
const closeOverlay = () => {
setExpanded(false)
}
const viewAllJobs = () => {
const openExpandedFromEmpty = () => {
setExpanded(true)
}
@@ -262,25 +206,6 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
await commandStore.execute('Comfy.ClearPendingTasks')
})
const interruptAll = wrapWithErrorHandlingAsync(async () => {
const tasks = queueStore.runningTasks
const promptIds = tasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
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(promptIds.map((id) => api.deleteItem('queue', id)))
return
}
await Promise.all(promptIds.map((id) => api.interrupt(id)))
})
const showClearHistoryDialog = () => {
dialogStore.showDialog({
key: 'queue-clear-history',

View File

@@ -104,7 +104,6 @@ import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
@@ -122,13 +121,14 @@ const props = defineProps<{
workflowId?: string
}>()
const { locale, t } = useI18n()
const copyAriaLabel = computed(() => t('g.copy'))
const workflowStore = useWorkflowStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const dialog = useDialogService()
const { locale } = useI18n()
const workflowValue = computed(() => {
const wid = props.workflowId
@@ -260,6 +260,22 @@ const estimatedFinishInValue = computed(() => {
type DetailRow = { label: string; value: string; canCopy?: boolean }
const formatComputeHours = (execMs: number | undefined) => {
if (execMs === undefined) return ''
const hours = Math.max(0, execMs) / 3600000
const formatHours = (value: number) =>
new Intl.NumberFormat(locale.value, {
minimumFractionDigits: 3,
maximumFractionDigits: 3
}).format(value)
if (hours > 0 && hours < 0.001) {
return t('queue.jobDetails.computeHoursValueLessThan', {
hours: formatHours(0.001)
})
}
return t('queue.jobDetails.computeHoursValue', { hours: formatHours(hours) })
}
const baseRows = computed<DetailRow[]>(() => [
{ label: t('queue.jobDetails.workflow'), value: workflowValue.value },
{ label: t('queue.jobDetails.jobId'), value: jobIdValue.value, canCopy: true }
@@ -310,8 +326,7 @@ const extraRows = computed<DetailRow[]>(() => {
const generatedOnValue = endTs ? formatClockTime(endTs, locale.value) : ''
const totalGenTimeValue =
execMs !== undefined ? formatElapsedTime(execMs) : ''
const computeHoursValue =
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
const computeHoursValue = formatComputeHours(execMs)
const rows: DetailRow[] = [
{ label: t('queue.jobDetails.generatedOn'), value: generatedOnValue },
@@ -333,8 +348,7 @@ const extraRows = computed<DetailRow[]>(() => {
const execMs: number | undefined = task?.executionTime
const failedAfterValue =
execMs !== undefined ? formatElapsedTime(execMs) : ''
const computeHoursValue =
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
const computeHoursValue = formatComputeHours(execMs)
const rows: DetailRow[] = [
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value },
{ label: t('queue.jobDetails.failedAfter'), value: failedAfterValue }

View File

@@ -1,231 +0,0 @@
<template>
<div class="flex items-center justify-between gap-2 px-3">
<div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap">
<TextButton
v-for="tab in visibleJobTabs"
:key="tab"
class="h-6 px-3 py-1 text-[12px] leading-none hover:opacity-90"
:type="selectedJobTab === tab ? 'secondary' : 'transparent'"
:class="[
selectedJobTab === tab ? 'text-text-primary' : 'text-text-secondary'
]"
:label="tabLabel(tab)"
@click="$emit('update:selectedJobTab', tab)"
/>
</div>
</div>
<div class="ml-2 flex shrink-0 items-center gap-2">
<IconButton
v-if="showWorkflowFilter"
v-tooltip.top="filterTooltipConfig"
type="secondary"
size="sm"
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
@click="onFilterClick"
>
<i
class="icon-[lucide--list-filter] block size-4 leading-none text-text-primary"
/>
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</IconButton>
<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"
>
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="t('sideToolbar.queueProgressOverlay.filterAllWorkflows')"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
"
@click="selectWorkflowFilter('all')"
>
<template #icon>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
<div class="mx-2 mt-1 h-px" />
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
"
@click="selectWorkflowFilter('current')"
>
<template #icon>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
</div>
</Popover>
<IconButton
v-tooltip.top="sortTooltipConfig"
type="secondary"
size="sm"
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
@click="onSortClick"
>
<i
class="icon-[lucide--arrow-up-down] block size-4 leading-none text-text-primary"
/>
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</IconButton>
<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">
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="sortLabel(mode)"
:aria-label="sortLabel(mode)"
@click="selectSortMode(mode)"
>
<template #icon>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
/>
</template>
</div>
</Popover>
</div>
</div>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.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
}>()
const emit = defineEmits<{
(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'))
)
// 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 as any)?.hide?.()
emit('update:selectedWorkflowFilter', value)
}
const onSortClick = (event: Event) => {
if (sortPopoverRef.value) {
sortPopoverRef.value.toggle(event)
}
}
const selectSortMode = (value: JobSortMode) => {
;(sortPopoverRef.value as any)?.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

@@ -64,7 +64,8 @@ export const RunningWithCurrent: Story = {
state: 'running',
title: 'Generating image',
progressTotalPercent: 66,
progressCurrentPercent: 10
progressCurrentPercent: 10,
runningNodeName: 'KSampler'
}
}

View File

@@ -2,8 +2,12 @@
<div
ref="rowRef"
class="relative"
@mouseenter="onRowEnter"
@mouseleave="onRowLeave"
data-testid="queue-job-item"
:data-job-id="props.jobId"
:data-job-state="props.state"
:data-running-node="props.runningNodeName"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@contextmenu.stop.prevent="onContextMenu"
>
<Teleport to="body">
@@ -42,165 +46,99 @@
/>
</div>
</Teleport>
<div
class="relative flex items-center justify-between gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<div class="flex items-center gap-1">
<div
v-if="
props.state === 'running' &&
(props.progressTotalPercent !== undefined ||
props.progressCurrentPercent !== undefined)
"
class="absolute inset-0"
class="relative flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover"
>
<div
v-if="props.progressTotalPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${props.progressTotalPercent}%` }"
/>
<div
v-if="props.progressCurrentPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${props.progressCurrentPercent}%` }"
/>
</div>
<div class="relative z-[1] flex items-center gap-1">
<div class="relative inline-flex items-center justify-center">
v-if="
props.state === 'running' &&
(props.progressTotalPercent !== undefined ||
props.progressCurrentPercent !== undefined)
"
class="absolute inset-0"
>
<div
class="absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2"
@mouseenter.stop="onIconEnter"
@mouseleave.stop="onIconLeave"
v-if="props.progressTotalPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${props.progressTotalPercent}%` }"
/>
<div
class="inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded-[6px]"
>
<img
v-if="iconImageUrl"
:src="iconImageUrl"
class="h-full w-full object-cover"
/>
<i
v-else
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
v-if="props.progressCurrentPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${props.progressCurrentPercent}%` }"
/>
</div>
<div class="relative z-[1] flex items-center gap-1">
<div class="relative inline-flex items-center justify-center">
<div
class="absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2"
@mouseenter.stop="onIconEnter"
@mouseleave.stop="onIconLeave"
/>
<div
class="inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded-[6px]"
>
<img
v-if="iconImageUrl"
:src="iconImageUrl"
class="h-full w-full object-cover"
/>
<i
v-else
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
/>
</div>
</div>
</div>
<div class="relative z-[1] min-w-0 flex-1">
<div class="truncate opacity-90" :title="props.title">
<slot name="primary">{{ props.title }}</slot>
</div>
</div>
<div class="relative z-[1] shrink-0 pr-2 text-text-secondary">
<slot name="secondary">{{ props.rightText }}</slot>
</div>
</div>
<div class="relative z-[1] min-w-0 flex-1">
<div class="truncate opacity-90" :title="props.title">
<slot name="primary">{{ props.title }}</slot>
</div>
</div>
<!--
TODO: Refactor action buttons to use a declarative config system.
Instead of hardcoding button visibility logic in the template, define an array of
action button configs with properties like:
- icon, label, action, tooltip
- visibleStates: JobState[] (which job states show this button)
- alwaysVisible: boolean (show without hover)
- destructive: boolean (use destructive styling)
Then render buttons in two groups:
1. Always-visible buttons (outside Transition)
2. Hover-only buttons (inside Transition)
This would eliminate the current duplication where the cancel button exists
both outside (for running) and inside (for pending) the Transition.
-->
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
<Transition
mode="out-in"
enter-active-class="transition-opacity transition-transform duration-150 ease-out"
leave-active-class="transition-opacity transition-transform duration-150 ease-in"
enter-from-class="opacity-0 translate-y-0.5"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-0.5"
>
<div
v-if="isHovered"
key="actions"
class="inline-flex items-center gap-2 pr-1"
<div
v-if="visibleActions.length"
class="relative z-[1] flex items-center gap-1 text-text-secondary"
>
<template v-for="action in visibleActions" :key="action.key">
<IconButton
v-if="action.type === 'icon'"
v-tooltip.top="action.tooltip"
:type="action.buttonType"
size="sm"
:class="actionButtonClass"
:aria-label="action.ariaLabel"
:data-testid="`job-action-${action.key}`"
@click.stop="action.onClick?.($event)"
>
<IconButton
v-if="props.state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig"
type="transparent"
size="sm"
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
:aria-label="t('g.delete')"
@click.stop="onDeleteClick"
>
<i class="icon-[lucide--trash-2] size-4" />
</IconButton>
<IconButton
v-else-if="
props.state !== 'completed' &&
props.state !== 'running' &&
computedShowClear
"
v-tooltip.top="cancelTooltipConfig"
type="transparent"
size="sm"
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<TextButton
v-else-if="props.state === 'completed'"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
type="transparent"
:label="t('menuLabels.View')"
:aria-label="t('menuLabels.View')"
@click.stop="emit('view')"
/>
<IconButton
v-if="props.showMenu !== undefined ? props.showMenu : true"
v-tooltip.top="moreTooltipConfig"
type="transparent"
size="sm"
class="size-6 transform gap-1 rounded bg-modal-card-button-surface text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</IconButton>
</div>
<div
v-else-if="props.state !== 'running'"
key="secondary"
class="pr-2"
>
<slot name="secondary">{{ props.rightText }}</slot>
</div>
</Transition>
<!-- Running job cancel button - always visible -->
<IconButton
v-if="props.state === 'running' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
type="transparent"
size="sm"
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<i :class="cn(action.iconClass, 'size-4')" />
</IconButton>
<TextButton
v-else
class="h-8 gap-1 rounded-lg bg-modal-card-button-surface px-3 py-0 text-text-primary transition duration-150 ease-in-out hover:opacity-95"
type="transparent"
:label="action.label"
:aria-label="action.ariaLabel"
:data-testid="`job-action-${action.key}`"
@click.stop="action.onClick?.($event)"
/>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
@@ -225,6 +163,7 @@ const props = withDefaults(
showMenu?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
runningNodeName?: string
activeDetailsId?: string | null
}>(),
{
@@ -255,6 +194,9 @@ const { t } = useI18n()
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const viewTooltipConfig = computed(() =>
buildTooltipConfig(t('menuLabels.View'))
)
const rowRef = ref<HTMLDivElement | null>(null)
const showDetails = computed(() => props.activeDetailsId === props.jobId)
@@ -306,6 +248,11 @@ const onIconLeave = () => scheduleHidePreview()
const onPreviewEnter = () => scheduleShowPreview()
const onPreviewLeave = () => scheduleHidePreview()
onBeforeUnmount(() => {
clearPreviewHideTimer()
clearPreviewShowTimer()
})
const popoverPosition = ref<{ top: number; right: number } | null>(null)
const updatePopoverPosition = () => {
@@ -323,6 +270,32 @@ const isAnyPopoverVisible = computed(
() => showDetails.value || (isPreviewVisible.value && canShowPreview.value)
)
type ActionVariant = 'neutral' | 'destructive'
type ActionMode = 'hover' | 'always'
type BaseActionConfig = {
key: string
variant: ActionVariant
mode: ActionMode
ariaLabel: string
tooltip?: ReturnType<typeof buildTooltipConfig>
isVisible: () => boolean
onClick?: (event?: MouseEvent) => void
}
type IconActionConfig = BaseActionConfig & {
type: 'icon'
iconClass: string
buttonType: 'secondary' | 'destructive'
}
type TextActionConfig = BaseActionConfig & {
type: 'text'
label: string
}
type ActionConfig = IconActionConfig | TextActionConfig
watch(
isAnyPopoverVisible,
(visible) => {
@@ -337,6 +310,114 @@ watch(
const isHovered = ref(false)
const computedShowClear = computed(() => {
if (props.showClear !== undefined) return props.showClear
return props.state !== 'completed'
})
const resolvedShowMenu = computed(() => props.showMenu ?? true)
const baseActions = computed<ActionConfig[]>(() => {
return [
{
key: 'menu',
type: 'icon',
variant: 'neutral',
buttonType: 'secondary',
mode: 'hover',
iconClass: 'icon-[lucide--more-horizontal]',
ariaLabel: t('g.more'),
tooltip: moreTooltipConfig.value,
isVisible: () => resolvedShowMenu.value,
onClick: (event?: MouseEvent) => {
if (event) emit('menu', event)
}
},
{
key: 'delete',
type: 'icon',
variant: 'destructive',
buttonType: 'destructive',
mode: 'hover',
iconClass: 'icon-[lucide--trash-2]',
ariaLabel: t('g.delete'),
tooltip: deleteTooltipConfig.value,
isVisible: () => props.state === 'failed' && computedShowClear.value,
onClick: () => {
onRowLeave()
emit('delete')
}
},
{
key: 'cancel-hover',
type: 'icon',
variant: 'destructive',
buttonType: 'destructive',
mode: 'hover',
iconClass: 'icon-[lucide--x]',
ariaLabel: t('g.cancel'),
tooltip: cancelTooltipConfig.value,
isVisible: () =>
props.state !== 'completed' &&
props.state !== 'running' &&
props.state !== 'failed' &&
computedShowClear.value,
onClick: () => {
onRowLeave()
emit('cancel')
}
},
{
key: 'view',
type: 'icon',
variant: 'neutral',
buttonType: 'secondary',
mode: 'hover',
iconClass: 'icon-[lucide--zoom-in]',
ariaLabel: t('menuLabels.View'),
tooltip: viewTooltipConfig.value,
isVisible: () => props.state === 'completed',
onClick: () => emit('view')
},
{
key: 'cancel-running',
type: 'icon',
variant: 'destructive',
buttonType: 'destructive',
mode: 'always',
iconClass: 'icon-[lucide--x]',
ariaLabel: t('g.cancel'),
tooltip: cancelTooltipConfig.value,
isVisible: () => props.state === 'running' && computedShowClear.value,
onClick: () => {
onRowLeave()
emit('cancel')
}
}
]
})
const visibleActions = computed(() =>
baseActions.value.filter(
(action) =>
action.isVisible() &&
(action.mode === 'always' || (action.mode === 'hover' && isHovered.value))
)
)
const handleMouseEnter = () => {
isHovered.value = true
onRowEnter()
}
const handleMouseLeave = () => {
isHovered.value = false
onRowLeave()
}
const actionButtonClass =
'h-8 min-w-8 gap-1 rounded-lg text-text-primary transition duration-150 ease-in-out hover:opacity-95'
const iconClass = computed(() => {
if (props.iconName) return props.iconName
return iconForJobState(props.state)
@@ -349,25 +430,7 @@ const shouldSpin = computed(
!props.iconImageUrl
)
const computedShowClear = computed(() => {
if (props.showClear !== undefined) return props.showClear
return props.state !== 'completed'
})
const emitDetailsLeave = () => emit('details-leave', props.jobId)
const onCancelClick = () => {
emitDetailsLeave()
emit('cancel')
}
const onDeleteClick = () => {
emitDetailsLeave()
emit('delete')
}
const onContextMenu = (event: MouseEvent) => {
const shouldShowMenu = props.showMenu !== undefined ? props.showMenu : true
if (shouldShowMenu) emit('menu', event)
if (resolvedShowMenu.value) emit('menu', event)
}
</script>

View File

@@ -1,190 +0,0 @@
import { describe, it, expect } from 'vitest'
import type { MenuOption } from './useMoreOptionsMenu'
import {
buildStructuredMenu,
convertContextMenuToOptions
} from './contextMenuConverter'
describe('contextMenuConverter', () => {
describe('buildStructuredMenu', () => {
it('should order core items before extension items', () => {
const options: MenuOption[] = [
{ label: 'Custom Extension Item', source: 'litegraph' },
{ label: 'Copy', source: 'vue' },
{ label: 'Rename', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Core items (Rename, Copy) should come before extension items
const renameIndex = result.findIndex((opt) => opt.label === 'Rename')
const copyIndex = result.findIndex((opt) => opt.label === 'Copy')
const extensionIndex = result.findIndex(
(opt) => opt.label === 'Custom Extension Item'
)
expect(renameIndex).toBeLessThan(extensionIndex)
expect(copyIndex).toBeLessThan(extensionIndex)
})
it('should add Extensions category label before extension items', () => {
const options: MenuOption[] = [
{ label: 'Copy', source: 'vue' },
{ label: 'My Custom Extension', source: 'litegraph' }
]
const result = buildStructuredMenu(options)
const extensionsLabel = result.find(
(opt) => opt.label === 'Extensions' && opt.type === 'category'
)
expect(extensionsLabel).toBeDefined()
expect(extensionsLabel?.disabled).toBe(true)
})
it('should place Delete at the very end', () => {
const options: MenuOption[] = [
{ label: 'Delete', action: () => {}, source: 'vue' },
{ label: 'Copy', source: 'vue' },
{ label: 'Rename', source: 'vue' }
]
const result = buildStructuredMenu(options)
const lastNonDivider = [...result]
.reverse()
.find((opt) => opt.type !== 'divider')
expect(lastNonDivider?.label).toBe('Delete')
})
it('should deduplicate items with same label, preferring vue source', () => {
const options: MenuOption[] = [
{ label: 'Copy', action: () => {}, source: 'litegraph' },
{ label: 'Copy', action: () => {}, source: 'vue' }
]
const result = buildStructuredMenu(options)
const copyItems = result.filter((opt) => opt.label === 'Copy')
expect(copyItems).toHaveLength(1)
expect(copyItems[0].source).toBe('vue')
})
it('should preserve dividers between sections', () => {
const options: MenuOption[] = [
{ label: 'Rename', source: 'vue' },
{ label: 'Copy', source: 'vue' },
{ label: 'Pin', source: 'vue' }
]
const result = buildStructuredMenu(options)
const dividers = result.filter((opt) => opt.type === 'divider')
expect(dividers.length).toBeGreaterThan(0)
})
it('should handle empty input', () => {
const result = buildStructuredMenu([])
expect(result).toEqual([])
})
it('should handle only dividers', () => {
const options: MenuOption[] = [{ type: 'divider' }, { type: 'divider' }]
const result = buildStructuredMenu(options)
// Should be empty since dividers are filtered initially
expect(result).toEqual([])
})
it('should recognize Remove as equivalent to Delete', () => {
const options: MenuOption[] = [
{ label: 'Remove', action: () => {}, source: 'vue' },
{ label: 'Copy', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Remove should be placed at the end like Delete
const lastNonDivider = [...result]
.reverse()
.find((opt) => opt.type !== 'divider')
expect(lastNonDivider?.label).toBe('Remove')
})
it('should group core items in correct section order', () => {
const options: MenuOption[] = [
{ label: 'Color', source: 'vue' },
{ label: 'Node Info', source: 'vue' },
{ label: 'Pin', source: 'vue' },
{ label: 'Rename', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Get indices of items (excluding dividers and categories)
const getIndex = (label: string) =>
result.findIndex((opt) => opt.label === label)
// Rename (section 1) should come before Pin (section 2)
expect(getIndex('Rename')).toBeLessThan(getIndex('Pin'))
// Pin (section 2) should come before Node Info (section 4)
expect(getIndex('Pin')).toBeLessThan(getIndex('Node Info'))
// Node Info (section 4) should come before or with Color (section 4)
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
})
})
describe('convertContextMenuToOptions', () => {
it('should convert empty array to empty result', () => {
const result = convertContextMenuToOptions([])
expect(result).toEqual([])
})
it('should convert null items to dividers', () => {
const result = convertContextMenuToOptions([null], undefined, false)
expect(result).toHaveLength(1)
expect(result[0].type).toBe('divider')
})
it('should skip blacklisted items like Properties', () => {
const items = [{ content: 'Properties', callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result.find((opt) => opt.label === 'Properties')).toBeUndefined()
})
it('should convert basic menu items with content', () => {
const items = [{ content: 'Test Item', callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result).toHaveLength(1)
expect(result[0].label).toBe('Test Item')
})
it('should mark items as litegraph source', () => {
const items = [{ content: 'Test Item', callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result[0].source).toBe('litegraph')
})
it('should pass through disabled state', () => {
const items = [{ content: 'Disabled Item', disabled: true }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result[0].disabled).toBe(true)
})
it('should apply structuring by default', () => {
const items = [
{ content: 'Copy', callback: () => {} },
{ content: 'Custom Extension', callback: () => {} }
]
const result = convertContextMenuToOptions(items)
// With structuring, there should be Extensions category
const hasExtensionsCategory = result.some(
(opt) => opt.label === 'Extensions' && opt.type === 'category'
)
expect(hasExtensionsCategory).toBe(true)
})
})
})

View File

@@ -1,620 +0,0 @@
import { default as DOMPurify } from 'dompurify'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
IContextMenuValue,
LGraphNode,
IContextMenuOptions,
ContextMenu
} from '@/lib/litegraph/src/litegraph'
import type { MenuOption, SubMenuOption } from './useMoreOptionsMenu'
import type { ContextMenuDivElement } from '@/lib/litegraph/src/interfaces'
/**
* Hard blacklist - items that should NEVER be included
*/
const HARD_BLACKLIST = new Set([
'Properties', // Never include Properties submenu
'Colors', // Use singular "Color" instead
'Shapes', // Use singular "Shape" instead
'Title',
'Mode',
'Properties Panel',
'Copy (Clipspace)'
])
/**
* Core menu items - items that should appear in the main menu, not under Extensions
* Includes both LiteGraph base menu items and ComfyUI built-in functionality
*/
const CORE_MENU_ITEMS = new Set([
// Basic operations
'Rename',
'Copy',
'Duplicate',
'Clone',
// Node state operations
'Run Branch',
'Pin',
'Unpin',
'Bypass',
'Remove Bypass',
'Mute',
// Structure operations
'Convert to Subgraph',
'Frame selection',
'Minimize Node',
'Expand',
'Collapse',
// Info and adjustments
'Node Info',
'Resize',
'Title',
'Properties Panel',
'Adjust Size',
// Visual
'Color',
'Colors',
'Shape',
'Shapes',
'Mode',
// Built-in node operations (node-specific)
'Open Image',
'Copy Image',
'Save Image',
'Open in Mask Editor',
'Edit Subgraph Widgets',
'Unpack Subgraph',
'Copy (Clipspace)',
'Paste (Clipspace)',
// Selection and alignment
'Align Selected To',
'Distribute Nodes',
// Deletion
'Delete',
'Remove',
// LiteGraph base items
'Show Advanced',
'Hide Advanced'
])
/**
* Normalize menu item label for duplicate detection
* Handles variations like Colors/Color, Shapes/Shape, Pin/Unpin, Remove/Delete
*/
function normalizeLabel(label: string): string {
return label
.toLowerCase()
.replace(/^un/, '') // Remove 'un' prefix (Unpin -> Pin)
.trim()
}
/**
* Check if a similar menu item already exists in the results
* Returns true if an item with the same normalized label exists
*/
function isDuplicateItem(label: string, existingItems: MenuOption[]): boolean {
const normalizedLabel = normalizeLabel(label)
// Map of equivalent items
const equivalents: Record<string, string[]> = {
color: ['color', 'colors'],
shape: ['shape', 'shapes'],
pin: ['pin', 'unpin'],
delete: ['remove', 'delete'],
duplicate: ['clone', 'duplicate']
}
return existingItems.some((item) => {
if (!item.label) return false
const existingNormalized = normalizeLabel(item.label)
// Check direct match
if (existingNormalized === normalizedLabel) return true
// Check if they're in the same equivalence group
for (const values of Object.values(equivalents)) {
if (
values.includes(normalizedLabel) &&
values.includes(existingNormalized)
) {
return true
}
}
return false
})
}
/**
* Check if a menu item is a core menu item (not an extension)
* Core items include LiteGraph base items and ComfyUI built-in functionality
*/
function isCoreMenuItem(label: string): boolean {
return CORE_MENU_ITEMS.has(label)
}
/**
* Filter out duplicate menu items based on label
* Gives precedence to Vue hardcoded options over LiteGraph options
*/
function removeDuplicateMenuOptions(options: MenuOption[]): MenuOption[] {
// Group items by label
const itemsByLabel = new Map<string, MenuOption[]>()
const itemsWithoutLabel: MenuOption[] = []
for (const opt of options) {
// Always keep dividers and category items
if (opt.type === 'divider' || opt.type === 'category') {
itemsWithoutLabel.push(opt)
continue
}
// Items without labels are kept as-is
if (!opt.label) {
itemsWithoutLabel.push(opt)
continue
}
// Group by label
if (!itemsByLabel.has(opt.label)) {
itemsByLabel.set(opt.label, [])
}
itemsByLabel.get(opt.label)!.push(opt)
}
// Select best item for each label (prefer vue over litegraph)
const result: MenuOption[] = []
const seenLabels = new Set<string>()
for (const opt of options) {
// Add non-labeled items in original order
if (opt.type === 'divider' || opt.type === 'category' || !opt.label) {
if (itemsWithoutLabel.includes(opt)) {
result.push(opt)
const idx = itemsWithoutLabel.indexOf(opt)
itemsWithoutLabel.splice(idx, 1)
}
continue
}
// Skip if we already processed this label
if (seenLabels.has(opt.label)) {
continue
}
seenLabels.add(opt.label)
// Get all items with this label
const duplicates = itemsByLabel.get(opt.label)!
// If only one item, add it
if (duplicates.length === 1) {
result.push(duplicates[0])
continue
}
// Multiple items: prefer vue source over litegraph
const vueItem = duplicates.find((item) => item.source === 'vue')
if (vueItem) {
result.push(vueItem)
} else {
// No vue item, just take the first one
result.push(duplicates[0])
}
}
return result
}
/**
* Order groups for menu items - defines the display order of sections
*/
const MENU_ORDER: string[] = [
// Section 1: Basic operations
'Rename',
'Copy',
'Duplicate',
// Section 2: Node actions
'Run Branch',
'Pin',
'Unpin',
'Bypass',
'Remove Bypass',
'Mute',
// Section 3: Structure operations
'Convert to Subgraph',
'Frame selection',
'Minimize Node',
'Expand',
'Collapse',
'Resize',
'Clone',
// Section 4: Node properties
'Node Info',
'Color',
// Section 5: Node-specific operations
'Open in Mask Editor',
'Open Image',
'Copy Image',
'Save Image',
'Copy (Clipspace)',
'Paste (Clipspace)',
// Fallback for other core items
'Convert to Group Node (Deprecated)'
]
/**
* Get the order index for a menu item (lower = earlier in menu)
*/
function getMenuItemOrder(label: string): number {
const index = MENU_ORDER.indexOf(label)
return index === -1 ? 999 : index
}
/**
* Build structured menu with core items first, then extensions under a labeled section
* Ensures Delete always appears at the bottom
*/
export function buildStructuredMenu(options: MenuOption[]): MenuOption[] {
// First, remove duplicates (giving precedence to Vue hardcoded options)
const deduplicated = removeDuplicateMenuOptions(options)
const coreItemsMap = new Map<string, MenuOption>()
const extensionItems: MenuOption[] = []
let deleteItem: MenuOption | undefined
// Separate items into core and extension categories
for (const option of deduplicated) {
// Skip dividers for now - we'll add them between sections later
if (option.type === 'divider') {
continue
}
// Skip category labels (they'll be added separately)
if (option.type === 'category') {
continue
}
// Check if this is the Delete/Remove item - save it for the end
const isDeleteItem = option.label === 'Delete' || option.label === 'Remove'
if (isDeleteItem && !option.hasSubmenu) {
deleteItem = option
continue
}
// Categorize based on label
if (option.label && isCoreMenuItem(option.label)) {
coreItemsMap.set(option.label, option)
} else {
extensionItems.push(option)
}
}
// Build ordered core items based on MENU_ORDER
const orderedCoreItems: MenuOption[] = []
const coreLabels = Array.from(coreItemsMap.keys())
coreLabels.sort((a, b) => getMenuItemOrder(a) - getMenuItemOrder(b))
// Section boundaries based on MENU_ORDER indices
// Section 1: 0-2 (Rename, Copy, Duplicate)
// Section 2: 3-8 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
// Section 3: 9-15 (Convert to Subgraph, Frame selection, Minimize Node, Expand, Collapse, Resize, Clone)
// Section 4: 16-17 (Node Info, Color)
// Section 5: 18+ (Image operations and fallback items)
const getSectionNumber = (index: number): number => {
if (index <= 2) return 1
if (index <= 8) return 2
if (index <= 15) return 3
if (index <= 17) return 4
return 5
}
let lastSection = 0
for (const label of coreLabels) {
const item = coreItemsMap.get(label)!
const itemIndex = getMenuItemOrder(label)
const currentSection = getSectionNumber(itemIndex)
// Add divider when moving to a new section
if (lastSection > 0 && currentSection !== lastSection) {
orderedCoreItems.push({ type: 'divider' })
}
orderedCoreItems.push(item)
lastSection = currentSection
}
// Build the final menu structure
const result: MenuOption[] = []
// Add ordered core items with their dividers
result.push(...orderedCoreItems)
// Add extensions section if there are extension items
if (extensionItems.length > 0) {
// Add divider before Extensions section
result.push({ type: 'divider' })
// Add non-clickable Extensions label
result.push({
label: 'Extensions',
type: 'category',
disabled: true
})
// Add extension items
result.push(...extensionItems)
}
// Add Delete at the bottom if it exists
if (deleteItem) {
result.push({ type: 'divider' })
result.push(deleteItem)
}
return result
}
/**
* Convert LiteGraph IContextMenuValue items to Vue MenuOption format
* Used to bridge LiteGraph context menus into Vue node menus
* @param items - The LiteGraph menu items to convert
* @param node - The node context (optional)
* @param applyStructuring - Whether to apply menu structuring (core/extensions separation). Defaults to true.
*/
export function convertContextMenuToOptions(
items: (IContextMenuValue | null)[],
node?: LGraphNode,
applyStructuring: boolean = true
): MenuOption[] {
const result: MenuOption[] = []
for (const item of items) {
// Null items are separators in LiteGraph
if (item === null) {
result.push({ type: 'divider' })
continue
}
// Skip items without content (shouldn't happen, but be safe)
if (!item.content) {
continue
}
// Skip hard blacklisted items
if (HARD_BLACKLIST.has(item.content)) {
continue
}
// Skip if a similar item already exists in results
if (isDuplicateItem(item.content, result)) {
continue
}
const option: MenuOption = {
label: item.content,
source: 'litegraph'
}
// Pass through disabled state
if (item.disabled) {
option.disabled = true
}
// Handle submenus
if (item.has_submenu) {
// Static submenu with pre-defined options
if (item.submenu?.options) {
option.hasSubmenu = true
option.submenu = convertSubmenuToOptions(item.submenu.options)
}
// Dynamic submenu - callback creates it on-demand
else if (item.callback && !item.disabled) {
option.hasSubmenu = true
// Intercept the callback to capture dynamic submenu items
const capturedSubmenu = captureDynamicSubmenu(item, node)
if (capturedSubmenu) {
option.submenu = capturedSubmenu
} else {
console.warn(
'[ContextMenuConverter] Failed to capture submenu for:',
item.content
)
}
}
}
// Handle callback (only if not disabled and not a submenu)
else if (item.callback && !item.disabled) {
// Wrap the callback to match the () => void signature
option.action = () => {
try {
void item.callback?.call(
item as unknown as ContextMenuDivElement,
item.value,
{},
undefined,
undefined,
item
)
} catch (error) {
console.error('Error executing context menu callback:', error)
}
}
}
result.push(option)
}
// Apply structured menu with core items and extensions section (if requested)
if (applyStructuring) {
return buildStructuredMenu(result)
}
return result
}
/**
* Capture submenu items from a dynamic submenu callback
* Intercepts ContextMenu constructor to extract items without creating HTML menu
*/
function captureDynamicSubmenu(
item: IContextMenuValue,
node?: LGraphNode
): SubMenuOption[] | undefined {
let capturedItems: readonly (IContextMenuValue | string | null)[] | undefined
let capturedOptions: IContextMenuOptions | undefined
// Store original ContextMenu constructor
const OriginalContextMenu = LiteGraph.ContextMenu
try {
// Mock ContextMenu constructor to capture submenu items and options
LiteGraph.ContextMenu = function (
items: readonly (IContextMenuValue | string | null)[],
options?: IContextMenuOptions
) {
// Capture both items and options
capturedItems = items
capturedOptions = options
// Return a minimal mock object to prevent errors
return {
close: () => {},
root: document.createElement('div')
} as unknown as ContextMenu
} as unknown as typeof ContextMenu
// Execute the callback to trigger submenu creation
try {
// Create a mock MouseEvent for the callback
const mockEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX: 0,
clientY: 0
})
// Create a mock parent menu
const mockMenu = {
close: () => {},
root: document.createElement('div')
} as unknown as ContextMenu
// Call the callback which should trigger ContextMenu constructor
// Callback signature varies, but typically: (value, options, event, menu, node)
void item.callback?.call(
item as unknown as ContextMenuDivElement,
item.value,
{},
mockEvent,
mockMenu,
node // Pass the node context for callbacks that need it
)
} catch (error) {
console.warn(
'[ContextMenuConverter] Error executing callback for:',
item.content,
error
)
}
} finally {
// Always restore original constructor
LiteGraph.ContextMenu = OriginalContextMenu
}
// Convert captured items to Vue submenu format
if (capturedItems) {
const converted = convertSubmenuToOptions(capturedItems, capturedOptions)
return converted
}
console.warn('[ContextMenuConverter] No items captured for:', item.content)
return undefined
}
/**
* Convert LiteGraph submenu items to Vue SubMenuOption format
*/
function convertSubmenuToOptions(
items: readonly (IContextMenuValue | string | null)[],
options?: IContextMenuOptions
): SubMenuOption[] {
const result: SubMenuOption[] = []
for (const item of items) {
// Skip null separators
if (item === null) {
continue
}
// Handle string items (simple labels like in Mode/Shapes menus)
if (typeof item === 'string') {
const subOption: SubMenuOption = {
label: item,
action: () => {
try {
// Call the options callback with the string value
if (options?.callback) {
void options.callback.call(
null,
item,
options,
undefined,
undefined,
options.extra
)
}
} catch (error) {
console.error('Error executing string item callback:', error)
}
}
}
result.push(subOption)
continue
}
// Handle object items
if (!item.content) {
continue
}
// Extract text content from HTML if present
const content = stripHtmlTags(item.content)
const subOption: SubMenuOption = {
label: content,
action: () => {
try {
void item.callback?.call(
item as unknown as ContextMenuDivElement,
item.value,
{},
undefined,
undefined,
item
)
} catch (error) {
console.error('Error executing submenu callback:', error)
}
}
}
// Pass through disabled state
if (item.disabled) {
subOption.disabled = true
}
result.push(subOption)
}
return result
}
/**
* Strip HTML tags from content string safely
* LiteGraph menu items often include HTML for styling
*/
function stripHtmlTags(html: string): string {
// Use DOMPurify to sanitize and strip all HTML tags
const sanitized = DOMPurify.sanitize(html, { ALLOWED_TAGS: [] })
const result = sanitized.trim()
return result || html.replace(/<[^>]*>/g, '').trim() || html
}

View File

@@ -15,13 +15,10 @@ export interface MenuOption {
icon?: string
shortcut?: string
hasSubmenu?: boolean
type?: 'divider' | 'category'
type?: 'divider'
action?: () => void
submenu?: SubMenuOption[]
badge?: BadgeVariant
disabled?: boolean
source?: 'litegraph' | 'vue'
isColorPicker?: boolean
}
export interface SubMenuOption {
@@ -29,7 +26,6 @@ export interface SubMenuOption {
icon?: string
action: () => void
color?: string
disabled?: boolean
}
export enum BadgeVariant {
@@ -177,12 +173,7 @@ export function useMoreOptionsMenu() {
}
// Section 5: Subgraph operations
options.push(
...getSubgraphOptions({
hasSubgraphs: hasSubgraphsSelected,
hasMultipleSelection: hasMultipleNodes.value
})
)
options.push(...getSubgraphOptions(hasSubgraphsSelected))
// Section 6: Multiple nodes operations
if (hasMultipleNodes.value) {

View File

@@ -1,106 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectionMenuOptions } from '@/composables/graph/useSelectionMenuOptions'
const subgraphMocks = vi.hoisted(() => ({
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn(),
addSubgraphToLibrary: vi.fn(),
createI18nMock: vi.fn(() => ({
global: {
t: vi.fn(),
te: vi.fn(),
d: vi.fn()
}
}))
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
}),
createI18n: subgraphMocks.createI18nMock
}))
vi.mock('@/composables/graph/useSelectionOperations', () => ({
useSelectionOperations: () => ({
copySelection: vi.fn(),
duplicateSelection: vi.fn(),
deleteSelection: vi.fn(),
renameSelection: vi.fn()
})
}))
vi.mock('@/composables/graph/useNodeArrangement', () => ({
useNodeArrangement: () => ({
alignOptions: [{ localizedName: 'align-left', icon: 'align-left' }],
distributeOptions: [{ localizedName: 'distribute', icon: 'distribute' }],
applyAlign: vi.fn(),
applyDistribute: vi.fn()
})
}))
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
useSubgraphOperations: () => ({
convertToSubgraph: subgraphMocks.convertToSubgraph,
unpackSubgraph: subgraphMocks.unpackSubgraph,
addSubgraphToLibrary: subgraphMocks.addSubgraphToLibrary
})
}))
vi.mock('@/composables/graph/useFrameNodes', () => ({
useFrameNodes: () => ({
frameNodes: vi.fn()
})
}))
describe('useSelectionMenuOptions - subgraph options', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns only convert option when no subgraphs are selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: false,
hasMultipleSelection: true
})
expect(options).toHaveLength(1)
expect(options[0]?.label).toBe('contextMenu.Convert to Subgraph')
expect(options[0]?.action).toBe(subgraphMocks.convertToSubgraph)
})
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: true,
hasMultipleSelection: true
})
const labels = options.map((option) => option.label)
expect(labels).toContain('contextMenu.Convert to Subgraph')
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(subgraphMocks.convertToSubgraph)
})
it('hides convert option when only a single subgraph is selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: true,
hasMultipleSelection: false
})
const labels = options.map((option) => option.label)
expect(labels).not.toContain('contextMenu.Convert to Subgraph')
expect(labels).toEqual([
'contextMenu.Add Subgraph to Library',
'contextMenu.Unpack Subgraph'
])
})
})

View File

@@ -63,29 +63,9 @@ export function useSelectionMenuOptions() {
}
]
const getSubgraphOptions = ({
hasSubgraphs,
hasMultipleSelection
}: {
hasSubgraphs: boolean
hasMultipleSelection: boolean
}): MenuOption[] => {
const convertOption: MenuOption = {
label: t('contextMenu.Convert to Subgraph'),
icon: 'icon-[lucide--shrink]',
action: convertToSubgraph,
badge: BadgeVariant.NEW
}
const options: MenuOption[] = []
const showConvertOption = !hasSubgraphs || hasMultipleSelection
if (showConvertOption) {
options.push(convertOption)
}
const getSubgraphOptions = (hasSubgraphs: boolean): MenuOption[] => {
if (hasSubgraphs) {
options.push(
return [
{
label: t('contextMenu.Add Subgraph to Library'),
icon: 'icon-[lucide--folder-plus]',
@@ -96,10 +76,17 @@ export function useSelectionMenuOptions() {
icon: 'icon-[lucide--expand]',
action: unpackSubgraph
}
)
]
} else {
return [
{
label: t('contextMenu.Convert to Subgraph'),
icon: 'icon-[lucide--shrink]',
action: convertToSubgraph,
badge: BadgeVariant.NEW
}
]
}
return options
}
const getMultipleNodesOptions = (): MenuOption[] => {

View File

@@ -0,0 +1,23 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { st } from '@/i18n'
import { useExecutionStore } from '@/stores/executionStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
export function useCurrentNodeName() {
const { t } = useI18n()
const executionStore = useExecutionStore()
const currentNodeName = computed(() => {
const node = executionStore.executingNode
if (!node) return t('g.emDash')
const title = (node.title ?? '').toString().trim()
if (title) return title
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
})
return { currentNodeName }
}

View File

@@ -1,10 +1,9 @@
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentNodeName } from '@/composables/queue/useCurrentNodeName'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
@@ -16,17 +15,9 @@ import {
isToday,
isYesterday
} from '@/utils/dateTimeUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildJobDisplay } from '@/utils/queueDisplay'
import { jobStateFromTask } from '@/utils/queueUtil'
/** Tabs for job list filtering */
export const jobTabs = ['All', 'Completed', 'Failed'] as const
export type JobTab = (typeof jobTabs)[number]
export const jobSortModes = ['mostRecent', 'totalGenerationTime'] as const
export type JobSortMode = (typeof jobSortModes)[number]
/**
* UI item in the job list. Mirrors data previously prepared inline.
*/
@@ -89,13 +80,12 @@ type TaskWithState = {
}
/**
* Builds the reactive job list, filters, and grouped view for the queue overlay.
* Builds the reactive job list and grouped view for the queue overlay.
*/
export function useJobList() {
const { t, locale } = useI18n()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const workflowStore = useWorkflowStore()
const seenPendingIds = ref<Set<string>>(new Set())
const recentlyAddedPendingIds = ref<Set<string>>(new Set())
@@ -168,6 +158,7 @@ export function useJobList() {
})
const { totalPercent, currentNodePercent } = useQueueProgress()
const { currentNodeName } = useCurrentNodeName()
const relativeTimeFormatter = computed(() => {
const localeValue = locale.value
@@ -183,21 +174,7 @@ export function useJobList() {
const isJobInitializing = (promptId: string | number | undefined) =>
executionStore.isPromptInitializing(promptId)
const currentNodeName = computed(() => {
const node = executionStore.executingNode
if (!node) return t('g.emDash')
const title = (node.title ?? '').toString().trim()
if (title) return title
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
})
const selectedJobTab = ref<JobTab>('All')
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
const selectedSortMode = ref<JobSortMode>('mostRecent')
const allTasksSorted = computed<TaskItemImpl[]>(() => {
const orderedTasks = computed<TaskItemImpl[]>(() => {
const all = [
...queueStore.pendingTasks,
...queueStore.runningTasks,
@@ -207,50 +184,14 @@ export function useJobList() {
})
const tasksWithJobState = computed<TaskWithState[]>(() =>
allTasksSorted.value.map((task) => ({
orderedTasks.value.map((task) => ({
task,
state: jobStateFromTask(task, isJobInitializing(task?.promptId))
}))
)
const hasFailedJobs = computed(() =>
tasksWithJobState.value.some(({ state }) => state === 'failed')
)
watch(
() => hasFailedJobs.value,
(hasFailed) => {
if (!hasFailed && selectedJobTab.value === 'Failed') {
selectedJobTab.value = 'All'
}
}
)
const filteredTaskEntries = computed<TaskWithState[]>(() => {
let entries = tasksWithJobState.value
if (selectedJobTab.value === 'Completed') {
entries = entries.filter(({ state }) => state === 'completed')
} else if (selectedJobTab.value === 'Failed') {
entries = entries.filter(({ state }) => state === 'failed')
}
if (selectedWorkflowFilter.value === 'current') {
const activeId = workflowStore.activeWorkflow?.activeState?.id
if (!activeId) return []
entries = entries.filter(({ task }) => {
const wid = task.workflow?.id
return !!wid && wid === activeId
})
}
return entries
})
const filteredTasks = computed<TaskItemImpl[]>(() =>
filteredTaskEntries.value.map(({ task }) => task)
)
const jobItems = computed<JobListItem[]>(() => {
return filteredTaskEntries.value.map(({ task, state }) => {
return tasksWithJobState.value.map(({ task, state }) => {
const isActive =
String(task.promptId ?? '') ===
String(executionStore.activePromptId ?? '')
@@ -304,7 +245,7 @@ export function useJobList() {
const groups: JobGroup[] = []
const index = new Map<string, number>()
const localeValue = locale.value
for (const { task, state } of filteredTaskEntries.value) {
for (const { task, state } of tasksWithJobState.value) {
let ts: number | undefined
if (state === 'completed' || state === 'failed') {
ts = task.executionEndTimestamp
@@ -330,29 +271,11 @@ export function useJobList() {
if (ji) groups[groupIdx].items.push(ji)
}
if (selectedSortMode.value === 'totalGenerationTime') {
const valueOrDefault = (value: JobListItem['executionTimeMs']) =>
typeof value === 'number' && !Number.isNaN(value) ? value : -1
const sortByExecutionTimeDesc = (a: JobListItem, b: JobListItem) =>
valueOrDefault(b.executionTimeMs) - valueOrDefault(a.executionTimeMs)
groups.forEach((group) => {
group.items.sort(sortByExecutionTimeDesc)
})
}
return groups
})
return {
// filters/state
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
hasFailedJobs,
// data sources
allTasksSorted,
filteredTasks,
orderedTasks,
jobItems,
groupedJobItems,
currentNodeName

View File

@@ -22,10 +22,7 @@ export const useContextMenuTranslation = () => {
this: LGraphCanvas,
...args: Parameters<typeof getCanvasMenuOptions>
) {
const res: (IContextMenuValue | null)[] = getCanvasMenuOptions.apply(
this,
args
)
const res: IContextMenuValue[] = getCanvasMenuOptions.apply(this, args)
// Add items from new extension API
const newApiItems = app.collectCanvasMenuItems(this)
@@ -61,16 +58,13 @@ export const useContextMenuTranslation = () => {
LGraphCanvas.prototype
)
// Install compatibility layer for getNodeMenuOptions
legacyMenuCompat.install(LGraphCanvas.prototype, 'getNodeMenuOptions')
// Wrap getNodeMenuOptions to add new API items
const nodeMenuFn = LGraphCanvas.prototype.getNodeMenuOptions
const getNodeMenuOptionsWithExtensions = function (
this: LGraphCanvas,
...args: Parameters<typeof nodeMenuFn>
) {
const res = nodeMenuFn.apply(this, args) as (IContextMenuValue | null)[]
const res = nodeMenuFn.apply(this, args)
// Add items from new extension API
const node = args[0]
@@ -79,28 +73,11 @@ export const useContextMenuTranslation = () => {
res.push(item)
}
// Add legacy monkey-patched items
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getNodeMenuOptions',
this,
...args
)
for (const item of legacyItems) {
res.push(item)
}
return res
}
LGraphCanvas.prototype.getNodeMenuOptions = getNodeMenuOptionsWithExtensions
legacyMenuCompat.registerWrapper(
'getNodeMenuOptions',
getNodeMenuOptionsWithExtensions,
nodeMenuFn,
LGraphCanvas.prototype
)
function translateMenus(
values: readonly (IContextMenuValue | string | null)[] | undefined,
options: IContextMenuOptions

View File

@@ -1,20 +0,0 @@
import { computed } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const BUILD_TIME_IS_STAGING = !__USE_PROD_CONFIG__
/**
* Returns whether the current environment is staging.
* - Cloud builds use runtime configuration (firebase_config.projectId containing '-dev')
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
*/
export const isStaging = computed(() => {
if (!isCloud) {
return BUILD_TIME_IS_STAGING
}
const projectId = remoteConfig.value.firebase_config?.projectId
return projectId?.includes('-dev') ?? BUILD_TIME_IS_STAGING
})

View File

@@ -389,13 +389,6 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
type: 'boolean',
defaultValue: false
},
{
id: 'enable-manager-legacy-ui',
name: 'Use legacy Manager UI',
tooltip: 'Uses the legacy ComfyUI-Manager UI instead of the new UI.',
type: 'boolean',
defaultValue: false
},
{
id: 'disable-all-custom-nodes',
name: 'Disable loading all custom nodes.',

View File

@@ -1,6 +1,5 @@
import { remove } from 'es-toolkit'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type {
ISlotType,
INodeInputSlot,
@@ -24,41 +23,22 @@ import type { ComfyApp } from '@/scripts/app'
const INLINE_INPUTS = false
type MatchTypeNode = LGraphNode &
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
comfyDynamic: { matchType: Record<string, Record<string, string>> }
}
type AutogrowNode = LGraphNode &
Pick<Required<LGraphNode>, 'onConnectionsChange' | 'widgets'> & {
comfyDynamic: {
autogrow: Record<
string,
{
min: number
max: number
inputSpecs: InputSpecV2[]
prefix?: string
names?: string[]
}
>
}
}
Pick<Required<LGraphNode>, 'comfyMatchType' | 'onConnectionsChange'>
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
if (input.widget?.name) return
node.widgets ??= []
const { widget } = input
if (widget && node.widgets.some((w) => w.name === widget.name)) return
node.widgets.push({
name: input.name,
y: 0,
type: 'shim',
options: {},
draw(ctx, _n, _w, y) {
ctx.save()
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
ctx.fillText(input.label ?? input.name, 20, y + 15)
ctx.restore()
},
name: input.name,
options: {},
serialize: false,
type: 'shim',
y: 0
}
})
input.alwaysVisible = true
input.widget = { name: input.name }
@@ -86,47 +66,72 @@ function dynamicComboWidget(
appArg,
widgetName
)
function isInGroup(e: { name: string }): boolean {
return e.name.startsWith(inputName + '.')
}
let currentDynamicNames: string[] = []
const updateWidgets = (value?: string) => {
if (!node.widgets) throw new Error('Not Reachable')
const newSpec = value ? options[value] : undefined
const removedInputs = remove(node.inputs, isInGroup)
remove(node.widgets, isInGroup)
if (!newSpec) return
const inputsToRemove: Record<string, INodeInputSlot> = {}
for (const name of currentDynamicNames) {
const input = node.inputs.find((input) => input.name === name)
if (input) inputsToRemove[input.name] = input
const widgetIndex = node.widgets.findIndex(
(widget) => widget.name === name
)
if (widgetIndex === -1) continue
node.widgets[widgetIndex].value = undefined
node.widgets.splice(widgetIndex, 1)
}
currentDynamicNames = []
if (!newSpec) {
for (const input of Object.values(inputsToRemove)) {
const inputIndex = node.inputs.findIndex((inp) => inp === input)
if (inputIndex === -1) continue
node.removeInput(inputIndex)
}
return
}
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
const startingInputLength = node.inputs.length
const initialInputIndex =
node.inputs.findIndex((i) => i.name === widget.name) + 1
let startingInputLength = node.inputs.length
if (insertionPoint === 0)
throw new Error("Dynamic widget doesn't exist on node")
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
newSpec.required,
newSpec.optional
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
[newSpec.required, false],
[newSpec.optional, true]
]
inputTypes.forEach((inputType, idx) => {
for (const [inputType, isOptional] of inputTypes)
for (const key in inputType ?? {}) {
const name = `${widget.name}.${key}`
const specToAdd = transformInputSpecV1ToV2(inputType![key], {
name,
isOptional: idx !== 0
isOptional
})
specToAdd.display_name = key
addNodeInput(node, specToAdd)
const newInputs = node.inputs
.slice(startingInputLength)
.filter((inp) => inp.name.startsWith(name))
for (const newInput of newInputs) {
if (INLINE_INPUTS && !newInput.widget)
ensureWidgetForInput(node, newInput)
}
currentDynamicNames.push(name)
if (INLINE_INPUTS) ensureWidgetForInput(node, node.inputs.at(-1)!)
if (
!inputsToRemove[name] ||
Array.isArray(inputType![key][0]) ||
!LiteGraph.isValidConnection(
inputsToRemove[name].type,
inputType![key][0]
)
)
continue
node.inputs.at(-1)!.link = inputsToRemove[name].link
inputsToRemove[name].link = null
}
})
for (const input of Object.values(inputsToRemove)) {
const inputIndex = node.inputs.findIndex((inp) => inp === input)
if (inputIndex === -1) continue
if (inputIndex < initialInputIndex) startingInputLength--
node.removeInput(inputIndex)
}
const inputInsertionPoint =
node.inputs.findIndex((i) => i.name === widget.name) + 1
const addedWidgets = node.widgets.splice(startingLength)
@@ -152,28 +157,6 @@ function dynamicComboWidget(
)
//assume existing inputs are in correct order
spliceInputs(node, inputInsertionPoint, 0, ...addedInputs)
for (const input of removedInputs) {
const inputIndex = node.inputs.findIndex((inp) => inp.name === input.name)
if (inputIndex === -1) {
node.inputs.push(input)
node.removeInput(node.inputs.length - 1)
} else {
node.inputs[inputIndex].link = input.link
if (!input.link) continue
const link = node.graph?.links?.[input.link]
if (!link) continue
link.target_slot = inputIndex
node.onConnectionsChange?.(
LiteGraph.INPUT,
inputIndex,
true,
link,
node.inputs[inputIndex]
)
}
}
node.size[1] = node.computeSize([...node.size])[1]
if (!node.graph) return
node._setConcreteSlots()
@@ -260,9 +243,8 @@ function changeOutputType(
}
function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
if (node.comfyDynamic?.matchType) return
node.comfyDynamic ??= {}
node.comfyDynamic.matchType = {}
if (node.comfyMatchType) return
node.comfyMatchType = {}
const outputGroups = node.constructor.nodeData?.output_matchtypes
node.onConnectionsChange = useChainCallback(
@@ -276,9 +258,9 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
) {
const input = this.inputs[slot]
if (contype !== LiteGraph.INPUT || !this.graph || !input) return
const [matchKey, matchGroup] = Object.entries(
this.comfyDynamic.matchType
).find(([, group]) => input.name in group) ?? ['', undefined]
const [matchKey, matchGroup] = Object.entries(this.comfyMatchType).find(
([, group]) => input.name in group
) ?? ['', undefined]
if (!matchGroup) return
if (iscon && linf) {
const { output, subgraphInput } = linf.resolve(this.graph)
@@ -335,8 +317,8 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
const typedSpec = { ...inputSpec, type: allowed_types }
addNodeInput(node, typedSpec)
withComfyMatchType(node)
node.comfyDynamic.matchType[template_id] ??= {}
node.comfyDynamic.matchType[template_id][name] = allowed_types
node.comfyMatchType[template_id] ??= {}
node.comfyMatchType[template_id][name] = allowed_types
//TODO: instead apply on output add?
//ensure outputs get updated
@@ -347,215 +329,160 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
)
}
function autogrowOrdinalToName(
ordinal: number,
key: string,
groupName: string,
node: AutogrowNode
) {
const {
names,
prefix = '',
inputSpecs
} = node.comfyDynamic.autogrow[groupName]
const baseName = names
? names[ordinal]
: (inputSpecs.length == 1 ? prefix : key) + ordinal
return { name: `${groupName}.${baseName}`, display_name: baseName }
}
function addAutogrowGroup(
ordinal: number,
groupName: string,
node: AutogrowNode
) {
function applyAutogrow(node: LGraphNode, untypedInputSpec: InputSpecV2) {
const { addNodeInput } = useLitegraphService()
const { max, min, inputSpecs } = node.comfyDynamic.autogrow[groupName]
if (ordinal >= max) return
const namedSpecs = inputSpecs.map((input) => ({
...input,
isOptional: ordinal >= (min ?? 0) || input.isOptional,
...autogrowOrdinalToName(ordinal, input.name, groupName, node)
}))
const parseResult = zAutogrowOptions.safeParse(untypedInputSpec)
if (!parseResult.success) throw new Error('invalid Autogrow spec')
const inputSpec = parseResult.data
const newInputs = namedSpecs
.filter(
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
const { input, min, names, prefix, max } = inputSpec.template
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
[input.required, false],
[input.optional, true]
]
const inputsV2 = inputTypes.flatMap(([inputType, isOptional]) =>
Object.entries(inputType ?? {}).map(([name, v]) =>
transformInputSpecV1ToV2(v, { name, isOptional })
)
.map((namedSpec) => {
addNodeInput(node, namedSpec)
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget))
ensureWidgetForInput(node, input)
return input
})
const lastIndex = node.inputs.findLastIndex((inp) =>
inp.name.startsWith(groupName)
)
const insertionIndex = lastIndex === -1 ? node.inputs.length : lastIndex + 1
spliceInputs(node, insertionIndex, 0, ...newInputs)
app.canvas?.setDirty(true, true)
}
const ORDINAL_REGEX = /\d+$/
function resolveAutogrowOrdinal(
inputName: string,
groupName: string,
node: AutogrowNode
): number | undefined {
//TODO preslice groupname?
const name = inputName.slice(groupName.length + 1)
const { names } = node.comfyDynamic.autogrow[groupName]
if (names) {
const ordinal = names.findIndex((s) => s === name)
return ordinal === -1 ? undefined : ordinal
function nameToInputIndex(name: string) {
const index = node.inputs.findIndex((input) => input.name === name)
if (index === -1) throw new Error('Failed to find input')
return index
}
const match = name.match(ORDINAL_REGEX)
if (!match) return undefined
const ordinal = parseInt(match[0])
return ordinal !== ordinal ? undefined : ordinal
}
function autogrowInputConnected(index: number, node: AutogrowNode) {
const input = node.inputs[index]
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
const lastInput = node.inputs.findLast((inp) =>
inp.name.startsWith(groupName)
)
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
if (
!lastInput ||
ordinal == undefined ||
ordinal !== resolveAutogrowOrdinal(lastInput.name, groupName, node)
)
return
addAutogrowGroup(ordinal + 1, groupName, node)
}
function autogrowInputDisconnected(index: number, node: AutogrowNode) {
const input = node.inputs[index]
if (!input) return
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
const { min = 1, inputSpecs } = node.comfyDynamic.autogrow[groupName]
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
if (ordinal == undefined || ordinal + 1 < min) return
//resolve all inputs in group
const groupInputs = node.inputs.filter(
(inp) =>
inp.name.startsWith(groupName + '.') &&
inp.name.lastIndexOf('.') === groupName.length
)
const stride = inputSpecs.length
if (groupInputs.length % stride !== 0) {
console.error('Failed to group multi-input autogrow inputs')
return
function nameToInput(name: string) {
return node.inputs[nameToInputIndex(name)]
}
app.canvas?.setDirty(true, true)
//groupBy would be nice here, but may not be supported
for (let column = 0; column < stride; column++) {
for (
let bubbleOrdinal = ordinal * stride + column;
bubbleOrdinal + stride < groupInputs.length;
bubbleOrdinal += stride
) {
const curInput = groupInputs[bubbleOrdinal]
curInput.link = groupInputs[bubbleOrdinal + stride].link
if (!curInput.link) continue
const link = node.graph?.links[curInput.link]
if (!link) continue
const curIndex = node.inputs.findIndex((inp) => inp === curInput)
if (curIndex === -1) throw new Error('missing input')
link.target_slot = curIndex
//In the distance, someone shouting YAGNI
const trackedInputs: string[][] = []
function addInputGroup(insertionIndex: number) {
const ordinal = trackedInputs.length
const inputGroup = inputsV2.map((input) => ({
...input,
name: names
? names[ordinal]
: ((inputsV2.length == 1 ? prefix : input.name) ?? '') + ordinal,
isOptional: ordinal >= (min ?? 0) || input.isOptional
}))
const newInputs = inputGroup
.filter(
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
)
.map((namedSpec) => {
addNodeInput(node, namedSpec)
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
if (inputsV2.length !== 1) ensureWidgetForInput(node, input)
return input
})
spliceInputs(node, insertionIndex, 0, ...newInputs)
trackedInputs.push(inputGroup.map((inp) => inp.name))
app.canvas?.setDirty(true, true)
}
for (let i = 0; i < (min || 1); i++) addInputGroup(node.inputs.length)
function removeInputGroup(inputName: string) {
const groupIndex = trackedInputs.findIndex((ig) =>
ig.some((inpName) => inpName === inputName)
)
if (groupIndex == -1) throw new Error('Failed to find group')
const group = trackedInputs[groupIndex]
for (const nameToRemove of group) {
const inputIndex = nameToInputIndex(nameToRemove)
const input = spliceInputs(node, inputIndex, 1)[0]
if (!input.widget?.name) continue
const widget = node.widgets?.find((w) => w.name === input.widget!.name)
if (!widget) return
widget.value = undefined
node.removeWidget(widget)
}
const lastInput = groupInputs.at(column - stride)
if (!lastInput) continue
lastInput.link = null
trackedInputs.splice(groupIndex, 1)
node.size[1] = node.computeSize([...node.size])[1]
app.canvas?.setDirty(true, true)
}
const removalChecks = groupInputs.slice((min - 1) * stride)
let i
for (i = removalChecks.length - stride; i >= 0; i -= stride) {
if (removalChecks.slice(i, i + stride).some((inp) => inp.link)) break
}
const toRemove = removalChecks.slice(i + stride * 2)
remove(node.inputs, (inp) => toRemove.includes(inp))
for (const input of toRemove) {
const widgetName = input?.widget?.name
if (!widgetName) continue
remove(node.widgets, (w) => w.name === widgetName)
}
node.size[1] = node.computeSize([...node.size])[1]
}
function withComfyAutogrow(node: LGraphNode): asserts node is AutogrowNode {
if (node.comfyDynamic?.autogrow) return
node.comfyDynamic ??= {}
node.comfyDynamic.autogrow = {}
function inputConnected(index: number) {
const input = node.inputs[index]
const groupIndex = trackedInputs.findIndex((ig) =>
ig.some((inputName) => inputName === input.name)
)
if (groupIndex == -1) throw new Error('Failed to find group')
if (
groupIndex + 1 === trackedInputs.length &&
trackedInputs.length < (max ?? names?.length ?? 100)
) {
const lastInput = trackedInputs[groupIndex].at(-1)
if (!lastInput) return
const insertionIndex = nameToInputIndex(lastInput) + 1
if (insertionIndex === 0) throw new Error('Failed to find Input')
addInputGroup(insertionIndex)
}
}
function inputDisconnected(index: number) {
const input = node.inputs[index]
if (trackedInputs.length === 1) return
const groupIndex = trackedInputs.findIndex((ig) =>
ig.some((inputName) => inputName === input.name)
)
if (groupIndex == -1) throw new Error('Failed to find group')
if (
trackedInputs[groupIndex].some(
(inputName) => nameToInput(inputName).link != null
)
)
return
if (groupIndex + 1 < (min ?? 0)) return
//For each group from here to last group, bubble swap links
for (let column = 0; column < trackedInputs[0].length; column++) {
let prevInput = nameToInputIndex(trackedInputs[groupIndex][column])
for (let i = groupIndex + 1; i < trackedInputs.length; i++) {
const curInput = nameToInputIndex(trackedInputs[i][column])
const linkId = node.inputs[curInput].link
node.inputs[prevInput].link = linkId
const link = linkId && node.graph?.links?.[linkId]
if (link) link.target_slot = prevInput
prevInput = curInput
}
node.inputs[prevInput].link = null
}
if (
trackedInputs.at(-2) &&
!trackedInputs.at(-2)?.some((name) => !!nameToInput(name).link)
)
removeInputGroup(trackedInputs.at(-1)![0])
}
let pendingConnection: number | undefined
let swappingConnection = false
const originalOnConnectInput = node.onConnectInput
node.onConnectInput = function (slot: number, ...args) {
pendingConnection = slot
requestAnimationFrame(() => (pendingConnection = undefined))
return originalOnConnectInput?.apply(this, [slot, ...args]) ?? true
}
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
function (
this: AutogrowNode,
contype: ISlotType,
slot: number,
(
type: ISlotType,
index: number,
iscon: boolean,
linf: LLink | null | undefined
) {
const input = this.inputs[slot]
if (contype !== LiteGraph.INPUT || !input) return
//Return if input isn't known autogrow
const key = input.name.slice(0, input.name.lastIndexOf('.'))
const autogrowGroup = this.comfyDynamic.autogrow[key]
if (!autogrowGroup) return
if (app.configuringGraph && input.widget)
ensureWidgetForInput(node, input)
if (iscon && linf) {
) => {
if (type !== NodeSlotType.INPUT) return
const inputName = node.inputs[index].name
if (!trackedInputs.flat().some((name) => name === inputName)) return
if (iscon) {
if (swappingConnection || !linf) return
autogrowInputConnected(slot, this)
inputConnected(index)
} else {
if (pendingConnection === slot) {
if (pendingConnection === index) {
swappingConnection = true
requestAnimationFrame(() => (swappingConnection = false))
return
}
requestAnimationFrame(() => autogrowInputDisconnected(slot, this))
requestAnimationFrame(() => inputDisconnected(index))
}
}
)
}
function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) {
withComfyAutogrow(node)
const parseResult = zAutogrowOptions.safeParse(inputSpecV2)
if (!parseResult.success) throw new Error('invalid Autogrow spec')
const inputSpec = parseResult.data
const { input, min = 1, names, prefix, max = 100 } = inputSpec.template
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
input.required,
input.optional
]
const inputsV2 = inputTypes.flatMap((inputType, index) =>
Object.entries(inputType ?? {}).map(([name, v]) =>
transformInputSpecV1ToV2(v, { name, isOptional: index === 1 })
)
)
node.comfyDynamic.autogrow[inputSpecV2.name] = {
names,
min,
max: names?.length ?? max,
prefix,
inputSpecs: inputsV2
}
for (let i = 0; i < min; i++) addAutogrowGroup(i, inputSpecV2.name, node)
}

View File

@@ -710,8 +710,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
getMenuOptions?(): IContextMenuValue<string>[]
getExtraMenuOptions?(
canvas: LGraphCanvas,
options: (IContextMenuValue<string> | null)[]
): (IContextMenuValue<string> | null)[]
options: IContextMenuValue<string>[]
): IContextMenuValue<string>[]
static active_node: LGraphNode
/** called before modifying the graph */
onBeforeChange?(graph: LGraph): void
@@ -8019,8 +8019,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
getCanvasMenuOptions(): (IContextMenuValue | null)[] {
let options: (IContextMenuValue<string> | null)[]
getCanvasMenuOptions(): IContextMenuValue[] {
let options: IContextMenuValue<string>[]
if (this.getMenuOptions) {
options = this.getMenuOptions()
} else {
@@ -8564,11 +8564,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
node,
newPos: this.calculateNewPosition(node, deltaX, deltaY)
})
} else if (!(child instanceof LGraphGroup)) {
// Non-node, non-group children (reroutes, etc.)
// Skip groups here - they're already in allItems and will be
// processed in the main loop of moveChildNodesInGroupVueMode
child.move(deltaX, deltaY, true)
} else {
// Non-node children (nested groups, reroutes)
child.move(deltaX, deltaY)
}
}
}

View File

@@ -416,7 +416,7 @@ export class LGraphNode
selected?: boolean
showAdvanced?: boolean
declare comfyDynamic?: Record<string, object>
declare comfyMatchType?: Record<string, Record<string, string>>
declare comfyClass?: string
declare isVirtualNode?: boolean
applyToGraph?(extraLinks?: LLink[]): void

View File

@@ -7,9 +7,7 @@ import type { IContextMenuValue } from './interfaces'
*/
const ENABLE_LEGACY_SUPPORT = true
type ContextMenuValueProvider = (
...args: unknown[]
) => (IContextMenuValue | null)[]
type ContextMenuValueProvider = (...args: unknown[]) => IContextMenuValue[]
class LegacyMenuCompat {
private originalMethods = new Map<string, ContextMenuValueProvider>()
@@ -39,22 +37,16 @@ class LegacyMenuCompat {
* @param preWrapperFn The method that existed before the wrapper
* @param prototype The prototype to verify wrapper installation
*/
registerWrapper<K extends keyof LGraphCanvas>(
methodName: K,
wrapperFn: LGraphCanvas[K],
preWrapperFn: LGraphCanvas[K],
registerWrapper(
methodName: keyof LGraphCanvas,
wrapperFn: ContextMenuValueProvider,
preWrapperFn: ContextMenuValueProvider,
prototype?: LGraphCanvas
) {
this.wrapperMethods.set(
methodName as string,
wrapperFn as unknown as ContextMenuValueProvider
)
this.preWrapperMethods.set(
methodName as string,
preWrapperFn as unknown as ContextMenuValueProvider
)
this.wrapperMethods.set(methodName, wrapperFn)
this.preWrapperMethods.set(methodName, preWrapperFn)
const isInstalled = prototype && prototype[methodName] === wrapperFn
this.wrapperInstalled.set(methodName as string, !!isInstalled)
this.wrapperInstalled.set(methodName, !!isInstalled)
}
/**
@@ -62,17 +54,11 @@ class LegacyMenuCompat {
* @param prototype The prototype to install on
* @param methodName The method name to track
*/
install<K extends keyof LGraphCanvas>(
prototype: LGraphCanvas,
methodName: K
) {
install(prototype: LGraphCanvas, methodName: keyof LGraphCanvas) {
if (!ENABLE_LEGACY_SUPPORT) return
const originalMethod = prototype[methodName]
this.originalMethods.set(
methodName as string,
originalMethod as unknown as ContextMenuValueProvider
)
this.originalMethods.set(methodName, originalMethod)
let currentImpl = originalMethod
@@ -80,13 +66,13 @@ class LegacyMenuCompat {
get() {
return currentImpl
},
set: (newImpl: LGraphCanvas[K]) => {
const fnKey = `${methodName as string}:${newImpl.toString().slice(0, 100)}`
set: (newImpl: ContextMenuValueProvider) => {
const fnKey = `${methodName}:${newImpl.toString().slice(0, 100)}`
if (!this.hasWarned.has(fnKey) && this.currentExtension) {
this.hasWarned.add(fnKey)
console.warn(
`%c[DEPRECATED]%c Monkey-patching ${methodName as string} is deprecated. (Extension: "${this.currentExtension}")\n` +
`%c[DEPRECATED]%c Monkey-patching ${methodName} is deprecated. (Extension: "${this.currentExtension}")\n` +
`Please use the new context menu API instead.\n\n` +
`See: https://docs.comfy.org/custom-nodes/js/context-menu-migration`,
'color: orange; font-weight: bold',
@@ -99,15 +85,7 @@ class LegacyMenuCompat {
}
/**
* Extract items that were added by legacy monkey patches.
*
* Uses set-based diffing by reference to reliably detect additions regardless
* of item reordering or replacement. Items present in patchedItems but not in
* originalItems (by reference equality) are considered additions.
*
* Note: If a monkey patch removes items (patchedItems has fewer unique items
* than originalItems), a warning is logged but we still return any new items.
*
* Extract items that were added by legacy monkey patches
* @param methodName The method name that was monkey-patched
* @param context The context to call methods with
* @param args Arguments to pass to the methods
@@ -117,7 +95,7 @@ class LegacyMenuCompat {
methodName: keyof LGraphCanvas,
context: LGraphCanvas,
...args: unknown[]
): (IContextMenuValue | null)[] {
): IContextMenuValue[] {
if (!ENABLE_LEGACY_SUPPORT) return []
if (this.isExtracting) return []
@@ -128,7 +106,7 @@ class LegacyMenuCompat {
this.isExtracting = true
const originalItems = originalMethod.apply(context, args) as
| (IContextMenuValue | null)[]
| IContextMenuValue[]
| undefined
if (!originalItems) return []
@@ -149,26 +127,15 @@ class LegacyMenuCompat {
const methodToCall = shouldSkipWrapper ? preWrapperMethod : currentMethod
const patchedItems = methodToCall.apply(context, args) as
| (IContextMenuValue | null)[]
| IContextMenuValue[]
| undefined
if (!patchedItems) return []
// Use set-based diff to detect additions by reference
const originalSet = new Set<IContextMenuValue | null>(originalItems)
const addedItems = patchedItems.filter((item) => !originalSet.has(item))
// Warn if items were removed (patched has fewer original items than expected)
const retainedOriginalCount = patchedItems.filter((item) =>
originalSet.has(item)
).length
if (retainedOriginalCount < originalItems.length) {
console.warn(
`[Context Menu Compat] Monkey patch for ${methodName} removed ${originalItems.length - retainedOriginalCount} original menu item(s). ` +
`This may cause unexpected behavior.`
)
if (patchedItems.length > originalItems.length) {
return patchedItems.slice(originalItems.length) as IContextMenuValue[]
}
return addedItems
return []
} catch (e) {
console.error('[Context Menu Compat] Failed to extract legacy items:', e)
return []

View File

@@ -707,6 +707,7 @@
"title": "Queue Progress",
"total": "Total: {percent}",
"colonPercent": ": {percent}",
"inlineTotalLabel": "Total",
"currentNode": "Current node:",
"viewAllJobs": "View all jobs",
"running": "running",
@@ -716,6 +717,8 @@
"showAssets": "Show assets",
"showAssetsPanel": "Show assets panel",
"queuedSuffix": "queued",
"toggleLabel": "{count} queued",
"clearQueue": "Clear queue",
"clearQueued": "Clear queued",
"clearHistory": "Clear job queue history",
"filterJobs": "Filter jobs",
@@ -807,7 +810,6 @@
"dark": "Dark",
"light": "Light",
"manageExtensions": "Manage Extensions",
"customNodesManager": "Custom Nodes Manager",
"settings": "Settings",
"help": "Help",
"queue": "Queue Panel"
@@ -1038,6 +1040,8 @@
"generatedOn": "Generated on",
"totalGenerationTime": "Total generation time",
"computeHoursUsed": "Compute hours used",
"computeHoursValue": "{hours} hours",
"computeHoursValueLessThan": "<{hours} hours",
"failedAfter": "Failed after",
"errorMessage": "Error message",
"report": "Report",
@@ -1334,10 +1338,6 @@
"disable-metadata": {
"name": "Disable saving prompt metadata in files."
},
"enable-manager-legacy-ui": {
"name": "Use legacy Manager UI",
"tooltip": "Uses the legacy ComfyUI-Manager UI instead of the new UI."
},
"disable-all-custom-nodes": {
"name": "Disable loading all custom nodes."
},

View File

@@ -11325,31 +11325,6 @@
}
}
},
"SamplerSEEDS2": {
"display_name": "SamplerSEEDS2",
"inputs": {
"solver_type": {
"name": "solver_type"
},
"eta": {
"name": "eta",
"tooltip": "Stochastic strength"
},
"s_noise": {
"name": "s_noise",
"tooltip": "SDE noise multiplier"
},
"r": {
"name": "r",
"tooltip": "Relative step size for the intermediate stage (c2 node)"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SamplingPercentToSigma": {
"display_name": "SamplingPercentToSigma",
"inputs": {

View File

@@ -55,6 +55,7 @@
variant="gray"
:label="formattedDuration"
/>
<SquareChip v-if="fileFormat" variant="gray" :label="fileFormat" />
</div>
<!-- Media actions - show on hover or when playing -->
@@ -265,6 +266,12 @@ const formattedDuration = computed(() => {
return formatDuration(Number(duration))
})
const fileFormat = computed(() => {
if (!asset?.name) return ''
const parts = asset.name.split('.')
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : ''
})
const durationChipClasses = computed(() => {
if (fileKind.value === 'audio') {
return '-translate-y-11'
@@ -282,7 +289,7 @@ const showStaticChips = computed(
!!asset &&
!isHovered.value &&
!isVideoPlaying.value &&
formattedDuration.value
(formattedDuration.value || fileFormat.value)
)
// Show action overlay when hovered OR playing

View File

@@ -57,10 +57,7 @@ function useNodeDragIndividual() {
const selectedNodes = toValue(selectedNodeIds)
// capture the starting positions of all other selected nodes
// Only move other selected items if the dragged node is part of the selection
const isDraggedNodeInSelection = selectedNodes?.has(nodeId)
if (isDraggedNodeInSelection && selectedNodes.size > 1) {
if (selectedNodes?.has(nodeId) && selectedNodes.size > 1) {
otherSelectedNodesStartPositions = new Map()
for (const id of selectedNodes) {
@@ -76,15 +73,9 @@ function useNodeDragIndividual() {
otherSelectedNodesStartPositions = null
}
// Capture selected groups only if the dragged node is part of the selection
// This prevents groups from moving when dragging an unrelated node
if (isDraggedNodeInSelection) {
selectedGroups = toValue(selectedItems).filter(isLGraphGroup)
lastCanvasDelta = { x: 0, y: 0 }
} else {
selectedGroups = null
lastCanvasDelta = null
}
// Capture selected groups (filter from selectedItems which only contains selected items)
selectedGroups = toValue(selectedItems).filter(isLGraphGroup)
lastCanvasDelta = { x: 0, y: 0 }
mutations.setSource(LayoutSource.Vue)
}

View File

@@ -2,7 +2,12 @@ import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
export type ButtonSize = 'full-width' | 'fit-content' | 'sm' | 'md'
type ButtonType = 'primary' | 'secondary' | 'transparent' | 'accent'
type ButtonType =
| 'primary'
| 'secondary'
| 'transparent'
| 'accent'
| 'destructive'
type ButtonBorder = boolean
export interface BaseButtonProps {
@@ -33,7 +38,10 @@ export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
'bg-transparent border-none text-muted-foreground hover:bg-secondary-background-hover'
),
accent:
'bg-primary-background hover:bg-primary-background-hover border-none text-white font-bold'
'bg-primary-background hover:bg-primary-background-hover border-none text-white font-bold',
destructive: cn(
'bg-destructive-background hover:bg-destructive-background-hover border-none text-base-foreground'
)
} as const
return baseByType[type]
@@ -47,14 +55,18 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
'bg-transparent text-base-foreground hover:bg-secondary-background-hover'
),
accent:
'bg-primary-background hover:bg-primary-background-hover text-white font-bold'
'bg-primary-background hover:bg-primary-background-hover text-white font-bold',
destructive: cn(
'bg-destructive-background hover:bg-destructive-background-hover text-base-foreground'
)
} as const
const borderByType = {
primary: 'border border-solid border-base-background',
secondary: 'border border-solid border-base-foreground',
transparent: 'border border-solid border-base-foreground',
accent: 'border border-solid border-primary-background'
accent: 'border border-solid border-primary-background',
destructive: 'border border-solid border-destructive-background'
} as const
return `${baseByType[type]} ${borderByType[type]}`

View File

@@ -158,23 +158,6 @@ vi.mock('@/stores/executionStore', () => ({
}
}))
let workflowStoreMock: {
activeWorkflow: null | { activeState?: { id?: string } }
}
const ensureWorkflowStore = () => {
if (!workflowStoreMock) {
workflowStoreMock = reactive({
activeWorkflow: null as null | { activeState?: { id?: string } }
})
}
return workflowStoreMock
}
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => {
return ensureWorkflowStore()
}
}))
const createTask = (
overrides: Partial<TestTask> & { mockState?: JobState } = {}
): TestTask => ({
@@ -210,9 +193,6 @@ const resetStores = () => {
executionStore.activePromptId = null
executionStore.executingNode = null
const workflowStore = ensureWorkflowStore()
workflowStore.activeWorkflow = null
ensureProgressRefs()
totalPercent.value = 0
currentNodePercent.value = 0
@@ -332,7 +312,7 @@ describe('useJobList', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('sorts all tasks by queue index descending', async () => {
it('sorts tasks by queue index descending', async () => {
queueStoreMock.pendingTasks = [
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
]
@@ -343,75 +323,16 @@ describe('useJobList', () => {
createTask({ promptId: 'h', queueIndex: 3, mockState: 'completed' })
]
const { allTasksSorted } = initComposable()
const { orderedTasks } = initComposable()
await flush()
expect(allTasksSorted.value.map((task) => task.promptId)).toEqual([
expect(orderedTasks.value.map((task) => task.promptId)).toEqual([
'r',
'h',
'p'
])
})
it('filters by job tab and resets failed tab when failures disappear', async () => {
queueStoreMock.historyTasks = [
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' }),
createTask({ promptId: 'f', queueIndex: 2, mockState: 'failed' }),
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
]
const instance = initComposable()
await flush()
instance.selectedJobTab.value = 'Completed'
await flush()
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['c'])
instance.selectedJobTab.value = 'Failed'
await flush()
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['f'])
expect(instance.hasFailedJobs.value).toBe(true)
queueStoreMock.historyTasks = [
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' })
]
await flush()
expect(instance.hasFailedJobs.value).toBe(false)
expect(instance.selectedJobTab.value).toBe('All')
})
it('filters by active workflow when requested', async () => {
queueStoreMock.pendingTasks = [
createTask({
promptId: 'wf-1',
queueIndex: 2,
mockState: 'pending',
workflow: { id: 'workflow-1' }
}),
createTask({
promptId: 'wf-2',
queueIndex: 1,
mockState: 'pending',
workflow: { id: 'workflow-2' }
})
]
const instance = initComposable()
await flush()
instance.selectedWorkflowFilter.value = 'current'
await flush()
expect(instance.filteredTasks.value).toEqual([])
workflowStoreMock.activeWorkflow = { activeState: { id: 'workflow-1' } }
await flush()
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual([
'wf-1'
])
})
it('hydrates job items with active progress and compute hours', async () => {
queueStoreMock.runningTasks = [
createTask({
@@ -468,7 +389,7 @@ describe('useJobList', () => {
)
})
it('groups job items by date label and sorts by total generation time when requested', async () => {
it('groups job items by date label using queue order', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
queueStoreMock.historyTasks = [
@@ -501,7 +422,6 @@ describe('useJobList', () => {
]
const instance = initComposable()
instance.selectedSortMode.value = 'totalGenerationTime'
await flush()
const groups = instance.groupedJobItems.value
@@ -513,8 +433,8 @@ describe('useJobList', () => {
const todayGroup = groups[0]
expect(todayGroup.items.map((item) => item.id)).toEqual([
'today-large',
'today-small'
'today-small',
'today-large'
])
})
})

View File

@@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
describe('contextMenuCompat', () => {
@@ -99,15 +98,13 @@ describe('contextMenuCompat', () => {
})
describe('extractLegacyItems', () => {
// Cache base items to ensure reference equality for set-based diffing
const baseItem1 = { content: 'Item 1', callback: () => {} }
const baseItem2 = { content: 'Item 2', callback: () => {} }
beforeEach(() => {
// Setup a mock original method that returns cached items
// This ensures reference equality when set-based diffing compares items
// Setup a mock original method
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [baseItem1, baseItem2]
return [
{ content: 'Item 1', callback: () => {} },
{ content: 'Item 2', callback: () => {} }
]
}
// Install compatibility layer
@@ -117,13 +114,12 @@ describe('contextMenuCompat', () => {
it('should extract items added by monkey patches', () => {
// Monkey-patch to add items
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Custom Item 1', callback: () => {} })
items.push({ content: 'Custom Item 2', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'Custom Item 1', callback: () => {} })
items.push({ content: 'Custom Item 2', callback: () => {} })
return items
}
// Extract legacy items
const legacyItems = legacyMenuCompat.extractLegacyItems(
@@ -146,11 +142,8 @@ describe('contextMenuCompat', () => {
expect(legacyItems).toHaveLength(0)
})
it('should detect replaced items as additions and warn about removed items', () => {
const warnSpy = vi.spyOn(console, 'warn')
// Monkey-patch that replaces items with different ones (same count)
// With set-based diffing, these are detected as new items since they're different references
it('should return empty array when patched method returns same count', () => {
// Monkey-patch that replaces items but keeps same count
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [
{ content: 'Replaced 1', callback: () => {} },
@@ -163,13 +156,7 @@ describe('contextMenuCompat', () => {
mockCanvas
)
// Set-based diffing detects the replaced items as additions
expect(legacyItems).toHaveLength(2)
expect(legacyItems[0]).toMatchObject({ content: 'Replaced 1' })
expect(legacyItems[1]).toMatchObject({ content: 'Replaced 2' })
// Should warn about removed original items
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('removed'))
expect(legacyItems).toHaveLength(0)
})
it('should handle errors gracefully', () => {
@@ -194,36 +181,29 @@ describe('contextMenuCompat', () => {
})
describe('integration', () => {
// Cache base items to ensure reference equality for set-based diffing
const integrationBaseItem = { content: 'Base Item', callback: () => {} }
const integrationBaseItem1 = { content: 'Base Item 1', callback: () => {} }
const integrationBaseItem2 = { content: 'Base Item 2', callback: () => {} }
it('should work with multiple extensions patching', () => {
// Setup base method with cached item
// Setup base method
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [integrationBaseItem]
return [{ content: 'Base Item', callback: () => {} }]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// First extension patches
const original1 = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original1.apply(this)
items.push({ content: 'Extension 1 Item', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original1 as any).apply(this, args)
items.push({ content: 'Extension 1 Item', callback: () => {} })
return items
}
// Second extension patches
const original2 = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original2.apply(this)
items.push({ content: 'Extension 2 Item', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original2 as any).apply(this, args)
items.push({ content: 'Extension 2 Item', callback: () => {} })
return items
}
// Extract legacy items
const legacyItems = legacyMenuCompat.extractLegacyItems(
@@ -238,22 +218,24 @@ describe('contextMenuCompat', () => {
})
it('should extract legacy items only once even when called multiple times', () => {
// Setup base method with cached items
// Setup base method
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [integrationBaseItem1, integrationBaseItem2]
return [
{ content: 'Base Item 1', callback: () => {} },
{ content: 'Base Item 2', callback: () => {} }
]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Simulate legacy extension monkey-patching the prototype
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Legacy Item 1', callback: () => {} })
items.push({ content: 'Legacy Item 2', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'Legacy Item 1', callback: () => {} })
items.push({ content: 'Legacy Item 2', callback: () => {} })
return items
}
// Extract legacy items multiple times (simulating repeated menu opens)
const legacyItems1 = legacyMenuCompat.extractLegacyItems(
@@ -286,19 +268,17 @@ describe('contextMenuCompat', () => {
})
it('should not extract items from registered wrapper methods', () => {
// Setup base method with cached item
// Setup base method
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [integrationBaseItem]
return [{ content: 'Base Item', callback: () => {} }]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Create a wrapper that adds new API items (simulating useContextMenuTranslation)
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
const wrapperMethod = function (
this: LGraphCanvas
): (IContextMenuValue | null)[] {
const items = originalMethod.apply(this)
const wrapperMethod = function (this: LGraphCanvas) {
const items = (originalMethod as any).apply(this, [])
// Add new API items
items.push({ content: 'New API Item 1', callback: () => {} })
items.push({ content: 'New API Item 2', callback: () => {} })
@@ -326,16 +306,16 @@ describe('contextMenuCompat', () => {
})
it('should extract legacy items even when a wrapper is registered but not active', () => {
// Setup base method with cached item
// Setup base method
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [integrationBaseItem]
return [{ content: 'Base Item', callback: () => {} }]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Register a wrapper (but don't set it as the current method)
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
const wrapperMethod = function (): (IContextMenuValue | null)[] {
const wrapperMethod = function () {
return [{ content: 'Wrapper Item', callback: () => {} }]
}
legacyMenuCompat.registerWrapper(
@@ -347,12 +327,11 @@ describe('contextMenuCompat', () => {
// Monkey-patch with a different function (legacy extension)
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Legacy Item', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'Legacy Item', callback: () => {} })
return items
}
// Extract legacy items - should return the legacy item because current method is NOT the wrapper
const legacyItems = legacyMenuCompat.extractLegacyItems(

View File

@@ -118,8 +118,8 @@ describe('Autogrow', () => {
connectInput(node, 1, graph)
connectInput(node, 2, graph)
expect(node.inputs.length).toBe(4)
expect(node.inputs[0].name).toBe('0.test0')
expect(node.inputs[2].name).toBe('0.test2')
expect(node.inputs[0].name).toBe('test0')
expect(node.inputs[2].name).toBe('test2')
})
test('Can name by list of names', () => {
const graph = new LGraph()
@@ -130,8 +130,8 @@ describe('Autogrow', () => {
connectInput(node, 1, graph)
connectInput(node, 2, graph)
expect(node.inputs.length).toBe(3)
expect(node.inputs[0].name).toBe('0.a')
expect(node.inputs[2].name).toBe('0.c')
expect(node.inputs[0].name).toBe('a')
expect(node.inputs[2].name).toBe('c')
})
test('Can add autogrow with min input count', () => {
const node = testNode()

View File

@@ -234,39 +234,6 @@ export default defineConfig({
tailwindcss(),
typegpuPlugin({}),
comfyAPIPlugin(IS_DEV),
// Inject legacy user stylesheet links for desktop/localhost only
{
name: 'inject-user-stylesheet-links',
enforce: 'post',
transformIndexHtml(html) {
if (DISTRIBUTION === 'cloud') return html
return {
html,
tags: [
{
tag: 'link',
attrs: {
rel: 'stylesheet',
type: 'text/css',
href: 'user.css'
},
injectTo: 'head-prepend'
},
{
tag: 'link',
attrs: {
rel: 'stylesheet',
type: 'text/css',
href: 'api/userdata/user.css'
},
injectTo: 'head-prepend'
}
]
}
}
},
// Twitter/Open Graph meta tags plugin (cloud distribution only)
{
name: 'inject-twitter-meta',