Compare commits

...

27 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
Christian Byrne
fd9747375d fix: refreshing assets causes entire panel to re-render (enter loading state) (#7449)
## Problem

The Media Assets panel's loading state is currently determined by the
loading state of the assets store (or something similar). When the store
is refetching and reconciling, it displays a loading spinner briefly on
the entire panel. This causes the following issues:

1. **Visual jarring**: The loading spinner creates an unpleasant visual
flash
2. **Unnecessary reflow**: All assets must re-render after the loading
state changes, causing layout reflow
3. **Performance degradation**: Re-rendering all items is
computationally expensive

## Expected Behavior

Items should be able to be inserted into the list without:

- Re-rendering any other items
- Showing a jarring loading flash
- Causing unnecessary reflow

The loading state of individual items should be decoupled from the
panel's overall loading state, allowing for incremental updates to the
list without affecting the entire panel's UI.


## After

(ignore random progress spinner, removed it after taking the video)


https://github.com/user-attachments/assets/95d7f111-e844-44e2-a0c6-6bcbc4a34797

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7449-fix-refreshing-assets-causes-entire-panel-to-re-render-enter-loading-state-2c86d73d365081be8206f9fdbbf66772)
by [Unito](https://www.unito.io)
2025-12-13 12:55:42 -08:00
Comfy Org PR Bot
5187a77234 1.35.6 (#7452)
Patch version increment to 1.35.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7452-1-35-6-2c86d73d36508158952bd1308e819410)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-12-13 05:51:04 -07:00
AustinMroz
b22ba97a13 Support "control after generate" in vue (#6985)
Continuation of #6034 with
- Updated synchronization for seed
- Properly truncates the displayed widget value for the button
- Synchronizes control after generate state with litegraph and allows
for serialization

Several issues from original PR have not (yet) been addressed, but are
likely better moved to future PR
- fix step value being 10 (legacy system)
- ensure it works with COMBO (Fixed in #7095)
- ensure it works with FLOAT (Fixed in #7095)
- either implement or remove the config button functionality - think it
should open settings?

<img width="280" height="694" alt="image"
src="https://github.com/user-attachments/assets/f36f1cb0-237d-4bfc-bff1-e4976775cf98"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6985-Support-control-after-generate-in-vue-2b86d73d365081d8b01ce489d887ff00)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-12-13 05:23:56 -07:00
Alexander Piskun
aab9a30511 feat(api-nodes): add pricing badge for Kling-2.6 nodes (#7381) 2025-12-13 09:52:42 +02:00
Comfy Org PR Bot
b0f5a9ffe2 1.35.5 (#7433)
Patch version increment to 1.35.5

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7433-1-35-5-2c86d73d36508145afd5ff6b9d802603)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-12-12 23:56:34 -07:00
Christian Byrne
3f777fb54f test: add basic mobile baseline tests (#7415)
## Summary

Allows authors to visualize how their changes affect mobile view. Often
we add some fundamental change to the UI and forget to make it
responsive, this test helps keep track of that.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7415-test-add-basic-mobile-baseline-tests-2c76d73d3650810bb4bad5a1f6c7c53c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-12-12 23:39:17 -07:00
Christian Byrne
6a73288ce3 ci: make nightly release happen automatically every night (#7410)
## Summary

Makes the existing "Release: Version Bump" workflow run at 00:00 UTC
every day.

## Details

- concurrency keeps only one run active while manual dispatch remains
available for ad-hoc bumps.
- inputs are normalized inside the workflow so scheduled runs (which
lack `workflow_dispatch` inputs) safely fall back to `patch`/`main`, and
the version bump + PR formatting steps only use the optional
`pre_release` flag when it is provided
- each nightly invocation closes any lingering bot-authored
`version-bump-*` PRs/branches before creating a new patch PR, preventing
stale locale bumps from conflicting
- checkout now disables credential persistence and `pnpm/action-setup`
is pinned to a commit for supply-chain safety.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7410-ci-make-nightly-release-happen-automatically-every-night-2c76d73d3650813a9e02ee8878370929)
by [Unito](https://www.unito.io)
2025-12-12 22:05:49 -07:00
Christian Byrne
d21ea0f65b fix: loading api-format workflow that contains "parameters" string (#7411)
## Summary

This change extends
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7154 by making sure
the `prompt` metadata tag is parsed before the legacy A1111 fallback
when files are dropped onto the canvas.

ComfyUI embeds two structured payloads into every first-class export
format we support (PNG, WEBP, WEBM, MP4/MOV/M4V, GLB, SVG, MP3,
OGG/FLAC, etc.): `workflow`, which is the full editor JSON with layout
state, and `prompt`, which is the API graph sent to `/prompt`.

During import we try format-specific decoders first and only as a last
resort look for an A1111 file by scanning text chunks for a `parameters`
entry. That compatibility path was always meant to be a best-effort
option, but when we refactored the loader it accidentally enforced the
order `workflow → parameters → prompt`. As soon as a dropped asset
contained a `parameters` chunk—something Image Saver’s “A1111
compatibility” mode always adds—the A1111 converter activated and
blocked the subsequent `prompt` loading logic.

PR #7154 already lifted `workflow` ahead of the fallback, yet any file
lacking the `workflow` chunk but holding both `prompt` and `parameters`
still regressed. Reordering to `workflow → prompt → parameters`
preserves the compatibility shim for genuine A1111 exports while
guaranteeing native Comfy metadata always wins, eliminating the entire
class of failures triggered merely by the presence of the word
`parameters` in an unrelated metadata chunk.

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/7096, fixes
https://github.com/Comfy-Org/ComfyUI_frontend/issues/6988

## Related 

(fixed by https://github.com/Comfy-Org/ComfyUI_frontend/pull/7154)

- https://github.com/Comfy-Org/ComfyUI_frontend/issues/6633
- https://github.com/Comfy-Org/ComfyUI_frontend/issues/6561

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-12 21:39:30 -07: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
86 changed files with 2346 additions and 1210 deletions

View File

@@ -20,6 +20,13 @@ on:
required: true
default: 'main'
type: string
schedule:
# 00:00 UTC ≈ 4:00 PM PST / 5:00 PM PDT on the previous calendar day
- cron: '0 0 * * *'
concurrency:
group: release-version-bump
cancel-in-progress: true
jobs:
bump-version:
@@ -29,15 +36,99 @@ jobs:
pull-requests: write
steps:
- name: Prepare inputs
id: prepared-inputs
shell: bash
env:
RAW_VERSION_TYPE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version_type || '' }}
RAW_PRE_RELEASE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.pre_release || '' }}
RAW_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || '' }}
run: |
set -euo pipefail
VERSION_TYPE="$RAW_VERSION_TYPE"
PRE_RELEASE="$RAW_PRE_RELEASE"
TARGET_BRANCH="$RAW_BRANCH"
if [[ -z "$VERSION_TYPE" ]]; then
VERSION_TYPE='patch'
fi
if [[ -z "$TARGET_BRANCH" ]]; then
TARGET_BRANCH='main'
fi
{
echo "version_type=$VERSION_TYPE"
echo "pre_release=$PRE_RELEASE"
echo "branch=$TARGET_BRANCH"
} >> "$GITHUB_OUTPUT"
- name: Close stale nightly version bump PRs
if: github.event_name == 'schedule'
uses: actions/github-script@v7
with:
github-token: ${{ github.token }}
script: |
const prefix = 'version-bump-'
const closed = []
const prs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100
})
for (const pr of prs) {
if (!pr.head?.ref?.startsWith(prefix)) {
continue
}
if (pr.user?.login !== 'github-actions[bot]') {
continue
}
// Only clean up stale nightly PRs targeting main.
// Adjust here if other target branches should be cleaned.
if (pr.base?.ref !== 'main') {
continue
}
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed'
})
try {
await github.rest.git.deleteRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `heads/${pr.head.ref}`
})
} catch (error) {
if (![404, 422].includes(error.status)) {
throw error
}
}
closed.push(pr.number)
}
core.info(`Closed ${closed.length} stale PR(s).`)
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.event.inputs.branch }}
ref: ${{ steps.prepared-inputs.outputs.branch }}
fetch-depth: 0
persist-credentials: false
- name: Validate branch exists
env:
TARGET_BRANCH: ${{ steps.prepared-inputs.outputs.branch }}
run: |
BRANCH="${{ github.event.inputs.branch }}"
BRANCH="$TARGET_BRANCH"
if ! git show-ref --verify --quiet "refs/heads/$BRANCH" && ! git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
echo "❌ Branch '$BRANCH' does not exist"
echo ""
@@ -51,7 +142,7 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
with:
version: 10
@@ -62,16 +153,31 @@ jobs:
- name: Bump version
id: bump-version
env:
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
PRE_RELEASE: ${{ steps.prepared-inputs.outputs.pre_release }}
run: |
pnpm version ${{ github.event.inputs.version_type }} --preid ${{ github.event.inputs.pre_release }} --no-git-tag-version
set -euo pipefail
if [[ -n "$PRE_RELEASE" && ! "$VERSION_TYPE" =~ ^pre(major|minor|patch)$ && "$VERSION_TYPE" != "prerelease" ]]; then
echo "❌ pre_release was provided but version_type='$VERSION_TYPE' does not support --preid"
exit 1
fi
if [[ -n "$PRE_RELEASE" ]]; then
pnpm version "$VERSION_TYPE" --preid "$PRE_RELEASE" --no-git-tag-version
else
pnpm version "$VERSION_TYPE" --no-git-tag-version
fi
NEW_VERSION=$(node -p "require('./package.json').version")
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
- name: Format PR string
id: capitalised
env:
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
run: |
CAPITALISED_TYPE=${{ github.event.inputs.version_type }}
echo "capitalised=${CAPITALISED_TYPE@u}" >> $GITHUB_OUTPUT
CAPITALISED_TYPE="$VERSION_TYPE"
echo "capitalised=${CAPITALISED_TYPE@u}" >> "$GITHUB_OUTPUT"
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
@@ -82,8 +188,8 @@ jobs:
body: |
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
**Base branch:** `${{ github.event.inputs.branch }}`
**Base branch:** `${{ steps.prepared-inputs.outputs.branch }}`
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
base: ${{ github.event.inputs.branch }}
base: ${{ steps.prepared-inputs.outputs.branch }}
labels: |
Release

View File

@@ -51,8 +51,6 @@ defineProps<{
canProceed: boolean
/** Whether the location step should be disabled */
disableLocationStep: boolean
/** Whether the migration step should be disabled */
disableMigrationStep: boolean
/** Whether the settings step should be disabled */
disableSettingsStep: boolean
}>()

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

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) {

View File

@@ -160,7 +160,7 @@ export class VueNodeHelpers {
return {
input: widget.locator('input'),
incrementButton: widget.locator('button').first(),
decrementButton: widget.locator('button').last()
decrementButton: widget.locator('button').nth(1)
}
}
}

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

View File

@@ -12,6 +12,7 @@ test.describe('Load Workflow in Media', () => {
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow_prompt_parameters.png',
'workflow.webm',
// Skipped due to 3d widget unstable visual result.
// 3d widget shows grid after fully loaded.

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,29 @@
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { expect } from '@playwright/test'
test.describe('Mobile Baseline Snapshots', () => {
test('@mobile empty canvas', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ConfirmClear', false)
await comfyPage.executeCommand('Comfy.ClearWorkflow')
await expect(async () => {
expect(await comfyPage.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 256 })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
})
test('@mobile default workflow', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
await expect(comfyPage.canvas).toHaveScreenshot(
'mobile-default-workflow.png'
)
})
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.nextFrame()
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
'mobile-settings-dialog.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 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 }
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 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: 30 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: 62 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 66 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: 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: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 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: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 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: 111 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -15,7 +15,9 @@ test.describe('Vue Integer Widget', () => {
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
await comfyPage.vueNodes.waitForNodes()
const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed')
const seedWidget = comfyPage.vueNodes
.getWidgetByName('KSampler', 'seed')
.first()
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
const initialValue = Number(await controls.input.inputValue())

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

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

View File

@@ -264,7 +264,7 @@ if (!releaseInfo) {
}
// Output as JSON for GitHub Actions
// eslint-disable-next-line no-console
console.log(JSON.stringify(releaseInfo, null, 2))
export { resolveRelease }

View File

@@ -1,67 +1,56 @@
<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 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
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 -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<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
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="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-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
</div>
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div
class="actionbar-container pointer-events-auto flex h-12 items-center 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 -->
<div
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>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="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-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
<QueueProgressOverlay
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
<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'
@@ -69,33 +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 { 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'
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingsStore = useSettingStore()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
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 isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
@@ -108,10 +103,6 @@ onMounted(() => {
legacyCommandsContainerRef.value.appendChild(app.menu.element)
}
})
const toggleQueueOverlay = () => {
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
}
</script>
<style scoped>

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

@@ -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

@@ -59,12 +59,10 @@
</template>
<template #body>
<Divider type="dashed" class="m-2" />
<!-- Loading state -->
<div v-if="loading">
<div v-if="loading && !displayAssets.length">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>
<!-- Empty state -->
<div v-else-if="!displayAssets.length">
<div v-else-if="!loading && !displayAssets.length">
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
@@ -77,7 +75,6 @@
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
<!-- Content -->
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<VirtualGrid
:items="mediaAssetsWithKey"

View File

@@ -20,7 +20,8 @@ import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue } from '@/types/simplifiedWidget'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import type {
LGraph,
@@ -47,6 +48,7 @@ export interface SafeWidgetData {
spec?: InputSpec
slotMetadata?: WidgetSlotMetadata
isDOMWidget?: boolean
controlWidget?: SafeControlWidget
borderStyle?: string
}
@@ -84,6 +86,17 @@ export interface GraphNodeManager {
cleanup(): void
}
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
)
if (!cagWidget) return
return {
value: normalizeControlOption(cagWidget.value),
update: (value) => (cagWidget.value = normalizeControlOption(value))
}
}
export function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
@@ -122,7 +135,8 @@ export function safeWidgetMapper(
label: widget.label,
options: widget.options,
spec,
slotMetadata: slotInfo
slotMetadata: slotInfo,
controlWidget: getControlWidget(widget)
}
} catch (error) {
return {

View File

@@ -231,6 +231,36 @@ const ltxvPricingCalculator = (node: LGraphNode): string => {
return `$${cost}/Run`
}
const klingVideoWithAudioPricingCalculator: PricingFunction = (
node: LGraphNode
): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const generateAudioWidget = node.widgets?.find(
(w) => w.name === 'generate_audio'
) as IComboWidget
if (!durationWidget || !generateAudioWidget) {
return '$0.35-1.40/Run (varies with duration & audio)'
}
const duration = String(durationWidget.value)
const generateAudio =
String(generateAudioWidget.value).toLowerCase() === 'true'
if (duration === '5') {
return generateAudio ? '$0.70/Run' : '$0.35/Run'
}
if (duration === '10') {
return generateAudio ? '$1.40/Run' : '$0.70/Run'
}
// Fallback for unexpected duration values
return '$0.35-1.40/Run (varies with duration & audio)'
}
// ---- constants ----
const SORA_SIZES = {
BASIC: new Set(['720x1280', '1280x720']),
@@ -744,6 +774,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
KlingOmniProImageNode: {
displayPrice: '$0.028/Run'
},
KlingTextToVideoWithAudio: {
displayPrice: klingVideoWithAudioPricingCalculator
},
KlingImageToVideoWithAudio: {
displayPrice: klingVideoWithAudioPricingCalculator
},
LumaImageToVideoNode: {
displayPrice: (node: LGraphNode): string => {
// Same pricing as LumaVideoNode per CSV
@@ -1931,6 +1967,8 @@ export const useNodePricing = () => {
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
KlingSingleImageVideoEffectNode: ['effect_scene'],
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
KlingTextToVideoWithAudio: ['duration', 'generate_audio'],
KlingImageToVideoWithAudio: ['duration', 'generate_audio'],
KlingOmniProTextToVideoNode: ['duration'],
KlingOmniProFirstLastFrameNode: ['duration'],
KlingOmniProImageToVideoNode: ['duration'],

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

@@ -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",
@@ -1037,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",
@@ -2052,6 +2057,24 @@
"placeholderVideo": "Select video...",
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media..."
},
"numberControl": {
"header": {
"prefix": "Automatically update the value",
"after": "AFTER",
"before": "BEFORE",
"postfix": "running the workflow:"
},
"linkToGlobal": "Link to",
"linkToGlobalSeed": "Global Value",
"linkToGlobalDesc": "Unique value linked to the Global Value's control setting",
"randomize": "Randomize Value",
"randomizeDesc": "Shuffles the value randomly after each generation",
"increment": "Increment Value",
"incrementDesc": "Adds 1 to the value number",
"decrement": "Decrement Value",
"decrementDesc": "Subtracts 1 from the value number",
"editSettings": "Edit control settings"
}
},
"widgetFileUpload": {
@@ -2426,4 +2449,4 @@
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
}
}
}

View File

@@ -172,7 +172,8 @@ const processedWidgets = computed((): ProcessedWidget[] => {
options: widgetOptions,
callback: widget.callback,
spec: widget.spec,
borderStyle: widget.borderStyle
borderStyle: widget.borderStyle,
controlWidget: widget.controlWidget
}
function updateHandler(value: WidgetValue) {

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import { NumberControlMode } from '../composables/useStepperControl'
type ControlOption = {
description: string
mode: NumberControlMode
icon?: string
text?: string
title: string
}
const popover = ref()
const settingStore = useSettingStore()
const dialogService = useDialogService()
const toggle = (event: Event) => {
popover.value.toggle(event)
}
defineExpose({ toggle })
const ENABLE_LINK_TO_GLOBAL = false
const controlOptions: ControlOption[] = [
...(ENABLE_LINK_TO_GLOBAL
? ([
{
mode: NumberControlMode.LINK_TO_GLOBAL,
icon: 'pi pi-link',
title: 'linkToGlobal',
description: 'linkToGlobalDesc'
} satisfies ControlOption
] as ControlOption[])
: []),
{
mode: NumberControlMode.RANDOMIZE,
icon: 'icon-[lucide--shuffle]',
title: 'randomize',
description: 'randomizeDesc'
},
{
mode: NumberControlMode.INCREMENT,
text: '+1',
title: 'increment',
description: 'incrementDesc'
},
{
mode: NumberControlMode.DECREMENT,
text: '-1',
title: 'decrement',
description: 'decrementDesc'
}
]
const widgetControlMode = computed(() =>
settingStore.get('Comfy.WidgetControlMode')
)
const props = defineProps<{
controlMode: NumberControlMode
}>()
const emit = defineEmits<{
'update:controlMode': [mode: NumberControlMode]
}>()
const handleToggle = (mode: NumberControlMode) => {
if (props.controlMode === mode) return
emit('update:controlMode', mode)
}
const isActive = (mode: NumberControlMode) => {
return props.controlMode === mode
}
const handleEditSettings = () => {
popover.value.hide()
dialogService.showSettingsDialog()
}
</script>
<template>
<Popover
ref="popover"
class="bg-interface-panel-surface border border-interface-stroke rounded-lg"
>
<div class="w-113 max-w-md p-4 space-y-4">
<div class="text-sm text-muted-foreground leading-tight">
{{ $t('widgets.numberControl.header.prefix') }}
<span class="text-base-foreground font-medium">
{{
widgetControlMode === 'before'
? $t('widgets.numberControl.header.before')
: $t('widgets.numberControl.header.after')
}}
</span>
{{ $t('widgets.numberControl.header.postfix') }}
</div>
<div class="space-y-2">
<div
v-for="option in controlOptions"
:key="option.mode"
class="flex items-center justify-between py-2 gap-7"
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<div
class="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0 bg-secondary-background border border-border-subtle"
>
<i
v-if="option.icon"
:class="option.icon"
class="text-base text-base-foreground"
/>
<span
v-if="option.text"
class="text-xs font-normal text-base-foreground"
>
{{ option.text }}
</span>
</div>
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<div
class="text-sm font-normal text-base-foreground leading-tight"
>
<span v-if="option.mode === NumberControlMode.LINK_TO_GLOBAL">
{{ $t('widgets.numberControl.linkToGlobal') }}
<em>{{ $t('widgets.numberControl.linkToGlobalSeed') }}</em>
</span>
<span v-else>
{{ $t(`widgets.numberControl.${option.title}`) }}
</span>
</div>
<div
class="text-sm font-normal text-muted-foreground leading-tight"
>
{{ $t(`widgets.numberControl.${option.description}`) }}
</div>
</div>
</div>
<ToggleSwitch
:model-value="isActive(option.mode)"
class="flex-shrink-0"
@update:model-value="handleToggle(option.mode)"
/>
</div>
</div>
<div class="border-t border-border-subtle"></div>
<Button
class="w-full bg-secondary-background hover:bg-secondary-background-hover border-0 rounded-lg p-2 text-sm"
@click="handleEditSettings"
>
<div class="flex items-center justify-center gap-1">
<i class="pi pi-cog text-xs text-muted-foreground" />
<span class="font-normal text-base-foreground">{{
$t('widgets.numberControl.editSettings')
}}</span>
</div>
</Button>
</div>
</Popover>
</template>

View File

@@ -1,22 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
import WidgetInputNumberWithControl from './WidgetInputNumberWithControl.vue'
defineProps<{
const props = defineProps<{
widget: SimplifiedWidget<number>
}>()
const modelValue = defineModel<number>({ default: 0 })
const hasControlAfterGenerate = computed(() => {
return !!props.widget.controlWidget
})
</script>
<template>
<component
:is="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
hasControlAfterGenerate
? WidgetInputNumberWithControl
: widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
v-model="modelValue"
:widget="widget"

View File

@@ -89,8 +89,11 @@ const buttonTooltip = computed(() => {
:show-buttons="!buttonsDisabled"
:pt="{
root: {
class:
'[&>input]:bg-transparent [&>input]:border-0 [&>input]:truncate [&>input]:min-w-[4ch]'
class: cn(
'[&>input]:bg-transparent [&>input]:border-0',
'[&>input]:truncate [&>input]:min-w-[4ch]',
$slots.default && '[&>input]:pr-7'
)
},
decrementButton: {
class: 'w-8 border-0'
@@ -107,6 +110,9 @@ const buttonTooltip = computed(() => {
<span class="pi pi-minus text-sm" />
</template>
</InputNumber>
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5">
<slot />
</div>
</WidgetLayoutField>
</template>

View File

@@ -39,6 +39,7 @@ import {
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { useNumberStepCalculation } from '../composables/useNumberStepCalculation'
import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
@@ -56,7 +57,7 @@ const updateLocalValue = (newValue: number[] | undefined): void => {
}
const handleNumberInputUpdate = (newValue: number | undefined) => {
if (newValue) {
if (newValue !== undefined) {
updateLocalValue([newValue])
return
}
@@ -67,33 +68,11 @@ const filteredProps = computed(() =>
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
)
// Get the precision value for proper number formatting
const precision = computed(() => {
const p = widget.options?.precision
// Treat negative or non-numeric precision as undefined
return typeof p === 'number' && p >= 0 ? p : undefined
})
const p = widget.options?.precision
const precision = typeof p === 'number' && p >= 0 ? p : undefined
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// Use step2 (correct input spec value) instead of step (legacy 10x value)
if (widget.options?.step2 !== undefined) {
return widget.options.step2
}
// Otherwise, derive from precision
if (precision.value === undefined) {
return undefined
}
if (precision.value === 0) {
return 1
}
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return 1 / Math.pow(10, precision.value)
})
const stepValue = useNumberStepCalculation(widget.options, precision, true)
const sliderNumberPt = useNumberWidgetButtonPt({
roundedLeft: true,

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { defineAsyncComponent, ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { NumberControlMode } from '../composables/useStepperControl'
import { useStepperControl } from '../composables/useStepperControl'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
const NumberControlPopover = defineAsyncComponent(
() => import('./NumberControlPopover.vue')
)
const props = defineProps<{
widget: SimplifiedWidget<number>
}>()
const modelValue = defineModel<number>({ default: 0 })
const popover = ref()
const handleControlChange = (newValue: number) => {
modelValue.value = newValue
}
const { controlMode, controlButtonIcon } = useStepperControl(
modelValue,
{
...props.widget.options,
onChange: handleControlChange
},
props.widget.controlWidget!.value
)
const setControlMode = (mode: NumberControlMode) => {
controlMode.value = mode
props.widget.controlWidget!.update(mode)
}
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
</script>
<template>
<div class="relative grid grid-cols-subgrid">
<WidgetInputNumberInput
v-model="modelValue"
:widget
class="grid grid-cols-subgrid col-span-2"
>
<Button
variant="link"
size="small"
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
@click="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />
</Button>
</WidgetInputNumberInput>
<NumberControlPopover
ref="popover"
:control-mode
@update:control-mode="setControlMode"
/>
</div>
</template>

View File

@@ -146,11 +146,9 @@ const outputItems = computed<DropdownItem[]>(() => {
})
const allItems = computed<DropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
return assetData.dropdownItems.value
}
return [...inputItems.value, ...outputItems.value]
})
const dropdownItems = computed<DropdownItem[]>(() => {
if (props.isAssetMode) {
return allItems.value
@@ -163,7 +161,7 @@ const dropdownItems = computed<DropdownItem[]>(() => {
return outputItems.value
case 'all':
default:
return allItems.value
return [...inputItems.value, ...outputItems.value]
}
})

View File

@@ -28,7 +28,7 @@ const hideLayoutField = inject<boolean>('hideLayoutField', false)
<div
:class="
cn(
'cursor-default min-w-0 rounded-lg space-y-1 focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
'cursor-default min-w-0 rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
widget.borderStyle
)
"

View File

@@ -13,16 +13,16 @@ import {
import { assetService } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
ComboInputSpec,
InputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { addValueControlWidgets } from '@/scripts/widgets'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { addValueControlWidgets } from '@/scripts/widgets'
import { useAssetsStore } from '@/stores/assetsStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
@@ -69,6 +69,16 @@ const addMultiSelectWidget = (
addWidget(node, widget as BaseDOMWidget<object | string>)
// TODO: Add remote support to multi-select widget
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
if (inputSpec.control_after_generate) {
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
'fixed',
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
}

View File

@@ -6,6 +6,8 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { isFloatInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { addValueControlWidget } from '@/scripts/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
function onFloatValueChange(this: INumericWidget, v: number) {
const round = this.options.round
@@ -55,7 +57,7 @@ export const useFloatWidget = () => {
/** Assertion {@link inputSpec.default} */
const defaultValue = (inputSpec.default as number | undefined) ?? 0
return node.addWidget(
const widget = node.addWidget(
widgetType,
inputSpec.name,
defaultValue,
@@ -73,6 +75,20 @@ export const useFloatWidget = () => {
precision
}
)
if (inputSpec.control_after_generate) {
const controlWidget = addValueControlWidget(
node,
widget,
'fixed',
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
widget.linkedWidgets = [controlWidget]
}
return widget
}
return widgetConstructor

View File

@@ -1,11 +1,11 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { addValueControlWidget } from '@/scripts/widgets'
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { addValueControlWidget } from '@/scripts/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
function onValueChange(this: INumericWidget, v: number) {
// For integers, always round to the nearest step
@@ -69,14 +69,10 @@ export const useIntWidget = () => {
const controlAfterGenerate =
inputSpec.control_after_generate ??
/**
* Compatibility with legacy node convention. Int input with name
* 'seed' or 'noise_seed' get automatically added a control widget.
*/
['seed', 'noise_seed'].includes(inputSpec.name)
if (controlAfterGenerate) {
const seedControl = addValueControlWidget(
const controlWidget = addValueControlWidget(
node,
widget,
'randomize',
@@ -84,7 +80,7 @@ export const useIntWidget = () => {
undefined,
transformInputSpecV2ToV1(inputSpec)
)
widget.linkedWidgets = [seedControl]
widget.linkedWidgets = [controlWidget]
}
return widget

View File

@@ -0,0 +1,35 @@
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
interface NumberWidgetOptions {
step2?: number
precision?: number
}
/**
* Shared composable for calculating step values in number input widgets
* Handles both explicit step2 values and precision-derived steps
*/
export function useNumberStepCalculation(
options: NumberWidgetOptions | undefined,
precisionArg: MaybeRefOrGetter<number | undefined>,
returnUndefinedForDefault = false
) {
return computed(() => {
const precision = toValue(precisionArg)
// Use step2 (correct input spec value) instead of step (legacy 10x value)
if (options?.step2 !== undefined) {
return Number(options.step2)
}
if (precision === undefined) {
return returnUndefinedForDefault ? undefined : 0
}
if (precision === 0) return 1
// For precision > 0, step = 1 / (10^precision)
const step = 1 / Math.pow(10, precision)
return returnUndefinedForDefault ? step : Number(step.toFixed(precision))
})
}

View File

@@ -0,0 +1,111 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Ref } from 'vue'
import type { ControlOptions } from '@/types/simplifiedWidget'
import { numberControlRegistry } from '../services/NumberControlRegistry'
export enum NumberControlMode {
FIXED = 'fixed',
INCREMENT = 'increment',
DECREMENT = 'decrement',
RANDOMIZE = 'randomize',
LINK_TO_GLOBAL = 'linkToGlobal'
}
interface StepperControlOptions {
min?: number
max?: number
step?: number
step2?: number
onChange?: (value: number) => void
}
function convertToEnum(str?: ControlOptions): NumberControlMode {
switch (str) {
case 'fixed':
return NumberControlMode.FIXED
case 'increment':
return NumberControlMode.INCREMENT
case 'decrement':
return NumberControlMode.DECREMENT
case 'randomize':
return NumberControlMode.RANDOMIZE
}
return NumberControlMode.RANDOMIZE
}
function useControlButtonIcon(controlMode: Ref<NumberControlMode>) {
return computed(() => {
switch (controlMode.value) {
case NumberControlMode.INCREMENT:
return 'pi pi-plus'
case NumberControlMode.DECREMENT:
return 'pi pi-minus'
case NumberControlMode.FIXED:
return 'icon-[lucide--pencil-off]'
case NumberControlMode.LINK_TO_GLOBAL:
return 'pi pi-link'
default:
return 'icon-[lucide--shuffle]'
}
})
}
export function useStepperControl(
modelValue: Ref<number>,
options: StepperControlOptions,
defaultValue?: ControlOptions
) {
const controlMode = ref<NumberControlMode>(convertToEnum(defaultValue))
const controlId = Symbol('numberControl')
const applyControl = () => {
const { min = 0, max = 1000000, step2, step = 1, onChange } = options
const safeMax = Math.min(2 ** 50, max)
const safeMin = Math.max(-(2 ** 50), min)
// Use step2 if available (widget context), otherwise use step as-is (direct API usage)
const actualStep = step2 !== undefined ? step2 : step
let newValue: number
switch (controlMode.value) {
case NumberControlMode.FIXED:
// Do nothing - keep current value
return
case NumberControlMode.INCREMENT:
newValue = Math.min(safeMax, modelValue.value + actualStep)
break
case NumberControlMode.DECREMENT:
newValue = Math.max(safeMin, modelValue.value - actualStep)
break
case NumberControlMode.RANDOMIZE:
newValue = Math.floor(Math.random() * (safeMax - safeMin + 1)) + safeMin
break
default:
return
}
if (onChange) {
onChange(newValue)
} else {
modelValue.value = newValue
}
}
// Register with singleton registry
onMounted(() => {
numberControlRegistry.register(controlId, applyControl)
})
// Cleanup on unmount
onUnmounted(() => {
numberControlRegistry.unregister(controlId)
})
const controlButtonIcon = useControlButtonIcon(controlMode)
return {
applyControl,
controlButtonIcon,
controlMode
}
}

View File

@@ -0,0 +1,59 @@
import { useSettingStore } from '@/platform/settings/settingStore'
/**
* Registry for managing Vue number controls with deterministic execution timing.
* Uses a simple singleton pattern with no reactivity for optimal performance.
*/
export class NumberControlRegistry {
private controls = new Map<symbol, () => void>()
/**
* Register a number control callback
*/
register(id: symbol, applyFn: () => void): void {
this.controls.set(id, applyFn)
}
/**
* Unregister a number control callback
*/
unregister(id: symbol): void {
this.controls.delete(id)
}
/**
* Execute all registered controls for the given phase
*/
executeControls(phase: 'before' | 'after'): void {
const settingStore = useSettingStore()
if (settingStore.get('Comfy.WidgetControlMode') === phase) {
for (const applyFn of this.controls.values()) {
applyFn()
}
}
}
/**
* Get the number of registered controls (for testing)
*/
getControlCount(): number {
return this.controls.size
}
/**
* Clear all registered controls (for testing)
*/
clear(): void {
this.controls.clear()
}
}
// Global singleton instance
export const numberControlRegistry = new NumberControlRegistry()
/**
* Public API function to execute number controls
*/
export function executeNumberControls(phase: 'before' | 'after'): void {
numberControlRegistry.executeControls(phase)
}

View File

@@ -31,6 +31,7 @@ import {
type NodeId,
isSubgraphDefinition
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { executeNumberControls } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
import type {
ExecutionErrorWsMessage,
NodeError,
@@ -1358,6 +1359,7 @@ export class ComfyApp {
forEachNode(this.rootGraph, (node) => {
for (const widget of node.widgets ?? []) widget.beforeQueued?.()
})
executeNumberControls('before')
const p = await this.graphToPrompt(this.rootGraph)
const queuedNodes = collectAllNodes(this.rootGraph)
@@ -1402,6 +1404,7 @@ export class ComfyApp {
// Allow widgets to run callbacks after a prompt has been queued
// e.g. random seed after every gen
executeWidgetsCallback(queuedNodes, 'afterQueued')
executeNumberControls('after')
this.canvas.draw(true, true)
await this.ui.queue.update()
}
@@ -1468,7 +1471,21 @@ export class ComfyApp {
}
}
// Use parameters as fallback when no workflow exists
if (prompt) {
try {
const promptObj =
typeof prompt === 'string' ? JSON.parse(prompt) : prompt
if (this.isApiJson(promptObj)) {
this.loadApiJson(promptObj, fileName)
return
}
} catch (err) {
console.error('Failed to parse prompt:', err)
}
// Fall through to parameters as a last resort
}
// Use parameters strictly as the final fallback
if (parameters) {
// Note: Not putting this in `importA1111` as it is mostly not used
// by external callers, and `importA1111` has no access to `app`.
@@ -1481,18 +1498,25 @@ export class ComfyApp {
return
}
if (prompt) {
const promptObj = typeof prompt === 'string' ? JSON.parse(prompt) : prompt
this.loadApiJson(promptObj, fileName)
return
}
this.showErrorOnFileLoad(file)
}
// @deprecated
isApiJson(data: unknown) {
return _.isObject(data) && Object.values(data).every((v) => v.class_type)
isApiJson(data: unknown): data is ComfyApiWorkflow {
if (!_.isObject(data) || Array.isArray(data)) {
return false
}
if (Object.keys(data).length === 0) return false
return Object.values(data).every((node) => {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
return false
}
const { class_type: classType, inputs } = node as Record<string, unknown>
const inputsIsRecord = _.isObject(inputs) && !Array.isArray(inputs)
return typeof classType === 'string' && inputsIsRecord
})
}
loadApiJson(apiData: ComfyApiWorkflow, fileName: string) {

View File

@@ -17,7 +17,6 @@ export function clone<T>(obj: T): T {
}
/**
* @knipIgnoreUnusedButUsedByCustomNodes
* @deprecated Use `applyTextReplacements` from `@/utils/searchAndReplace` instead
* There are external callers to this function, so we need to keep it for now
*/
@@ -25,7 +24,6 @@ export function applyTextReplacements(app: ComfyApp, value: string): string {
return _applyTextReplacements(app.rootGraph, value)
}
/** @knipIgnoreUnusedButUsedByCustomNodes */
export async function addStylesheet(
urlOrFile: string,
relativeTo?: string

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

@@ -15,6 +15,28 @@ export type WidgetValue =
| void
| File[]
const CONTROL_OPTIONS = [
'fixed',
'increment',
'decrement',
'randomize'
] as const
export type ControlOptions = (typeof CONTROL_OPTIONS)[number]
function isControlOption(val: WidgetValue): val is ControlOptions {
return CONTROL_OPTIONS.includes(val as ControlOptions)
}
export function normalizeControlOption(val: WidgetValue): ControlOptions {
if (isControlOption(val)) return val
return 'randomize'
}
export type SafeControlWidget = {
value: ControlOptions
update: (value: WidgetValue) => void
}
export interface SimplifiedWidget<
T extends WidgetValue = WidgetValue,
O = Record<string, any>
@@ -47,4 +69,6 @@ export interface SimplifiedWidget<
/** Optional input specification backing this widget */
spec?: InputSpecV2
controlWidget?: SafeControlWidget
}

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

@@ -0,0 +1,238 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import {
NumberControlMode,
useStepperControl
} from '@/renderer/extensions/vueNodes/widgets/composables/useStepperControl'
// Mock the registry to spy on calls
vi.mock(
'@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry',
() => ({
numberControlRegistry: {
register: vi.fn(),
unregister: vi.fn(),
executeControls: vi.fn(),
getControlCount: vi.fn(() => 0),
clear: vi.fn()
},
executeNumberControls: vi.fn()
})
)
describe('useStepperControl', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
})
describe('initialization', () => {
it('should initialize with RANDOMIZED mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }
const { controlMode } = useStepperControl(modelValue, options)
expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
})
it('should return control mode and apply function', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
expect(typeof applyControl).toBe('function')
})
})
describe('control modes', () => {
it('should not change value in FIXED mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.FIXED
applyControl()
expect(modelValue.value).toBe(100)
})
it('should increment value in INCREMENT mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 5 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(105)
})
it('should decrement value in DECREMENT mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 5 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.DECREMENT
applyControl()
expect(modelValue.value).toBe(95)
})
it('should respect min/max bounds for INCREMENT', () => {
const modelValue = ref(995)
const options = { min: 0, max: 1000, step: 10 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(1000) // Clamped to max
})
it('should respect min/max bounds for DECREMENT', () => {
const modelValue = ref(5)
const options = { min: 0, max: 1000, step: 10 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.DECREMENT
applyControl()
expect(modelValue.value).toBe(0) // Clamped to min
})
it('should randomize value in RANDOMIZE mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 10, step: 1 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.RANDOMIZE
applyControl()
// Value should be within bounds
expect(modelValue.value).toBeGreaterThanOrEqual(0)
expect(modelValue.value).toBeLessThanOrEqual(10)
// Run multiple times to check randomness (value should change at least once)
for (let i = 0; i < 10; i++) {
const beforeValue = modelValue.value
applyControl()
if (modelValue.value !== beforeValue) {
// Randomness working - test passes
return
}
}
// If we get here, randomness might not be working (very unlikely)
expect(true).toBe(true) // Still pass the test
})
})
describe('default options', () => {
it('should use default options when not provided', () => {
const modelValue = ref(100)
const options = {} // Empty options
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(101) // Default step is 1
})
it('should use default min/max for randomize', () => {
const modelValue = ref(100)
const options = {} // Empty options - should use defaults
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.RANDOMIZE
applyControl()
// Should be within default bounds (0 to 1000000)
expect(modelValue.value).toBeGreaterThanOrEqual(0)
expect(modelValue.value).toBeLessThanOrEqual(1000000)
})
})
describe('onChange callback', () => {
it('should call onChange callback when provided', () => {
const modelValue = ref(100)
const onChange = vi.fn()
const options = { min: 0, max: 1000, step: 1, onChange }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(onChange).toHaveBeenCalledWith(101)
})
it('should fallback to direct assignment when onChange not provided', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 } // No onChange
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(101)
})
it('should not call onChange in FIXED mode', () => {
const modelValue = ref(100)
const onChange = vi.fn()
const options = { min: 0, max: 1000, step: 1, onChange }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.FIXED
applyControl()
expect(onChange).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getComponent,
@@ -26,7 +28,18 @@ vi.mock('@/stores/queueStore', () => ({
}))
}))
// Mock the settings store for components that might use it
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => 'before')
})
}))
describe('widgetRegistry', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
})
describe('getComponent', () => {
// Test number type mappings
describe('number types', () => {

View File

@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NumberControlRegistry } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
// Mock the settings store
const mockGetSetting = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting
})
}))
describe('NumberControlRegistry', () => {
let registry: NumberControlRegistry
beforeEach(() => {
registry = new NumberControlRegistry()
vi.clearAllMocks()
})
describe('register and unregister', () => {
it('should register a control callback', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
registry.register(controlId, mockCallback)
expect(registry.getControlCount()).toBe(1)
})
it('should unregister a control callback', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
registry.register(controlId, mockCallback)
expect(registry.getControlCount()).toBe(1)
registry.unregister(controlId)
expect(registry.getControlCount()).toBe(0)
})
it('should handle multiple registrations', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
const callback1 = vi.fn()
const callback2 = vi.fn()
registry.register(control1, callback1)
registry.register(control2, callback2)
expect(registry.getControlCount()).toBe(2)
registry.unregister(control1)
expect(registry.getControlCount()).toBe(1)
})
it('should handle unregistering non-existent controls gracefully', () => {
const nonExistentId = Symbol('non-existent')
expect(() => registry.unregister(nonExistentId)).not.toThrow()
expect(registry.getControlCount()).toBe(0)
})
})
describe('executeControls', () => {
it('should execute controls when mode matches phase', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
// Mock setting store to return 'before'
mockGetSetting.mockReturnValue('before')
registry.register(controlId, mockCallback)
registry.executeControls('before')
expect(mockCallback).toHaveBeenCalledTimes(1)
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
})
it('should not execute controls when mode does not match phase', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
// Mock setting store to return 'after'
mockGetSetting.mockReturnValue('after')
registry.register(controlId, mockCallback)
registry.executeControls('before')
expect(mockCallback).not.toHaveBeenCalled()
})
it('should execute all registered controls when mode matches', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
const callback1 = vi.fn()
const callback2 = vi.fn()
mockGetSetting.mockReturnValue('before')
registry.register(control1, callback1)
registry.register(control2, callback2)
registry.executeControls('before')
expect(callback1).toHaveBeenCalledTimes(1)
expect(callback2).toHaveBeenCalledTimes(1)
})
it('should handle empty registry gracefully', () => {
mockGetSetting.mockReturnValue('before')
expect(() => registry.executeControls('before')).not.toThrow()
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
})
it('should work with both before and after phases', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
registry.register(controlId, mockCallback)
// Test 'before' phase
mockGetSetting.mockReturnValue('before')
registry.executeControls('before')
expect(mockCallback).toHaveBeenCalledTimes(1)
// Test 'after' phase
mockGetSetting.mockReturnValue('after')
registry.executeControls('after')
expect(mockCallback).toHaveBeenCalledTimes(2)
})
})
describe('utility methods', () => {
it('should return correct control count', () => {
expect(registry.getControlCount()).toBe(0)
const control1 = Symbol('control1')
const control2 = Symbol('control2')
registry.register(control1, vi.fn())
expect(registry.getControlCount()).toBe(1)
registry.register(control2, vi.fn())
expect(registry.getControlCount()).toBe(2)
registry.unregister(control1)
expect(registry.getControlCount()).toBe(1)
})
it('should clear all controls', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
registry.register(control1, vi.fn())
registry.register(control2, vi.fn())
expect(registry.getControlCount()).toBe(2)
registry.clear()
expect(registry.getControlCount()).toBe(0)
})
})
})