Compare commits

..

1 Commits

Author SHA1 Message Date
Claude
d930514bea fix: incorporate Fuse search scores into template sorting
When searching templates, the Fuse.js relevance scores were being
discarded when any sort option other than 'default' was selected.
This caused templates with better search matches to be ranked lower
than templates with higher usage/popularity but worse search relevance.

Changes:
- Store Fuse search scores in a Map for use during sorting
- For 'recommended' sort with active search: weight search relevance
  at 60% and base recommendation score at 40%
- For 'popular' sort with active search: weight both equally at 50%
- For VRAM/size sorts: use search relevance as tiebreaker
- 'default' sort preserves Fuse's original relevance order
2026-01-08 20:01:41 +00:00
450 changed files with 9819 additions and 91418 deletions

View File

@@ -1,9 +1,9 @@
# Description: Deploys test results from forked PRs (forks can't access deployment secrets)
name: 'CI: Tests E2E (Deploy for Forks)'
name: "CI: Tests E2E (Deploy for Forks)"
on:
workflow_run:
workflows: ['CI: Tests E2E']
workflows: ["CI: Tests E2E"]
types: [requested, completed]
env:
@@ -81,7 +81,6 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
# Rename merged report if exists
[ -d "reports/playwright-report-chromium-merged" ] && \

View File

@@ -1,19 +1,15 @@
# Description: End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
name: 'CI: Tests E2E'
name: "CI: Tests E2E"
on:
push:
branches: [main, master, core/*, desktop/*]
pull_request:
branches-ignore:
[wip/*, draft/*, temp/*, vue-nodes-migration, version-bump-*]
# Run after i18n workflow completes for version-bump PRs
workflow_run:
workflows: ['i18n: Update Core']
types: [completed]
[wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }}
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
@@ -22,8 +18,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.event.workflow_run.head_branch || github.ref }}
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
@@ -43,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -58,8 +52,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.event.workflow_run.head_branch || github.ref }}
- name: Download built frontend
uses: actions/download-artifact@v4
with:
@@ -93,7 +85,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -107,8 +99,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.event.workflow_run.head_branch || github.ref }}
- name: Download built frontend
uses: actions/download-artifact@v4
with:
@@ -153,13 +143,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.event.workflow_run.head_branch || github.ref }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
# Setup pnpm/node to run playwright merge-reports (no browsers needed)
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download blob reports
uses: actions/download-artifact@v4
@@ -171,10 +158,10 @@ jobs:
- name: Merge into HTML Report
run: |
# Generate HTML report
pnpm dlx @playwright/test merge-reports --reporter=html ./all-blob-reports
pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
# Generate JSON report separately with explicit output path
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm dlx @playwright/test merge-reports --reporter=json ./all-blob-reports
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4
@@ -187,43 +174,15 @@ jobs:
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Get PR info once for reuse by comment jobs
get-pr-info:
runs-on: ubuntu-latest
if: |
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) ||
(github.event_name == 'workflow_run')
outputs:
pr_number: ${{ steps.get-pr.outputs.number }}
branch: ${{ steps.get-pr.outputs.branch }}
steps:
- name: Get PR number
id: get-pr
env:
GH_TOKEN: ${{ github.token }}
PR_TARGET_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_BRANCH: ${{ github.head_ref || github.event.workflow_run.head_branch }}
run: |
echo "branch=${PR_BRANCH}" >> $GITHUB_OUTPUT
if [ -n "$PR_NUMBER" ]; then
echo "number=${PR_NUMBER}" >> $GITHUB_OUTPUT
else
gh pr view --repo "${PR_TARGET_REPO}" "${PR_BRANCH}" \
--json 'number' --jq '"number=\(.number)"' >> $GITHUB_OUTPUT
fi
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: get-pr-info
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.event.workflow_run.head_branch || github.ref }}
- name: Get start time
id: start-time
@@ -232,30 +191,25 @@ jobs:
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ needs.get-pr-info.outputs.pr_number }}
BRANCH: ${{ needs.get-pr-info.outputs.branch }}
START_TIME: ${{ steps.start-time.outputs.time }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"$PR_NUMBER" \
"$BRANCH" \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting" \
"$START_TIME"
"${{ steps.start-time.outputs.time }}"
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [playwright-tests, merge-reports, get-pr-info]
needs: [playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: always()
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.event.workflow_run.head_branch || github.ref }}
- name: Download all playwright reports
uses: actions/download-artifact@v4
@@ -268,12 +222,9 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SHA: ${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha }}
PR_NUMBER: ${{ needs.get-pr-info.outputs.pr_number }}
BRANCH: ${{ needs.get-pr-info.outputs.branch }}
run: |
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"$PR_NUMBER" \
"$BRANCH" \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"completed"
#### END Deployment and commenting (non-forked PRs only)

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.10
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.8
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -69,7 +69,7 @@ jobs:
- name: Checkout ComfyUI (sparse)
uses: actions/checkout@v5
with:
repository: Comfy-Org/ComfyUI
repository: comfyanonymous/ComfyUI
sparse-checkout: |
requirements.txt
path: comfyui
@@ -184,7 +184,7 @@ jobs:
# Note: This only affects the local checkout, NOT the fork's master branch
# We only push the automation branch, leaving the fork's master untouched
echo "Fetching upstream master..."
if ! git fetch https://github.com/Comfy-Org/ComfyUI.git master; then
if ! git fetch https://github.com/comfyanonymous/ComfyUI.git master; then
echo "Failed to fetch upstream master"
exit 1
fi
@@ -257,7 +257,7 @@ jobs:
# Extract fork owner from repository name
FORK_OWNER=$(echo "$COMFYUI_FORK" | cut -d'/' -f1)
echo "Creating PR from ${COMFYUI_FORK} to Comfy-Org/ComfyUI"
echo "Creating PR from ${COMFYUI_FORK} to comfyanonymous/ComfyUI"
# Configure git
git config user.name "github-actions[bot]"
@@ -288,7 +288,7 @@ jobs:
# Try to create PR, ignore error if it already exists
if ! gh pr create \
--repo Comfy-Org/ComfyUI \
--repo comfyanonymous/ComfyUI \
--head "${FORK_OWNER}:${BRANCH}" \
--base master \
--title "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}" \
@@ -297,7 +297,7 @@ jobs:
# Check if PR already exists
set +e
EXISTING_PR=$(gh pr list --repo Comfy-Org/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
EXISTING_PR=$(gh pr list --repo comfyanonymous/ComfyUI --head "${FORK_OWNER}:${BRANCH}" --json number --jq '.[0].number' 2>&1)
PR_LIST_EXIT=$?
set -e
@@ -318,7 +318,7 @@ jobs:
run: |
echo "## ComfyUI PR Created" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Draft PR created in Comfy-Org/ComfyUI" >> $GITHUB_STEP_SUMMARY
echo "Draft PR created in comfyanonymous/ComfyUI" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### PR Body:" >> $GITHUB_STEP_SUMMARY
cat pr-body.txt >> $GITHUB_STEP_SUMMARY

View File

@@ -6,11 +6,10 @@ const { defineConfig } = require('@lobehub/i18n-cli');
module.exports = defineConfig({
modelName: 'gpt-4.1',
splitToken: 1024,
saveImmediately: true,
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.
@@ -19,11 +18,5 @@ module.exports = defineConfig({
- For 'zh' locale: Use ONLY Simplified Chinese characters (简体中文). Common examples: 节点 (not 節點), 画布 (not 畫布), 图像 (not 圖像), 选择 (not 選擇), 减小 (not 減小).
- For 'zh-TW' locale: Use ONLY Traditional Chinese characters (繁體中文) with Taiwan-specific terminology.
- NEVER mix Simplified and Traditional Chinese characters within the same locale.
IMPORTANT Persian Translation Guidelines:
- For 'fa' locale: Use formal Persian (فارسی رسمی) for professional tone throughout the UI.
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
- Maintain consistency with terminology used in Persian software and design applications.
`
});

View File

@@ -7,7 +7,7 @@ import type { InlineConfig } from 'vite'
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: ['@storybook/addon-docs', '@storybook/addon-mcp'],
addons: ['@storybook/addon-docs'],
framework: {
name: '@storybook/vue3-vite',
options: {}
@@ -69,32 +69,9 @@ const config: StorybookConfig = {
allowedHosts: true
},
resolve: {
alias: [
{
find: '@/composables/queue/useJobList',
replacement: process.cwd() + '/src/storybook/mocks/useJobList.ts'
},
{
find: '@/composables/queue/useJobActions',
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
},
{
find: '@/utils/formatUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/formatUtil.ts'
},
{
find: '@/utils/networkUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/networkUtil.ts'
},
{
find: '@',
replacement: process.cwd() + '/src'
}
]
alias: {
'@': process.cwd() + '/src'
}
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main

View File

@@ -63,9 +63,6 @@ The project uses **Nx** for build orchestration and task management
- Imports:
- sorted/grouped by plugin
- run `pnpm format` before committing
- use separate `import type` statements, not inline `type` in mixed imports
-`import type { Foo } from './foo'` + `import { bar } from './foo'`
-`import { bar, type Foo } from './foo'`
- ESLint:
- Vue + TS rules
- no floating promises
@@ -122,10 +119,7 @@ The project uses **Nx** for build orchestration and task management
- Prefer reactive props destructuring to `const props = defineProps<...>`
- Do not use `withDefaults` or runtime props declaration
- Do not import Vue macros unnecessarily
- Prefer `defineModel` to separately defining a prop and emit for v-model bindings
- Define slots via template usage, not `defineSlots`
- Use same-name shorthand for slot prop bindings: `:isExpanded` instead of `:is-expanded="isExpanded"`
- Derive component types using `vue-component-type-helpers` (`ComponentProps`, `ComponentSlots`) instead of separate type files
- Prefer `useModel` to separately defining a prop and emit
- Be judicious with addition of new refs or other state
- If it's possible to accomplish the design goals with just a prop, don't add a `ref`
- If it's possible to use the `ref` or prop directly, don't add a `computed`
@@ -143,7 +137,7 @@ The project uses **Nx** for build orchestration and task management
8. Implement proper error handling
9. Follow Vue 3 style guide and naming conventions
10. Use Vite for fast development and building
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json. Use the plurals system in i18n instead of hardcoding pluralization in templates.
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
12. Avoid new usage of PrimeVue components
13. Write tests for all changes, especially bug fixes to catch future regressions
14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary
@@ -161,8 +155,6 @@ The project uses **Nx** for build orchestration and task management
## Testing Guidelines
See @docs/testing/*.md for detailed patterns.
- Frameworks:
- Vitest (unit/component, happy-dom)
- Playwright (E2E)
@@ -276,8 +268,6 @@ When referencing Comfy-Org repos:
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
- NEVER use `!important` or the `!` important prefix for tailwind classes
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
## Agent-only rules

View File

@@ -37,7 +37,7 @@
/src/components/graph/selectionToolbox/ @Myestery
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
# Workflow Templates
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
@@ -55,7 +55,8 @@
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Translations
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
/src/locales/pt-BR/ @JonatanAtila @Yorha4D @KarryCharon @shinshin86
# LLM Instructions (blank on purpose)
.claude/

View File

@@ -3,7 +3,7 @@ import { test as base, expect } from '@playwright/test'
import dotenv from 'dotenv'
import * as fs from 'fs'
import type { LGraphNode, LGraph } from '../../src/lib/litegraph/src/litegraph'
import type { LGraphNode } from '../../src/lib/litegraph/src/litegraph'
import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema'
import type { KeyCombo } from '../../src/schemas/keyBindingSchema'
import type { useWorkspaceStore } from '../../src/stores/workspaceStore'
@@ -21,6 +21,7 @@ import {
import { Topbar } from './components/Topbar'
import type { Position, Size } from './types'
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
import TaskHistory from './utils/taskHistory'
dotenv.config()
@@ -145,6 +146,8 @@ class ConfirmDialog {
}
export class ComfyPage {
private _history: TaskHistory | null = null
public readonly url: string
// All canvas position operations are based on default view of canvas.
public readonly canvas: Locator
@@ -298,6 +301,11 @@ export class ComfyPage {
}
}
setupHistory(): TaskHistory {
this._history ??= new TaskHistory(this)
return this._history
}
async setup({
clearStorage = true,
mockReleases = true
@@ -1583,29 +1591,14 @@ export class ComfyPage {
return window['app'].graph.nodes
})
}
async waitForGraphNodes(count: number) {
await this.page.waitForFunction((count) => {
return window['app']?.canvas.graph?.nodes?.length === count
}, count)
}
async getNodeRefsByType(
type: string,
includeSubgraph: boolean = false
): Promise<NodeReference[]> {
async getNodeRefsByType(type: string): Promise<NodeReference[]> {
return Promise.all(
(
await this.page.evaluate(
({ type, includeSubgraph }) => {
const graph = (
includeSubgraph ? window['app'].canvas.graph : window['app'].graph
) as LGraph
const nodes = graph.nodes
return nodes
.filter((n: LGraphNode) => n.type === type)
.map((n: LGraphNode) => n.id)
},
{ type, includeSubgraph }
)
await this.page.evaluate((type) => {
return window['app'].graph.nodes
.filter((n: LGraphNode) => n.type === type)
.map((n: LGraphNode) => n.id)
}, type)
).map((id: NodeId) => this.getNodeRefById(id))
)
}

View File

@@ -159,18 +159,8 @@ export class VueNodeHelpers {
getInputNumberControls(widget: Locator) {
return {
input: widget.locator('input'),
decrementButton: widget.getByTestId('decrement'),
incrementButton: widget.getByTestId('increment')
incrementButton: widget.locator('button').first(),
decrementButton: widget.locator('button').nth(1)
}
}
/**
* Enter the subgraph of a node.
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
*/
async enterSubgraph(nodeId?: string): Promise<void> {
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId('subgraph-enter-button')
await editButton.click()
}
}

View File

@@ -0,0 +1,164 @@
import type { Request, Route } from '@playwright/test'
import _ from 'es-toolkit/compat'
import fs from 'fs'
import path from 'path'
import { v4 as uuidv4 } from 'uuid'
import type {
HistoryTaskItem,
TaskItem,
TaskOutput
} from '../../../src/schemas/apiSchema'
import type { ComfyPage } from '../ComfyPage'
/** keyof TaskOutput[string] */
type OutputFileType = 'images' | 'audio' | 'animated'
const DEFAULT_IMAGE = 'example.webp'
const getFilenameParam = (request: Request) => {
const url = new URL(request.url())
return url.searchParams.get('filename') || DEFAULT_IMAGE
}
const getContentType = (filename: string, fileType: OutputFileType) => {
const subtype = path.extname(filename).slice(1)
switch (fileType) {
case 'images':
return `image/${subtype}`
case 'audio':
return `audio/${subtype}`
case 'animated':
return `video/${subtype}`
}
}
const setQueueIndex = (task: TaskItem) => {
task.prompt[0] = TaskHistory.queueIndex++
}
const setPromptId = (task: TaskItem) => {
task.prompt[1] = uuidv4()
}
export default class TaskHistory {
static queueIndex = 0
static readonly defaultTask: Readonly<HistoryTaskItem> = {
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []],
outputs: {},
status: {
status_str: 'success',
completed: true,
messages: []
},
taskType: 'History'
}
private tasks: HistoryTaskItem[] = []
private outputContentTypes: Map<string, string> = new Map()
constructor(readonly comfyPage: ComfyPage) {}
private loadAsset: (filename: string) => Buffer = _.memoize(
(filename: string) => {
const filePath = this.comfyPage.assetPath(filename)
return fs.readFileSync(filePath)
}
)
private async handleGetHistory(route: Route) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.tasks)
})
}
private async handleGetView(route: Route) {
const fileName = getFilenameParam(route.request())
if (!this.outputContentTypes.has(fileName)) {
return route.continue()
}
const asset = this.loadAsset(fileName)
return route.fulfill({
status: 200,
contentType: this.outputContentTypes.get(fileName),
body: asset,
headers: {
'Cache-Control': 'public, max-age=31536000',
'Content-Length': asset.byteLength.toString()
}
})
}
async setupRoutes() {
return this.comfyPage.page.route(
/.*\/api\/(view|history)(\?.*)?$/,
async (route) => {
const request = route.request()
const method = request.method()
const isViewReq = request.url().includes('view') && method === 'GET'
if (isViewReq) return this.handleGetView(route)
const isHistoryPath = request.url().includes('history')
const isGetHistoryReq = isHistoryPath && method === 'GET'
if (isGetHistoryReq) return this.handleGetHistory(route)
const isClearReq =
method === 'POST' &&
isHistoryPath &&
request.postDataJSON()?.clear === true
if (isClearReq) return this.clearTasks()
return route.continue()
}
)
}
private createOutputs(
filenames: string[],
filetype: OutputFileType
): TaskOutput {
return filenames.reduce((outputs, filename, i) => {
const nodeId = `${i + 1}`
outputs[nodeId] = {
[filetype]: [{ filename, subfolder: '', type: 'output' }]
}
const contentType = getContentType(filename, filetype)
this.outputContentTypes.set(filename, contentType)
return outputs
}, {})
}
private addTask(task: HistoryTaskItem) {
setPromptId(task)
setQueueIndex(task)
this.tasks.unshift(task) // Tasks are added to the front of the queue
}
clearTasks(): this {
this.tasks = []
return this
}
withTask(
outputFilenames: string[],
outputFiletype: OutputFileType = 'images',
overrides: Partial<HistoryTaskItem> = {}
): this {
this.addTask({
...TaskHistory.defaultTask,
outputs: this.createOutputs(outputFilenames, outputFiletype),
...overrides
})
return this
}
/** Repeats the last task in the task history a specified number of times. */
repeat(n: number): this {
for (let i = 0; i < n; i++)
this.addTask(structuredClone(this.tasks.at(0)) as HistoryTaskItem)
return this
}
}

View File

@@ -133,11 +133,8 @@ test.describe('Menu', () => {
// Checkmark should be invisible again (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
// Click in top-right corner to close menu (avoid hamburger menu at top-left)
const viewport = comfyPage.page.viewportSize()!
await comfyPage.page
.locator('body')
.click({ position: { x: viewport.width - 10, y: 10 } })
// Click outside to close menu
await comfyPage.page.locator('body').click({ position: { x: 10, y: 10 } })
// Verify menu is now closed
await expect(menu).not.toBeVisible()

View File

@@ -22,14 +22,8 @@ test.describe('Mobile Baseline Snapshots', () => {
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.nextFrame()
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
'mobile-settings-dialog.png',
{
mask: [
comfyPage.settingDialog.root.getByTestId('current-user-indicator')
]
}
'mobile-settings-dialog.png'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -8,11 +8,13 @@ test.describe('Properties panel', () => {
const { propertiesPanel } = comfyPage.menu
await expect(propertiesPanel.panelTitle).toContainText('Workflow Overview')
await expect(propertiesPanel.panelTitle).toContainText(
'No node(s) selected'
)
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
await expect(propertiesPanel.panelTitle).toContainText('3 items selected')
await expect(propertiesPanel.panelTitle).toContainText('3 nodes selected')
await expect(propertiesPanel.root.getByText('KSampler')).toHaveCount(1)
await expect(
propertiesPanel.root.getByText('CLIP Text Encode (Prompt)')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -102,7 +102,7 @@ test.describe('Vue Node Link Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
// await comfyPage.setup()
await comfyPage.setup()
await comfyPage.loadWorkflow('vueNodes/simple-triple')
await comfyPage.vueNodes.waitForNodes()
await fitToViewInstant(comfyPage)
@@ -993,51 +993,4 @@ test.describe('Vue Node Link Interaction', () => {
expect(linked).toBe(true)
})
})
test('Dragging from subgraph input connects to correct slot', async ({
comfyPage,
comfyMouse
}) => {
// Setup workflow with a KSampler node
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.waitForGraphNodes(0)
await comfyPage.executeCommand('Workspace.SearchBox.Toggle')
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await comfyPage.waitForGraphNodes(1)
// Convert the KSampler node to a subgraph
let ksamplerNode = (await comfyPage.getNodeRefsByType('KSampler'))?.[0]
await comfyPage.vueNodes.selectNode(String(ksamplerNode.id))
await comfyPage.executeCommand('Comfy.Graph.ConvertToSubgraph')
// Enter the subgraph
await comfyPage.vueNodes.enterSubgraph()
await fitToViewInstant(comfyPage)
// Get the KSampler node inside the subgraph
ksamplerNode = (await comfyPage.getNodeRefsByType('KSampler', true))?.[0]
const positiveInput = await ksamplerNode.getInput(1)
const negativeInput = await ksamplerNode.getInput(2)
const positiveInputPos = await getSlotCenter(
comfyPage.page,
ksamplerNode.id,
1,
true
)
const sourceSlot = await comfyPage.getSubgraphInputSlot()
const calculatedSourcePos = await sourceSlot.getOpenSlotPosition()
await comfyMouse.move(calculatedSourcePos)
await comfyMouse.drag(positiveInputPos)
await comfyMouse.drop()
// Verify connection went to the correct slot
const positiveLinks = await positiveInput.getLinkCount()
const negativeLinks = await negativeInput.getLinkCount()
expect(positiveLinks).toBe(1)
expect(negativeLinks).toBe(0)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

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

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 98 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: 106 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -194,10 +194,7 @@ test.describe('Image widget', () => {
const comboEntry = comfyPage.page.getByRole('menuitem', {
name: 'image32x32.webp'
})
await comboEntry.click()
// Stabilization for the image swap
await comfyPage.nextFrame()
await comboEntry.click({ noWaitAfter: true })
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,138 +0,0 @@
---
globs:
- '**/*.test.ts'
- '**/*.spec.ts'
---
# Vitest Patterns
## Setup
Use `createTestingPinia` from `@pinia/testing`, not `createPinia`:
```typescript
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('MyStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
vi.resetAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
})
```
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
## Mock Patterns
### Reset all mocks at once
```typescript
beforeEach(() => {
vi.resetAllMocks() // Not individual mock.mockReset() calls
})
```
### Module mocks with vi.mock()
```typescript
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
fetchData: vi.fn()
}
}))
vi.mock('@/services/myService', () => ({
myService: {
doThing: vi.fn()
}
}))
```
### Configure mocks in tests
```typescript
import { api } from '@/scripts/api'
import { myService } from '@/services/myService'
it('handles success', () => {
vi.mocked(myService.doThing).mockResolvedValue({ data: 'test' })
// ... test code
})
```
## Testing Event Listeners
When a store registers event listeners at module load time:
```typescript
function getEventHandler() {
const call = vi.mocked(api.addEventListener).mock.calls.find(
([event]) => event === 'my_event'
)
return call?.[1] as (e: CustomEvent<MyEventType>) => void
}
function dispatch(data: MyEventType) {
const handler = getEventHandler()
handler(new CustomEvent('my_event', { detail: data }))
}
it('handles events', () => {
const store = useMyStore()
dispatch({ field: 'value' })
expect(store.items).toHaveLength(1)
})
```
## Testing with Fake Timers
For stores with intervals, timeouts, or polling:
```typescript
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('polls after delay', async () => {
const store = useMyStore()
store.startPolling()
await vi.advanceTimersByTimeAsync(30000)
expect(mockService.fetch).toHaveBeenCalled()
})
```
## Assertion Style
Prefer `.toHaveLength()` over `.length.toBe()`:
```typescript
// Good
expect(store.items).toHaveLength(1)
// Avoid
expect(store.items.length).toBe(1)
```
Use `.toMatchObject()` for partial matching:
```typescript
expect(store.completedItems[0]).toMatchObject({
id: 'task-123',
status: 'done'
})
```

View File

@@ -8,8 +8,7 @@ const config: KnipConfig = {
'src/assets/css/style.css',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts',
'src/storybook/mocks/**/*.ts'
'src/types/index.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
},

View File

@@ -1,23 +0,0 @@
import path from 'node:path'
export default {
'./**/*.js': (stagedFiles) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
...formatAndEslint(stagedFiles),
'pnpm typecheck'
]
}
function formatAndEslint(fileNames) {
// Convert absolute paths to relative paths for better ESLint resolution
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
return [
`pnpm exec prettier --cache --write ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}

View File

@@ -3,13 +3,6 @@
"short_name": "ComfyUI",
"description": "ComfyUI: AI image generation platform",
"start_url": "/",
"icons": [
{
"src": "/assets/images/comfy-logo-single.svg",
"sizes": "any",
"type": "image/svg+xml"
}
],
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000"

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.38.3",
"version": "1.37.5",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -66,7 +66,6 @@
"@prettier/plugin-oxc": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@storybook/addon-docs": "catalog:",
"@storybook/addon-mcp": "catalog:",
"@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:",
"@tailwindcss/vite": "catalog:",

View File

@@ -247,7 +247,6 @@
--inverted-background-hover: var(--color-charcoal-600);
--warning-background: var(--color-gold-400);
--warning-background-hover: var(--color-gold-500);
--success-background: var(--color-jade-600);
--border-default: var(--color-smoke-600);
--border-subtle: var(--color-smoke-400);
--muted-background: var(--color-smoke-700);
@@ -282,7 +281,7 @@
--modal-card-border-highlighted: var(--secondary-background-selected);
--modal-card-button-surface: var(--color-smoke-300);
--modal-card-placeholder-background: var(--color-smoke-600);
--modal-card-tag-background: var(--color-smoke-200);
--modal-card-tag-background: var(--color-smoke-400);
--modal-card-tag-foreground: var(--base-foreground);
--modal-panel-background: var(--color-white);
}
@@ -373,7 +372,6 @@
--inverted-background-hover: var(--color-smoke-200);
--warning-background: var(--color-gold-600);
--warning-background-hover: var(--color-gold-500);
--success-background: var(--color-jade-600);
--border-default: var(--color-charcoal-200);
--border-subtle: var(--color-charcoal-300);
--muted-background: var(--color-charcoal-100);
@@ -518,7 +516,6 @@
--color-inverted-background-hover: var(--inverted-background-hover);
--color-warning-background: var(--warning-background);
--color-warning-background-hover: var(--warning-background-hover);
--color-success-background: var(--success-background);
--color-border-default: var(--border-default);
--color-border-subtle: var(--border-subtle);
--color-muted-background: var(--muted-background);

134
pnpm-lock.yaml generated
View File

@@ -84,9 +84,6 @@ catalogs:
'@storybook/addon-docs':
specifier: ^10.1.9
version: 10.1.9
'@storybook/addon-mcp':
specifier: 0.1.6
version: 0.1.6
'@storybook/vue3':
specifier: ^10.1.9
version: 10.1.9
@@ -552,9 +549,6 @@ importers:
'@storybook/addon-docs':
specifier: 'catalog:'
version: 10.1.9(@types/react@19.1.9)(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@storybook/addon-mcp':
specifier: 'catalog:'
version: 0.1.6(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)
'@storybook/vue3':
specifier: 'catalog:'
version: 10.1.9(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vue@3.5.13(typescript@5.9.3))
@@ -3154,11 +3148,6 @@ packages:
peerDependencies:
storybook: ^10.1.9
'@storybook/addon-mcp@0.1.6':
resolution: {integrity: sha512-+EagCHqwIb9tg3DKskEsXpsqQVnMljxgR5Tt3Bu0ZpWweB1HdMy+ok128gzNfTZ3r+5ljksr0q66YCEkrQwdDA==}
peerDependencies:
storybook: ^9.1.16 || ^10.0.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0
'@storybook/builder-vite@10.1.9':
resolution: {integrity: sha512-rUILpjGV7gKfXrUeZzpNAer9PspB3LJI1d+gJHISx2Gs24bdneA3y/gu0fWw46ccOSIcwb91xoK5QxliJcWsWg==}
peerDependencies:
@@ -3192,9 +3181,6 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@storybook/mcp@0.1.1':
resolution: {integrity: sha512-+AivFDms1XkY2VUvZBBYy0co5qvRh20eYXYwhaDPQXX2Q4y96arSkWn22e/l3DQwA9Ywzv481vj4gl4zPrCQkg==}
'@storybook/react-dom-shim@10.1.9':
resolution: {integrity: sha512-gJsR6fI1gG4DSin6sQx8RmGDQF8Lije0cZbxHyVedNleBsveGXIPFUKFVi+pRNdwBPni1Z2g/gYyHzkOEqPD2w==}
peerDependencies:
@@ -3467,26 +3453,6 @@ packages:
'@tiptap/starter-kit@2.10.4':
resolution: {integrity: sha512-tu/WCs9Mkr5Nt8c3/uC4VvAbQlVX0OY7ygcqdzHGUeG9zP3twdW7o5xM3kyDKR2++sbVzqu5Ll5qNU+1JZvPGQ==}
'@tmcp/adapter-valibot@0.1.5':
resolution: {integrity: sha512-9P2wrVYPngemNK0UvPb/opC722/jfd09QxXmme1TRp/wPsl98vpSk/MXt24BCMqBRv4Dvs0xxJH4KHDcjXW52Q==}
peerDependencies:
tmcp: ^1.17.0
valibot: ^1.1.0
'@tmcp/session-manager@0.2.1':
resolution: {integrity: sha512-DOGy9LfufXCy1wfpGHZ6qPSDQtRnTVwOb71+41ffovTqzLMZlK3iLK/LIsekHxIiku+iIAUiqEKN+DHbqEm8IA==}
peerDependencies:
tmcp: ^1.16.3
'@tmcp/transport-http@0.8.3':
resolution: {integrity: sha512-gnoBjDBd8/ppl4WRrNKPKHlioCxE8D0zTyNUOzqUjsg0s6GRsyB5iMirh9lC4QjQt0NEOrI+sIJdz+9ymf0MDA==}
peerDependencies:
'@tmcp/auth': ^0.3.3 || ^0.4.0
tmcp: ^1.18.0
peerDependenciesMeta:
'@tmcp/auth':
optional: true
'@trivago/prettier-plugin-sort-imports@5.2.2':
resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==}
engines: {node: '>18.12'}
@@ -3820,11 +3786,6 @@ packages:
cpu: [x64]
os: [win32]
'@valibot/to-json-schema@1.5.0':
resolution: {integrity: sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ==}
peerDependencies:
valibot: ^1.2.0
'@vitejs/plugin-vue@6.0.3':
resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -5209,9 +5170,6 @@ packages:
jiti:
optional: true
esm-env@1.2.2:
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
esm-resolve@1.0.11:
resolution: {integrity: sha512-LxF0wfUQm3ldUDHkkV2MIbvvY0TgzIpJ420jHSV1Dm+IlplBEWiJTKWM61GtxUfvjV6iD4OtTYFGAGM2uuIUWg==}
@@ -6020,9 +5978,6 @@ packages:
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
json-rpc-2.0@1.7.1:
resolution: {integrity: sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==}
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -7431,9 +7386,6 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
sqids@0.3.0:
resolution: {integrity: sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw==}
stable-hash-x@0.2.0:
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
engines: {node: '>=12.0.0'}
@@ -7661,9 +7613,6 @@ packages:
resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==}
hasBin: true
tmcp@1.19.0:
resolution: {integrity: sha512-wOY449EdaWDo7wLZEOVjeH9fn/AqfFF4f+3pDerCI8xHpy2Z8msUjAF0Vkg01aEFIdFMmiNDiY4hu6E7jVX79w==}
tmp@0.2.5:
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
engines: {node: '>=14.14'}
@@ -7909,9 +7858,6 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
uri-template-matcher@1.1.2:
resolution: {integrity: sha512-uZc1h12jdO3m/R77SfTEOuo6VbMhgWznaawKpBjRGSJb7i91x5PgI37NQJtG+Cerxkk0yr1pylBY2qG1kQ+aEQ==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
@@ -7927,14 +7873,6 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
valibot@1.2.0:
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
typescript:
optional: true
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -8081,9 +8019,6 @@ packages:
vue-component-type-helpers@3.2.1:
resolution: {integrity: sha512-gKV7XOkQl4urSuLHNY1tnVQf7wVgtb/mKbRyxSLWGZUY9RK7aDPhBenTjm+i8ZFe0zC2PZeHMPtOZXZfyaFOzQ==}
vue-component-type-helpers@3.2.2:
resolution: {integrity: sha512-x8C2nx5XlUNM0WirgfTkHjJGO/ABBxlANZDtHw2HclHtQnn+RFPTnbjMJn8jHZW4TlUam0asHcA14lf1C6Jb+A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@@ -11014,18 +10949,6 @@ snapshots:
- vite
- webpack
'@storybook/addon-mcp@0.1.6(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)':
dependencies:
'@storybook/mcp': 0.1.1(typescript@5.9.3)
'@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))
'@tmcp/transport-http': 0.8.3(tmcp@1.19.0(typescript@5.9.3))
storybook: 10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
tmcp: 1.19.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
- '@tmcp/auth'
- typescript
'@storybook/builder-vite@10.1.9(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@storybook/csf-plugin': 10.1.9(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
@@ -11055,16 +10978,6 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@storybook/mcp@0.1.1(typescript@5.9.3)':
dependencies:
'@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))
'@tmcp/transport-http': 0.8.3(tmcp@1.19.0(typescript@5.9.3))
tmcp: 1.19.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
- '@tmcp/auth'
- typescript
'@storybook/react-dom-shim@10.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))':
dependencies:
react: 19.2.3
@@ -11094,7 +11007,7 @@ snapshots:
storybook: 10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.3)
vue-component-type-helpers: 3.2.2
vue-component-type-helpers: 3.2.1
'@swc/helpers@0.5.17':
dependencies:
@@ -11362,23 +11275,6 @@ snapshots:
'@tiptap/extension-text-style': 2.10.4(@tiptap/core@2.10.4(@tiptap/pm@2.10.4))
'@tiptap/pm': 2.10.4
'@tmcp/adapter-valibot@0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))':
dependencies:
'@standard-schema/spec': 1.1.0
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
tmcp: 1.19.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
'@tmcp/session-manager@0.2.1(tmcp@1.19.0(typescript@5.9.3))':
dependencies:
tmcp: 1.19.0(typescript@5.9.3)
'@tmcp/transport-http@0.8.3(tmcp@1.19.0(typescript@5.9.3))':
dependencies:
'@tmcp/session-manager': 0.2.1(tmcp@1.19.0(typescript@5.9.3))
esm-env: 1.2.2
tmcp: 1.19.0(typescript@5.9.3)
'@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4)':
dependencies:
'@babel/generator': 7.28.5
@@ -11727,10 +11623,6 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3))':
dependencies:
valibot: 1.2.0(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
@@ -13411,8 +13303,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
esm-env@1.2.2: {}
esm-resolve@1.0.11: {}
espree@10.4.0:
@@ -14299,8 +14189,6 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
json-rpc-2.0@1.7.1: {}
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
@@ -16167,8 +16055,6 @@ snapshots:
sprintf-js@1.0.3: {}
sqids@0.3.0: {}
stable-hash-x@0.2.0: {}
stack-utils@2.0.6:
@@ -16461,16 +16347,6 @@ snapshots:
dependencies:
tldts-core: 7.0.19
tmcp@1.19.0(typescript@5.9.3):
dependencies:
'@standard-schema/spec': 1.1.0
json-rpc-2.0: 1.7.1
sqids: 0.3.0
uri-template-matcher: 1.1.2
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
- typescript
tmp@0.2.5: {}
to-regex-range@5.0.1:
@@ -16768,8 +16644,6 @@ snapshots:
dependencies:
punycode: 2.3.1
uri-template-matcher@1.1.2: {}
use-sync-external-store@1.6.0(react@19.2.3):
dependencies:
react: 19.2.3
@@ -16780,10 +16654,6 @@ snapshots:
uuid@11.1.0: {}
valibot@1.2.0(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -17044,8 +16914,6 @@ snapshots:
vue-component-type-helpers@3.2.1: {}
vue-component-type-helpers@3.2.2: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
dependencies:
vue: 3.5.13(typescript@5.9.3)

View File

@@ -29,7 +29,6 @@ catalog:
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^0.1.10
'@storybook/addon-docs': ^10.1.9
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9
'@tailwindcss/vite': ^4.1.12

View File

@@ -10,158 +10,37 @@ interface TestStats {
finished?: number
}
interface TestResult {
status: string
duration?: number
error?: {
message?: string
stack?: string
}
attachments?: Array<{
name: string
path?: string
contentType: string
}>
}
interface TestCase {
title: string
ok: boolean
outcome: string
results: TestResult[]
}
interface Suite {
title: string
file: string
suites?: Suite[]
tests?: TestCase[]
}
interface FullReportData {
stats?: TestStats
suites?: Suite[]
}
interface ReportData {
stats?: TestStats
}
interface FailedTest {
name: string
file: string
traceUrl?: string
error?: string
}
interface TestCounts {
passed: number
failed: number
flaky: number
skipped: number
total: number
failures?: FailedTest[]
}
/**
* Extract failed test details from Playwright report
*/
function extractFailedTests(
reportData: FullReportData,
baseUrl?: string
): FailedTest[] {
const failures: FailedTest[] = []
function processTest(test: TestCase, file: string, suitePath: string[]) {
// Check if test failed or is flaky
const hasFailed = test.results.some(
(r) => r.status === 'failed' || r.status === 'timedOut'
)
const isFlaky = test.outcome === 'flaky'
if (hasFailed || isFlaky) {
const fullTestName = [...suitePath, test.title]
.filter(Boolean)
.join(' ')
const failedResult = test.results.find(
(r) => r.status === 'failed' || r.status === 'timedOut'
)
// Find trace attachment
let traceUrl: string | undefined
if (failedResult?.attachments) {
const traceAttachment = failedResult.attachments.find(
(a) => a.name === 'trace' && a.contentType === 'application/zip'
)
if (traceAttachment?.path) {
// Convert local path to URL path
const tracePath = traceAttachment.path.replace(/\\/g, '/')
const traceFile = path.basename(tracePath)
if (baseUrl) {
// Construct trace viewer URL
const traceDataUrl = `${baseUrl}/data/${traceFile}`
traceUrl = `${baseUrl}/trace/?trace=${encodeURIComponent(traceDataUrl)}`
}
}
}
failures.push({
name: fullTestName,
file: file,
traceUrl,
error: failedResult?.error?.message
})
}
}
function processSuite(suite: Suite, parentPath: string[] = []) {
const suitePath = suite.title ? [...parentPath, suite.title] : parentPath
// Process tests in this suite
if (suite.tests) {
for (const test of suite.tests) {
processTest(test, suite.file, suitePath)
}
}
// Recursively process nested suites
if (suite.suites) {
for (const childSuite of suite.suites) {
processSuite(childSuite, suitePath)
}
}
}
if (reportData.suites) {
for (const suite of reportData.suites) {
processSuite(suite)
}
}
return failures
}
/**
* Extract test counts from Playwright HTML report
* @param reportDir - Path to the playwright-report directory
* @param baseUrl - Base URL of the deployed report (for trace links)
* @returns Test counts { passed, failed, flaky, skipped, total, failures }
* @returns Test counts { passed, failed, flaky, skipped, total }
*/
function extractTestCounts(reportDir: string, baseUrl?: string): TestCounts {
function extractTestCounts(reportDir: string): TestCounts {
const counts: TestCounts = {
passed: 0,
failed: 0,
flaky: 0,
skipped: 0,
total: 0,
failures: []
total: 0
}
try {
// First, try to find report.json which Playwright generates with JSON reporter
const jsonReportFile = path.join(reportDir, 'report.json')
if (fs.existsSync(jsonReportFile)) {
const reportJson: FullReportData = JSON.parse(
const reportJson: ReportData = JSON.parse(
fs.readFileSync(jsonReportFile, 'utf-8')
)
if (reportJson.stats) {
@@ -175,12 +54,6 @@ function extractTestCounts(reportDir: string, baseUrl?: string): TestCounts {
counts.failed = stats.unexpected || 0
counts.flaky = stats.flaky || 0
counts.skipped = stats.skipped || 0
// Extract detailed failure information
if (counts.failed > 0 || counts.flaky > 0) {
counts.failures = extractFailedTests(reportJson, baseUrl)
}
return counts
}
}
@@ -296,18 +169,15 @@ function extractTestCounts(reportDir: string, baseUrl?: string): TestCounts {
// Main execution
const reportDir = process.argv[2]
const baseUrl = process.argv[3] // Optional: base URL for trace links
if (!reportDir) {
console.error(
'Usage: extract-playwright-counts.ts <report-directory> [base-url]'
)
console.error('Usage: extract-playwright-counts.ts <report-directory>')
process.exit(1)
}
const counts = extractTestCounts(reportDir, baseUrl)
const counts = extractTestCounts(reportDir)
// Output as JSON for easy parsing in shell script
process.stdout.write(JSON.stringify(counts) + '\n')
console.log(JSON.stringify(counts))
export { extractTestCounts, extractFailedTests }
export { extractTestCounts }

View File

@@ -134,22 +134,23 @@ post_comment() {
# Main execution
if [ "$STATUS" = "starting" ]; then
# Post concise starting comment
# Post starting comment
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎭 Playwright Tests: ⏳ Running...
## 🎭 Playwright Test Results
Tests started at $START_TIME UTC
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Tests are starting...**
<details>
<summary>📊 Browser Tests</summary>
⏰ Started at: $START_TIME UTC
- **chromium**: Running...
- **chromium-0.5x**: Running...
- **chromium-2x**: Running...
- **mobile-chrome**: Running...
### 🚀 Running Tests
- 🧪 **chromium**: Running tests...
- 🧪 **chromium-0.5x**: Running tests...
- 🧪 **chromium-2x**: Running tests...
- 🧪 **mobile-chrome**: Running tests...
</details>
---
⏱️ Please wait while tests are running...
EOF
)
post_comment "$comment"
@@ -188,8 +189,7 @@ else
if command -v tsx > /dev/null 2>&1 && [ -f "$EXTRACT_SCRIPT" ]; then
echo "Extracting counts from $REPORT_DIR using $EXTRACT_SCRIPT" >&2
# Pass the base URL so we can generate trace links
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" "$url" 2>&1 || echo '{}')
counts=$(tsx "$EXTRACT_SCRIPT" "$REPORT_DIR" 2>&1 || echo '{}')
echo "Extracted counts for $browser: $counts" >&2
echo "$counts" > "$temp_dir/$i.counts"
else
@@ -286,74 +286,43 @@ else
# Determine overall status
if [ $total_failed -gt 0 ]; then
status_icon="❌"
status_text="Failed"
status_text="Some tests failed"
elif [ $total_flaky -gt 0 ]; then
status_icon="⚠️"
status_text="Passed with flaky tests"
status_text="Tests passed with flaky tests"
elif [ $total_tests -gt 0 ]; then
status_icon="✅"
status_text="Passed"
status_text="All tests passed!"
else
status_icon="🕵🏻"
status_text="No test results"
status_text="No test results found"
fi
# Generate concise completion comment
# Generate completion comment
comment="$COMMENT_MARKER
## 🎭 Playwright Tests: $status_icon **$status_text**"
## 🎭 Playwright Test Results
$status_icon **$status_text**
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC"
# Add summary counts if we have test data
if [ $total_tests -gt 0 ]; then
comment="$comment
**Results:** $total_passed passed, $total_failed failed, $total_flaky flaky, $total_skipped skipped (Total: $total_tests)"
fi
# Extract and display failed tests from all browsers
if [ $total_failed -gt 0 ] || [ $total_flaky -gt 0 ]; then
comment="$comment
### ❌ Failed Tests"
# Process each browser's failures
for counts_json in "${counts_array[@]}"; do
[ -z "$counts_json" ] || [ "$counts_json" = "{}" ] && continue
if command -v jq > /dev/null 2>&1; then
# Extract failures array from JSON
failures=$(echo "$counts_json" | jq -r '.failures // [] | .[]? | "\(.name)|\(.file)|\(.traceUrl // "")"')
if [ -n "$failures" ]; then
while IFS='|' read -r test_name test_file trace_url; do
[ -z "$test_name" ] && continue
# Convert file path to GitHub URL (relative to repo root)
github_file_url="https://github.com/$GITHUB_REPOSITORY/blob/$GITHUB_SHA/$test_file"
# Build the failed test line
test_line="- [$test_name]($github_file_url)"
if [ -n "$trace_url" ] && [ "$trace_url" != "null" ]; then
test_line="$test_line: [View trace]($trace_url)"
fi
comment="$comment
$test_line"
done <<< "$failures"
fi
fi
done
### 📈 Summary
- **Total Tests:** $total_tests
- **Passed:** $total_passed
- **Failed:** $total_failed $([ $total_failed -gt 0 ] && echo '❌' || echo '')
- **Flaky:** $total_flaky $([ $total_flaky -gt 0 ] && echo '⚠️' || echo '')
- **Skipped:** $total_skipped $([ $total_skipped -gt 0 ] && echo '⏭️' || echo '')"
fi
# Add browser reports in collapsible section
comment="$comment
<details>
<summary>📊 Browser Reports</summary>
"
### 📊 Test Reports by Browser"
# Add browser results
# Add browser results with individual counts
i=0
IFS=' ' read -r -a browser_array <<< "$BROWSERS"
IFS=' ' read -r -a url_array <<< "$urls"
@@ -380,7 +349,7 @@ $test_line"
fi
if [ -n "$b_total" ] && [ "$b_total" != "0" ]; then
counts_str=" ($b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped)"
counts_str=" $b_passed / ❌ $b_failed / ⚠️ $b_flaky / ⏭️ $b_skipped"
else
counts_str=""
fi
@@ -389,10 +358,10 @@ $test_line"
fi
comment="$comment
- **${browser}**: [View Report](${url})${counts_str}"
- **${browser}**: [View Report](${url})${counts_str}"
else
comment="$comment
- **${browser}**: Deployment failed"
- **${browser}**: Deployment failed"
fi
i=$((i + 1))
done
@@ -400,7 +369,8 @@ $test_line"
comment="$comment
</details>"
---
🎉 Click on the links above to view detailed test results for each browser configuration."
post_comment "$comment"
fi

View File

@@ -1,21 +1 @@
@import '@comfyorg/design-system/css/style.css';
@media (prefers-reduced-motion: no-preference) {
/* List transition animations */
.list-scale-move,
.list-scale-enter-active,
.list-scale-leave-active {
transition: opacity 150ms ease, transform 150ms ease;
}
.list-scale-enter-from,
.list-scale-leave-to {
opacity: 0;
transform: scale(70%);
}
.list-scale-leave-active {
position: absolute;
width: 100%;
}
}
@import '@comfyorg/design-system/css/style.css';

View File

@@ -20,14 +20,9 @@
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
@@ -54,16 +49,13 @@
<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-base-foreground"
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>
</Button>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
@@ -99,19 +91,15 @@ import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
@@ -123,15 +111,8 @@ const commandStore = useCommandStore()
const queueStore = useQueueStore()
const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
@@ -139,12 +120,6 @@ const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
)
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
})
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex h-full items-center" :class="cn(!isDocked && '-ml-2')">
<div class="flex h-full items-center">
<div
v-if="isDragging && !isDocked"
:class="actionbarClass"
@@ -77,6 +77,7 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const tabContainer = document.querySelector('.workflow-tabs-container')
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
@@ -87,7 +88,14 @@ const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
const { x, y, style, isDragging } = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
containerElement: document.body,
onMove: (event) => {
// Prevent dragging the menu over the top of the tabs
const minY = tabContainer?.getBoundingClientRect().bottom ?? 40
if (event.y < minY) {
event.y = minY
}
}
})
// Update storedPosition when x or y changes

View File

@@ -1,6 +1,6 @@
<template>
<div
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
class="subgraph-breadcrumb w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
@@ -13,37 +13,17 @@
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
}"
>
<Button
class="context-menu-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
icon="pi pi-bars"
text
severity="secondary"
size="small"
@click="handleMenuClick"
/>
<Button
v-if="isInSubgraph"
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
text
severity="secondary"
size="small"
@click="handleBackClick"
>
<i class="icon-[lucide--undo-2]" />
</Button>
<Breadcrumb
ref="breadcrumbRef"
class="w-fit rounded-lg p-0"
:class="{ hidden: !isInSubgraph }"
:model="items"
:pt="{ item: { class: 'pointer-events-auto' } }"
:aria-label="$t('g.graphNavigation')"
>
<template #item="{ item }">
<SubgraphBreadcrumbItem
:ref="(el) => setItemRef(item, el)"
:item="item"
:is-active="item.key === activeItemKey"
:is-active="item === items.at(-1)"
/>
</template>
<template #separator
@@ -55,7 +35,6 @@
<script setup lang="ts">
import Breadcrumb from 'primevue/breadcrumb'
import Button from 'primevue/button'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
@@ -64,7 +43,6 @@ import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
@@ -77,12 +55,6 @@ const ICON_WIDTH = 20
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const rootItemRef = ref<InstanceType<typeof SubgraphBreadcrumbItem>>()
const setItemRef = (item: MenuItem, el: unknown) => {
if (item.key === 'root') {
rootItemRef.value = el as InstanceType<typeof SubgraphBreadcrumbItem>
}
}
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const isBlueprint = computed(() =>
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
@@ -90,28 +62,17 @@ const isBlueprint = computed(() =>
const collapseTabs = ref(false)
const overflowingTabs = ref(false)
const isInSubgraph = computed(() => navigationStore.navigationStack.length > 0)
const breadcrumbElement = computed(() => {
if (!breadcrumbRef.value) return null
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
key: 'root',
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(canvas.graph.rootGraph)
}
}))
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
return list
})
const items = computed(() => {
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
label: subgraph.name,
key: `subgraph-${subgraph.id}`,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_item_selected'
@@ -134,26 +95,21 @@ const items = computed(() => {
return [home.value, ...items]
})
const activeItemKey = computed(() => items.value.at(-1)?.key)
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
key: 'root',
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
const handleMenuClick = (event: MouseEvent) => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_menu_selected'
})
rootItemRef.value?.toggleMenu(event)
}
const handleBackClick = () => {
void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
}
const breadcrumbElement = computed(() => {
if (!breadcrumbRef.value) return null
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
return list
})
canvas.setGraph(canvas.graph.rootGraph)
}
}))
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
@@ -233,18 +189,13 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item) {
@apply flex items-center overflow-hidden h-8;
@apply flex items-center overflow-hidden;
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
border: 1px solid transparent;
background-color: transparent;
transition: all 0.2s;
/* Collapse middle items first */
flex-shrink: 10000;
}
:deep(.p-breadcrumb-separator) {
border: 1px solid transparent;
background-color: transparent;
display: flex;
padding: 0 var(--p-breadcrumb-item-margin);
}
@@ -254,9 +205,11 @@ onUpdated(() => {
calc(var(--p-breadcrumb-item-margin) + var(--p-breadcrumb-item-padding));
}
:deep(.p-breadcrumb-item:hover) {
@apply rounded-lg;
border-color: var(--interface-stroke);
:deep(.p-breadcrumb-separator),
:deep(.p-breadcrumb-item) {
@apply h-12;
border-top: 1px solid var(--interface-stroke);
border-bottom: 1px solid var(--interface-stroke);
background-color: var(--comfy-menu-bg);
}
@@ -265,8 +218,10 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item:first-child) {
@apply rounded-l-lg;
/* Then collapse the root workflow */
flex-shrink: 5000;
border-left: 1px solid var(--interface-stroke);
.p-breadcrumb-item-link {
padding-left: var(--p-breadcrumb-item-padding);
@@ -274,10 +229,13 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item:last-child) {
@apply rounded-r-lg;
/* Then collapse the active item */
flex-shrink: 1;
border-right: 1px solid var(--interface-stroke);
}
:deep(.p-breadcrumb-item-link:hover),
:deep(.p-breadcrumb-item-link-menu-visible) {
background-color: color-mix(
in srgb,

View File

@@ -7,7 +7,7 @@
}"
draggable="false"
href="#"
class="p-breadcrumb-item-link h-8 cursor-pointer px-2"
class="p-breadcrumb-item-link h-12 cursor-pointer px-2"
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
@@ -25,7 +25,7 @@
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
<Menu
v-if="isActive || isRoot"
v-if="isActive"
ref="menu"
:model="menuItems"
:popup="true"
@@ -59,7 +59,6 @@ import Tag from 'primevue/tag'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
ComfyWorkflow,
@@ -136,28 +135,79 @@ const tooltipText = computed(() => {
return props.item.label
})
const startRename = async () => {
// Check if element is hidden (collapsed breadcrumb)
// When collapsed, root item is hidden via CSS display:none, so use rename command
if (isRoot && wrapperRef.value?.offsetParent === null) {
await useCommandStore().execute('Comfy.RenameWorkflow')
return
}
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
const menuItems = computed<MenuItem[]>(() => {
return [
{
label: t('g.rename'),
icon: 'pi pi-pencil',
command: startRename
},
{
label: t('breadcrumbsMenu.duplicate'),
icon: 'pi pi-copy',
command: async () => {
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
},
visible: isRoot && !props.item.isBlueprint
},
{
separator: true,
visible: isRoot
},
{
label: t('menuLabels.Save'),
icon: 'pi pi-save',
command: async () => {
await useCommandStore().execute('Comfy.SaveWorkflow')
},
visible: isRoot
},
{
label: t('menuLabels.Save As'),
icon: 'pi pi-save',
command: async () => {
await useCommandStore().execute('Comfy.SaveWorkflowAs')
},
visible: isRoot
},
{
separator: true
},
{
label: t('breadcrumbsMenu.clearWorkflow'),
icon: 'pi pi-trash',
command: async () => {
await useCommandStore().execute('Comfy.ClearWorkflow')
}
},
{
separator: true,
visible: props.item.key === 'root' && props.item.isBlueprint
},
{
label: t('subgraphStore.publish'),
icon: 'pi pi-copy',
command: async () => {
await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root' && props.item.isBlueprint
},
{
separator: true,
visible: isRoot
},
{
label: props.item.isBlueprint
? t('breadcrumbsMenu.deleteBlueprint')
: t('breadcrumbsMenu.deleteWorkflow'),
icon: 'pi pi-times',
command: async () => {
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
},
visible: isRoot
}
})
}
const { menuItems } = useWorkflowActionsMenu(startRename, { isRoot })
]
})
const handleClick = (event: MouseEvent) => {
if (isEditing.value) {
@@ -178,6 +228,20 @@ const handleClick = (event: MouseEvent) => {
}
}
const startRename = () => {
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
})
}
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
@@ -185,14 +249,6 @@ const inputBlur = async (doRename: boolean) => {
isEditing.value = false
}
const toggleMenu = (event: MouseEvent) => {
menu.value?.toggle(event)
}
defineExpose({
toggleMenu
})
</script>
<style scoped>

View File

@@ -2,8 +2,8 @@
<div
:class="
cn(
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer bg-secondary-background',
backgroundClass
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer',
backgroundClass || 'bg-secondary-background'
)
"
>

View File

@@ -1,11 +1,6 @@
<template>
<div class="relative inline-flex items-center">
<Button
size="icon"
variant="secondary"
v-bind="$attrs"
@click="popover?.toggle"
>
<Button size="icon" variant="secondary" @click="popover?.toggle">
<i
:class="
cn(
@@ -65,10 +60,6 @@ import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
interface MoreButtonProps {
isVertical?: boolean
}

View File

@@ -1,65 +0,0 @@
<template>
<span class="relative inline-flex items-center justify-center size-[1em]">
<i :class="mainIcon" class="text-[1em]" />
<i
:class="
cn(
subIcon,
'absolute leading-none pointer-events-none',
positionX === 'left' ? 'left-0' : 'right-0',
positionY === 'top' ? 'top-0' : 'bottom-0'
)
"
:style="subIconStyle"
/>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
type Position = 'top' | 'bottom' | 'left' | 'right'
export interface OverlayIconProps {
mainIcon: string
subIcon: string
positionX?: Position
positionY?: Position
offsetX?: number
offsetY?: number
subIconScale?: number
}
const {
mainIcon,
subIcon,
positionX = 'right',
positionY = 'bottom',
offsetX = 0,
offsetY = 0,
subIconScale = 0.6
} = defineProps<OverlayIconProps>()
const textShadow = [
`-1px -1px 0 rgba(0, 0, 0, 0.7)`,
`1px -1px 0 rgba(0, 0, 0, 0.7)`,
`-1px 1px 0 rgba(0, 0, 0, 0.7)`,
`1px 1px 0 rgba(0, 0, 0, 0.7)`,
`-1px 0 0 rgba(0, 0, 0, 0.7)`,
`1px 0 0 rgba(0, 0, 0, 0.7)`,
`0 -1px 0 rgba(0, 0, 0, 0.7)`,
`0 1px 0 rgba(0, 0, 0, 0.7)`
].join(', ')
const subIconStyle = computed(() => ({
fontSize: `${subIconScale}em`,
textShadow,
...(offsetX !== 0 && {
[positionX === 'left' ? 'left' : 'right']: `${offsetX}px`
}),
...(offsetY !== 0 && {
[positionY === 'top' ? 'top' : 'bottom']: `${offsetY}px`
})
}))
</script>

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
const { label, severity = 'default' } = defineProps<{
label: string
severity?: Severity
}>()
function badgeClasses(sev: Severity): string {
const baseClasses =
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
switch (sev) {
case 'danger':
return `${baseClasses} bg-destructive-background text-white`
case 'contrast':
return `${baseClasses} bg-base-foreground text-base-background`
case 'warn':
return `${baseClasses} bg-warning-background text-base-background`
case 'secondary':
return `${baseClasses} bg-secondary-background text-base-foreground`
default:
return `${baseClasses} bg-primary-background text-base-foreground`
}
}
</script>
<template>
<span :class="badgeClasses(severity)">{{ label }}</span>
</template>

View File

@@ -563,8 +563,7 @@ const {
availableRunsOn,
filteredCount,
totalCount,
resetFilters,
loadFuseOptions
resetFilters
} = useTemplateFiltering(navigationFilteredTemplates)
/**
@@ -816,10 +815,10 @@ const pageTitle = computed(() => {
// Initialize templates loading with useAsyncState
const { isLoading } = useAsyncState(
async () => {
// Run all operations in parallel for better performance
await Promise.all([
loadTemplates(),
workflowTemplatesStore.loadWorkflowTemplates(),
loadFuseOptions()
workflowTemplatesStore.loadWorkflowTemplates()
])
return true
},

View File

@@ -55,4 +55,17 @@ const dialogStore = useDialogStore()
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;
max-height: 1026px;
}
@media (min-width: 3000px) {
.manager-dialog {
max-width: 2200px;
max-height: 1320px;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex w-full items-center justify-between p-4">
<div class="flex items-center gap-2">
<i class="icon-[lucide--triangle-alert] text-warning-background"></i>
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
<p class="m-0 text-sm">
{{
isCloud

View File

@@ -1,264 +1,180 @@
<template>
<div
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<div class="flex w-112 flex-col gap-8 p-8">
<!-- Header -->
<div class="flex py-8 items-center justify-between px-8">
<h2 class="text-lg font-bold text-base-foreground m-0">
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold text-base-foreground m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
@click="() => handleClose()"
>
<i class="icon-[lucide--x] size-6" />
</button>
</div>
<p
v-if="isInsufficientCredits"
class="text-sm text-muted-foreground m-0 px-8"
>
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
<!-- Preset amount buttons -->
<div class="px-8">
<h3 class="m-0 text-sm font-normal text-muted-foreground">
{{ $t('credits.topUp.selectAmount') }}
</h3>
<div class="flex gap-2 pt-3">
<Button
v-for="amount in PRESET_AMOUNTS"
:key="amount"
:autofocus="amount === 50"
variant="secondary"
size="lg"
:class="
cn(
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
selectedPreset === amount && 'bg-secondary-background-selected'
)
"
@click="handlePresetClick(amount)"
>
${{ amount }}
</Button>
</h1>
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0 w-96">
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
</div>
</div>
<!-- Amount (USD) / Credits -->
<div class="flex gap-2 px-8 pt-8">
<!-- You Pay -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youPay') }}
</div>
<FormattedNumberStepper
:model-value="payAmount"
:min="0"
:max="MAX_AMOUNT"
:step="getStepAmount"
@update:model-value="handlePayAmountChange"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<span class="shrink-0 text-base font-semibold text-base-foreground"
>$</span
>
</template>
</FormattedNumberStepper>
</div>
<!-- You Get -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youGet') }}
</div>
<FormattedNumberStepper
v-model="creditsModel"
:min="0"
:max="usdToCredits(MAX_AMOUNT)"
:step="getCreditsStepAmount"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
<div v-else class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>
</div>
<!-- Warnings -->
<!-- Current Balance Section -->
<div class="flex flex-col gap-4">
<div class="flex items-baseline gap-2">
<UserCredit text-class="text-3xl font-bold" show-credits-only />
<span class="text-sm text-muted-foreground">{{
$t('credits.creditsAvailable')
}}</span>
</div>
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
</div>
</div>
<p
v-if="isBelowMin"
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
})
}}
</p>
<p
v-if="showCeilingWarning"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))
})
}}
<span>{{ $t('credits.topUp.needMore') }}</span>
<a
href="https://www.comfy.org/cloud/enterprise"
target="_blank"
class="ml-1 text-inherit"
>{{ $t('credits.topUp.contactUs') }}</a
>
</p>
<!-- Credit Options Section -->
<div class="flex flex-col gap-4">
<span class="text-sm text-muted-foreground">
{{ $t('credits.topUp.howManyCredits') }}
</span>
<div class="flex flex-col gap-2">
<CreditTopUpOption
v-for="option in creditOptions"
:key="option.credits"
:credits="option.credits"
:description="option.description"
:selected="selectedCredits === option.credits"
@select="selectedCredits = option.credits"
/>
</div>
<div class="flex flex-row items-center gap-2 group pt-2">
<i
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
/>
<span
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
@click="togglePopover"
>
{{ t('subscription.videoTemplateBasedCredits') }}
</span>
</div>
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
<!-- Buy Button -->
<Button
:disabled="!isValidAmount || loading"
:disabled="!selectedCredits || loading"
:loading="loading"
variant="primary"
size="lg"
class="h-10 justify-center"
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
@click="handleBuy"
>
{{ $t('credits.topUp.buyCredits') }}
{{ $t('credits.topUp.buy') }}
</Button>
<div class="flex items-center justify-center gap-1">
</div>
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class:
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
}
}"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground leading-normal">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a
:href="pricingUrl"
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
target="_blank"
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
>
{{ $t('credits.topUp.viewPricing') }}
<i class="icon-[lucide--external-link] size-4" />
<span class="underline">
{{ t('subscription.videoEstimateTryTemplate') }}
</span>
<span class="no-underline" v-html="'&rarr;'"></span>
</a>
</div>
</div>
</Popover>
</div>
</template>
<script setup lang="ts">
import { Popover } from 'primevue'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import { creditsToUsd } from '@/base/credits/comfyCredits'
import UserCredit from '@/components/common/UserCredit.vue'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
interface CreditOption {
credits: number
description: string
}
const { isInsufficientCredits = false } = defineProps<{
isInsufficientCredits?: boolean
}>()
const { formattedRenewalDate } = useSubscription()
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { isSubscriptionEnabled } = useSubscription()
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
const MAX_AMOUNT = 10000
// State
const selectedPreset = ref<number | null>(50)
const payAmount = ref(50)
const showCeilingWarning = ref(false)
const selectedCredits = ref<number | null>(null)
const loading = ref(false)
// Computed
const pricingUrl = computed(() =>
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
)
const popover = ref()
const creditsModel = computed({
get: () => usdToCredits(payAmount.value),
set: (newCredits: number) => {
payAmount.value = Math.round(creditsToUsd(newCredits))
selectedPreset.value = null
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
const creditOptions: CreditOption[] = [
{
credits: 1055, // $5.00
description: t('credits.topUp.videosEstimate', { count: 30 })
},
{
credits: 2110, // $10.00
description: t('credits.topUp.videosEstimate', { count: 60 })
},
{
credits: 4220, // $20.00
description: t('credits.topUp.videosEstimate', { count: 120 })
},
{
credits: 10550, // $50.00
description: t('credits.topUp.videosEstimate', { count: 301 })
}
})
]
const isValidAmount = computed(
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
)
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
// Utility functions
function formatNumber(num: number): string {
return num.toLocaleString('en-US')
}
// Step amount functions
function getStepAmount(currentAmount: number): number {
if (currentAmount < 100) return 5
if (currentAmount < 1000) return 50
return 100
}
function getCreditsStepAmount(currentCredits: number): number {
const usdAmount = creditsToUsd(currentCredits)
return usdToCredits(getStepAmount(usdAmount))
}
// Event handlers
function handlePayAmountChange(value: number) {
payAmount.value = value
selectedPreset.value = null
showCeilingWarning.value = false
}
function handlePresetClick(amount: number) {
showCeilingWarning.value = false
payAmount.value = amount
selectedPreset.value = amount
}
function handleClose(clearTracking = true) {
if (clearTracking) {
clearTopupTracking()
}
dialogStore.closeDialog({ key: 'top-up-credits' })
}
async function handleBuy() {
// Prevent double-clicks
if (loading.value || !isValidAmount.value) return
const handleBuy = async () => {
if (!selectedCredits.value) return
loading.value = true
try {
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
await authActions.purchaseCredits(payAmount.value)
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
handleClose(false)
dialogService.showSettingsDialog(
isSubscriptionEnabled() ? 'subscription' : 'credits'
)
const usdAmount = creditsToUsd(selectedCredits.value)
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
await authActions.purchaseCredits(usdAmount)
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -7,7 +7,7 @@
pt:text="w-full"
>
<div class="flex items-center justify-between">
<div data-testid="current-user-indicator">
<div>
{{ $t('g.currentUser') }}: {{ userStore.currentUser?.username }}
</div>
<Button

View File

@@ -220,12 +220,6 @@ function show(event: MouseEvent) {
y: screenY / scale - offset[1]
}
// Initialize last* values to current transform to prevent updateMenuPosition
// from overwriting PrimeVue's flip-adjusted position on the first RAF tick
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
isOpen.value = true
contextMenu.value?.show(event)
}

View File

@@ -1,134 +0,0 @@
<template>
<!-- Help Center Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<div
v-if="isHelpCenterVisible"
class="help-center-popup"
:class="{
'sidebar-left':
triggerLocation === 'sidebar' && sidebarLocation === 'left',
'sidebar-right':
triggerLocation === 'sidebar' && sidebarLocation === 'right',
'topbar-right': triggerLocation === 'topbar',
'small-sidebar': isSmall
}"
>
<HelpCenterMenuContent @close="closeHelpCenter" />
</div>
</Teleport>
<!-- Release Notification Toast positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<ReleaseNotificationToast
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
/>
</Teleport>
<!-- WhatsNew Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<WhatsNewPopup
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
@whats-new-dismissed="handleWhatsNewDismissed"
/>
</Teleport>
<!-- Backdrop to close popup when clicking outside -->
<Teleport to="body">
<div
v-if="isHelpCenterVisible"
class="help-center-backdrop"
@click="closeHelpCenter"
/>
</Teleport>
</template>
<script setup lang="ts">
import { useHelpCenter } from '@/composables/useHelpCenter'
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
const { isSmall = false } = defineProps<{
isSmall?: boolean
}>()
const {
isHelpCenterVisible,
triggerLocation,
sidebarLocation,
closeHelpCenter,
handleWhatsNewDismissed
} = useHelpCenter()
</script>
<style scoped>
.help-center-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: transparent;
}
.help-center-popup {
position: absolute;
bottom: 1rem;
z-index: 10000;
animation: slideInUp 0.2s ease-out;
pointer-events: auto;
}
.help-center-popup.sidebar-left {
left: 1rem;
}
.help-center-popup.sidebar-left.small-sidebar {
left: 1rem;
}
.help-center-popup.sidebar-right {
right: 1rem;
}
.help-center-popup.topbar-right {
top: 2rem;
right: 1rem;
bottom: auto;
animation: slideInDown 0.2s ease-out;
}
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -1,293 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
import Button from '@/components/ui/button/Button.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
import HoneyToast from './HoneyToast.vue'
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
return {
taskId: 'task-1',
assetId: 'asset-1',
assetName: 'model-v1.safetensors',
bytesTotal: 1000000,
bytesDownloaded: 0,
progress: 0,
status: 'created',
lastUpdate: Date.now(),
...overrides
}
}
const meta: Meta<typeof HoneyToast> = {
title: 'Toast/HoneyToast',
component: HoneyToast,
parameters: {
layout: 'fullscreen'
},
decorators: [
() => ({
template: '<div class="h-screen bg-base-background p-8"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(false)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'completed',
progress: 1
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'running',
progress: 0.45
}),
createMockJob({
taskId: 'task-3',
assetName: 'vae-decoder.safetensors',
status: 'created'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 of 3</span>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Expanded: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(true)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'completed',
progress: 1
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'running',
progress: 0.45
}),
createMockJob({
taskId: 'task-3',
assetName: 'vae-decoder.safetensors',
status: 'created'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 of 3</span>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Completed: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(false)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
bytesDownloaded: 1000000,
progress: 1,
status: 'completed'
}),
createMockJob({
taskId: 'task-2',
assetId: 'asset-2',
assetName: 'lora-style.safetensors',
bytesTotal: 500000,
bytesDownloaded: 500000,
progress: 1,
status: 'completed'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
<span class="font-bold text-base-foreground">All downloads completed</span>
</div>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
<Button variant="muted-textonly" size="icon">
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const WithError: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(true)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'failed',
progress: 0.23
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'completed',
progress: 1
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--circle-alert] size-4 text-destructive-background" />
<span class="font-bold text-base-foreground">1 download failed</span>
</div>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
<Button variant="muted-textonly" size="icon">
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Hidden: Story = {
render: () => ({
components: { HoneyToast },
template: `
<div>
<p class="text-base-foreground">HoneyToast is hidden when visible=false. Nothing appears at the bottom.</p>
<HoneyToast :visible="false">
<template #default>
<div class="px-4 py-4">Content</div>
</template>
<template #footer>
<div class="h-12 px-4">Footer</div>
</template>
</HoneyToast>
</div>
`
})
}

View File

@@ -1,137 +0,0 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
import HoneyToast from './HoneyToast.vue'
describe('HoneyToast', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
})
function mountComponent(
props: { visible: boolean; expanded?: boolean } = { visible: true }
): VueWrapper {
return mount(HoneyToast, {
props,
slots: {
default: (slotProps: { isExpanded: boolean }) =>
h(
'div',
{ 'data-testid': 'content' },
slotProps.isExpanded ? 'expanded' : 'collapsed'
),
footer: (slotProps: { isExpanded: boolean; toggle: () => void }) =>
h(
'button',
{
'data-testid': 'toggle-btn',
onClick: slotProps.toggle
},
slotProps.isExpanded ? 'Collapse' : 'Expand'
)
},
attachTo: document.body
})
}
it('renders when visible is true', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeTruthy()
wrapper.unmount()
})
it('does not render when visible is false', async () => {
const wrapper = mountComponent({ visible: false })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeFalsy()
wrapper.unmount()
})
it('passes is-expanded=false to slots by default', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
wrapper.unmount()
})
it('applies collapsed max-height class when collapsed', async () => {
const wrapper = mountComponent({ visible: true, expanded: false })
await nextTick()
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
wrapper.unmount()
})
it('has aria-live="polite" for accessibility', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast?.getAttribute('aria-live')).toBe('polite')
wrapper.unmount()
})
it('supports v-model:expanded with reactive parent state', async () => {
const TestWrapper = defineComponent({
components: { HoneyToast },
setup() {
const expanded = ref(false)
return { expanded }
},
template: `
<HoneyToast :visible="true" v-model:expanded="expanded">
<template #default="slotProps">
<div data-testid="content">{{ slotProps.isExpanded ? 'expanded' : 'collapsed' }}</div>
</template>
<template #footer="slotProps">
<button data-testid="toggle-btn" @click="slotProps.toggle">
{{ slotProps.isExpanded ? 'Collapse' : 'Expand' }}
</button>
</template>
</HoneyToast>
`
})
const wrapper = mount(TestWrapper, { attachTo: document.body })
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
const toggleBtn = document.body.querySelector(
'[data-testid="toggle-btn"]'
) as HTMLButtonElement
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
toggleBtn?.click()
await nextTick()
expect(content?.textContent).toBe('expanded')
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
wrapper.unmount()
})
})

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const { visible } = defineProps<{
visible: boolean
}>()
const isExpanded = defineModel<boolean>('expanded', { default: false })
function toggle() {
isExpanded.value = !isExpanded.value
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="translate-y-full opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-full opacity-0"
>
<div
v-if="visible"
role="status"
aria-live="polite"
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
>
<div
:class="
cn(
'overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-[400px]' : 'max-h-0'
)
"
>
<slot :is-expanded />
</div>
<slot name="footer" :is-expanded :toggle />
</div>
</Transition>
</Teleport>
</template>

View File

@@ -160,7 +160,7 @@
>
<i
v-if="slotProps.selected"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
class="text-bold icon-[lucide--check] text-xs text-white"
/>
</div>
<span>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-backdrop/30"
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-smoke-700/30"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
@@ -14,12 +14,12 @@
class="rounded-full"
@click="toggleMenu"
>
<i class="pi pi-bars text-lg text-base-foreground" />
<i class="pi pi-bars text-lg text-white" />
</Button>
<div
v-show="isMenuOpen"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
>
<div class="flex flex-col">
<Button
@@ -29,13 +29,13 @@
:class="
cn(
'flex w-full items-center justify-start',
activeCategory === category && 'bg-button-active-surface'
activeCategory === category && 'bg-smoke-600'
)
"
@click="selectCategory(category)"
>
<i :class="getCategoryIcon(category)" />
<span class="whitespace-nowrap text-base-foreground">{{
<span class="whitespace-nowrap text-white">{{
$t(categoryLabels[category])
}}</span>
</Button>
@@ -169,7 +169,7 @@ const getCategoryIcon = (category: string) => {
export: 'pi pi-download'
}
// @ts-expect-error fixme ts strict error
return `${icons[category]} text-base-foreground text-lg`
return `${icons[category]} text-white text-lg`
}
const emit = defineEmits<{

View File

@@ -2,11 +2,11 @@
<Transition name="fade">
<div
v-if="loading"
class="absolute inset-0 z-50 flex items-center justify-center bg-backdrop/50"
class="bg-opacity-50 absolute inset-0 z-50 flex items-center justify-center bg-black"
>
<div class="flex flex-col items-center">
<div class="spinner" />
<div class="mt-4 text-lg text-base-foreground">
<div class="mt-4 text-lg text-white">
{{ loadingMessage }}
</div>
</div>

View File

@@ -15,7 +15,7 @@
:class="[
'pi',
playing ? 'pi-pause' : 'pi-play',
'text-lg text-base-foreground'
'text-lg text-white'
]"
/>
</Button>
@@ -46,7 +46,7 @@
class="flex-1"
@update:model-value="handleSliderChange"
/>
<span class="min-w-16 text-xs text-base-foreground">
<span class="min-w-16 text-xs text-white">
{{ formatTime(currentTime) }} / {{ formatTime(animationDuration) }}
</span>
</div>

View File

@@ -11,7 +11,7 @@
:aria-label="$t('load3d.switchCamera')"
@click="switchCamera"
>
<i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
<i :class="['pi', 'pi-camera', 'text-lg text-white']" />
</Button>
<PopupSlider
v-if="showFOVButton"

View File

@@ -12,18 +12,18 @@
:aria-label="$t('load3d.exportModel')"
@click="toggleExportFormats"
>
<i class="pi pi-download text-lg text-base-foreground" />
<i class="pi pi-download text-lg text-white" />
</Button>
<div
v-show="showExportFormats"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="format in exportFormats"
:key="format.value"
variant="textonly"
class="text-base-foreground"
class="text-white"
@click="exportModel(format.value)"
>
{{ format.label }}

View File

@@ -12,7 +12,7 @@
:aria-label="$t('load3d.lightIntensity')"
@click="toggleLightIntensity"
>
<i class="pi pi-sun text-lg text-base-foreground" />
<i class="pi pi-sun text-lg text-white" />
</Button>
<div
v-show="showLightIntensity"

View File

@@ -12,11 +12,11 @@
:aria-label="t('load3d.upDirection')"
@click="toggleUpDirection"
>
<i class="pi pi-arrow-up text-lg text-base-foreground" />
<i class="pi pi-arrow-up text-lg text-white" />
</Button>
<div
v-show="showUpDirection"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
>
<div class="flex flex-col">
<Button
@@ -24,10 +24,7 @@
:key="direction"
variant="textonly"
:class="
cn(
'text-base-foreground',
upDirection === direction && 'bg-blue-500'
)
cn('text-white', upDirection === direction && 'bg-blue-500')
"
@click="selectUpDirection(direction)"
>
@@ -49,11 +46,11 @@
:aria-label="t('load3d.materialMode')"
@click="toggleMaterialMode"
>
<i class="pi pi-box text-lg text-base-foreground" />
<i class="pi pi-box text-lg text-white" />
</Button>
<div
v-show="showMaterialMode"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg"
>
<div class="flex flex-col">
<Button
@@ -62,7 +59,7 @@
variant="textonly"
:class="
cn(
'whitespace-nowrap text-base-foreground',
'whitespace-nowrap text-white',
materialMode === mode && 'bg-blue-500'
)
"
@@ -86,7 +83,7 @@
:aria-label="t('load3d.showSkeleton')"
@click="showSkeleton = !showSkeleton"
>
<i class="pi pi-sitemap text-lg text-base-foreground" />
<i class="pi pi-sitemap text-lg text-white" />
</Button>
</div>
</div>

View File

@@ -8,11 +8,11 @@
:aria-label="tooltipText"
@click="toggleSlider"
>
<i :class="['pi', icon, 'text-lg text-base-foreground']" />
<i :class="['pi', icon, 'text-lg text-white']" />
</Button>
<div
v-show="showSlider"
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface p-4 shadow-lg w-[150px]"
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg w-[150px]"
>
<Slider
v-model="value"

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative rounded-lg bg-backdrop/30">
<div class="relative rounded-lg bg-smoke-700/30">
<div class="flex flex-col gap-2">
<Button
v-tooltip.right="{
@@ -25,7 +25,7 @@
:class="[
'pi',
isRecording ? 'pi-circle-fill' : 'pi-video',
'text-lg text-base-foreground'
'text-lg text-white'
]"
/>
</Button>
@@ -42,7 +42,7 @@
:aria-label="$t('load3d.exportRecording')"
@click="handleExportRecording"
>
<i class="pi pi-download text-lg text-base-foreground" />
<i class="pi pi-download text-lg text-white" />
</Button>
<Button
@@ -57,12 +57,12 @@
:aria-label="$t('load3d.clearRecording')"
@click="handleClearRecording"
>
<i class="pi pi-trash text-lg text-base-foreground" />
<i class="pi pi-trash text-lg text-white" />
</Button>
<div
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
class="mt-1 text-center text-xs text-base-foreground"
class="mt-1 text-center text-xs text-white"
>
{{ formatDuration(recordingDuration) }}
</div>

View File

@@ -8,7 +8,7 @@
:aria-label="$t('load3d.showGrid')"
@click="toggleGrid"
>
<i class="pi pi-table text-lg text-base-foreground" />
<i class="pi pi-table text-lg text-white" />
</Button>
<div v-if="!hasBackgroundImage">
@@ -23,7 +23,7 @@
:aria-label="$t('load3d.backgroundColor')"
@click="openColorPicker"
>
<i class="pi pi-palette text-lg text-base-foreground" />
<i class="pi pi-palette text-lg text-white" />
<input
ref="colorPickerRef"
type="color"
@@ -48,7 +48,7 @@
:aria-label="$t('load3d.uploadBackgroundImage')"
@click="openImagePicker"
>
<i class="pi pi-image text-lg text-base-foreground" />
<i class="pi pi-image text-lg text-white" />
<input
ref="imagePickerRef"
type="file"
@@ -76,7 +76,7 @@
:aria-label="$t('load3d.panoramaMode')"
@click="toggleBackgroundRenderMode"
>
<i class="pi pi-globe text-lg text-base-foreground" />
<i class="pi pi-globe text-lg text-white" />
</Button>
</div>
@@ -98,7 +98,7 @@
:aria-label="$t('load3d.removeBackgroundImage')"
@click="removeBackgroundImage"
>
<i class="pi pi-times text-lg text-base-foreground" />
<i class="pi pi-times text-lg text-white" />
</Button>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative rounded-lg bg-backdrop/30">
<div class="relative rounded-lg bg-smoke-700/30">
<div class="flex flex-col gap-2">
<Button
v-tooltip.right="{
@@ -12,7 +12,7 @@
:aria-label="t('load3d.openIn3DViewer')"
@click="openIn3DViewer"
>
<i class="pi pi-expand text-lg text-base-foreground" />
<i class="pi pi-expand text-lg text-white" />
</Button>
</div>
</div>

View File

@@ -4,7 +4,6 @@
class="maskEditor-dialog-root flex h-full w-full flex-col"
@contextmenu.prevent
@dragstart="handleDragStart"
@keydown.stop
>
<div
id="maskEditorCanvasContainer"

View File

@@ -11,7 +11,7 @@
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-current"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
@@ -35,74 +35,6 @@
</svg>
</button>
<div class="h-5 border-l border-border" />
<button
:class="iconButtonClass"
:title="t('maskEditor.rotateLeft')"
@click="onRotateLeft"
>
<svg
viewBox="-6 -7 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
/>
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.rotateRight')"
@click="onRotateRight"
>
<svg
viewBox="-9 -7 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<g transform="scale(-1, 1)">
<path
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
/>
</g>
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.mirrorHorizontal')"
@click="onMirrorHorizontal"
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="M7.5,1.5c-.28,0-.5.22-.5.5v11c0,.28.22.5.5.5s.5-.22.5-.5v-11c0-.28-.22-.5-.5-.5Z"
/>
<path d="M3.5,4.5l-2,3,2,3v-6ZM11.5,4.5v6l2-3-2-3Z" />
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.mirrorVertical')"
@click="onMirrorVertical"
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="M2,7.5c0-.28.22-.5.5-.5h11c.28,0,.5.22.5.5s-.22.5-.5.5h-11c-.28,0-.5-.22-.5-.5Z"
/>
<path d="M4.5,4.5l3-2,3,2h-6ZM4.5,10.5h6l-3,2-3-2Z" />
</svg>
</button>
<div class="h-5 w-px bg-[var(--p-form-field-border-color)]" />
<button :class="textButtonClass" @click="onInvert">
{{ t('maskEditor.invert') }}
</button>
@@ -131,7 +63,6 @@ import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
import { t } from '@/i18n'
import { useDialogStore } from '@/stores/dialogStore'
@@ -140,17 +71,16 @@ import { useMaskEditorStore } from '@/stores/maskEditorStore'
const store = useMaskEditorStore()
const dialogStore = useDialogStore()
const canvasTools = useCanvasTools()
const canvasTransform = useCanvasTransform()
const saver = useMaskEditorSaver()
const saveButtonText = ref(t('g.save'))
const saveEnabled = ref(true)
const iconButtonClass =
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-border-default pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
const textButtonClass =
'h-7.5 w-15 rounded-[10px] border border-border-default text-current font-sans pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
'h-7.5 w-15 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
const onUndo = () => {
store.canvasHistory.undo()
@@ -160,38 +90,6 @@ const onRedo = () => {
store.canvasHistory.redo()
}
const onRotateLeft = async () => {
try {
await canvasTransform.rotateCounterclockwise()
} catch (error) {
console.error('[TopBarHeader] Rotate left failed:', error)
}
}
const onRotateRight = async () => {
try {
await canvasTransform.rotateClockwise()
} catch (error) {
console.error('[TopBarHeader] Rotate right failed:', error)
}
}
const onMirrorHorizontal = async () => {
try {
await canvasTransform.mirrorHorizontal()
} catch (error) {
console.error('[TopBarHeader] Mirror horizontal failed:', error)
}
}
const onMirrorVertical = async () => {
try {
await canvasTransform.mirrorVertical()
} catch (error) {
console.error('[TopBarHeader] Mirror vertical failed:', error)
}
}
const onInvert = () => {
canvasTools.invertMask()
}

View File

@@ -1,19 +0,0 @@
<script>
import Select from 'primevue/select'
export default {
name: 'SelectPlus',
extends: Select,
emits: ['hide'],
methods: {
onOverlayLeave() {
this.unbindOutsideClickListener()
this.unbindScrollListener()
this.unbindResizeListener()
this.$emit('hide')
this.overlay = null
}
}
}
</script>

View File

@@ -262,7 +262,7 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
await openResultGallery(item)
openResultGallery(item)
await focusAssetInSidebar(item)
}
)

View File

@@ -1,9 +1,6 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type {
JobListItem,
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import type { TaskStatus } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
@@ -40,86 +37,91 @@ function resetStores() {
exec.nodeProgressStatesByPrompt = {}
}
function makeTask(
id: string,
priority: number,
fields: Partial<JobListItem> & { status: JobStatus; create_time: number }
): TaskItemImpl {
const job: JobListItem = {
id,
priority,
last_state_update: null,
update_time: fields.create_time,
...fields
}
return new TaskItemImpl(job)
}
function makePendingTask(
id: string,
priority: number,
createTimeMs: number
index: number,
createTimeMs?: number
): TaskItemImpl {
return makeTask(id, priority, {
status: 'pending',
create_time: createTimeMs
})
const extraData = {
client_id: 'c1',
...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {})
}
return new TaskItemImpl('Pending', [index, id, {}, extraData, []])
}
function makeRunningTask(
id: string,
priority: number,
createTimeMs: number
index: number,
createTimeMs?: number
): TaskItemImpl {
return makeTask(id, priority, {
status: 'in_progress',
create_time: createTimeMs
})
const extraData = {
client_id: 'c1',
...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {})
}
return new TaskItemImpl('Running', [index, id, {}, extraData, []])
}
function makeRunningTaskWithStart(
id: string,
priority: number,
index: number,
startedSecondsAgo: number
): TaskItemImpl {
const start = Date.now() - startedSecondsAgo * 1000
return makeTask(id, priority, {
status: 'in_progress',
create_time: start - 5000,
update_time: start
})
const status: TaskStatus = {
status_str: 'success',
completed: false,
messages: [['execution_start', { prompt_id: id, timestamp: start } as any]]
}
return new TaskItemImpl(
'Running',
[index, id, {}, { client_id: 'c1', create_time: start - 5000 }, []],
status
)
}
function makeHistoryTask(
id: string,
priority: number,
index: number,
durationSec: number,
ok: boolean,
errorMessage?: string
): TaskItemImpl {
const now = Date.now()
const executionEndTime = now
const executionStartTime = now - durationSec * 1000
return makeTask(id, priority, {
status: ok ? 'completed' : 'failed',
create_time: executionStartTime - 5000,
update_time: now,
execution_start_time: executionStartTime,
execution_end_time: executionEndTime,
execution_error: errorMessage
? {
prompt_id: id,
timestamp: now,
node_id: '1',
node_type: 'ExampleNode',
exception_message: errorMessage,
exception_type: 'RuntimeError',
traceback: [],
current_inputs: {},
current_outputs: {}
}
: undefined
})
const start = Date.now() - durationSec * 1000 - 1000
const end = start + durationSec * 1000
const messages: TaskStatus['messages'] = ok
? [
['execution_start', { prompt_id: id, timestamp: start } as any],
['execution_success', { prompt_id: id, timestamp: end } as any]
]
: [
['execution_start', { prompt_id: id, timestamp: start } as any],
[
'execution_error',
{
prompt_id: id,
timestamp: end,
node_id: '1',
node_type: 'Node',
executed: [],
exception_message:
errorMessage || 'Demo error: Node failed during execution',
exception_type: 'RuntimeError',
traceback: [],
current_inputs: {},
current_outputs: {}
} as any
]
]
const status: TaskStatus = {
status_str: ok ? 'success' : 'error',
completed: true,
messages
}
return new TaskItemImpl(
'History',
[index, id, {}, { client_id: 'c1', create_time: start }, []],
status
)
}
export const Queued: Story = {
@@ -138,12 +140,8 @@ export const Queued: Story = {
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
]
// Add some other pending jobs to give context
queue.pendingTasks.push(
makePendingTask('job-older-1', 100, Date.now() - 60_000)
)
queue.pendingTasks.push(
makePendingTask('job-older-2', 101, Date.now() - 30_000)
)
queue.pendingTasks.push(makePendingTask('job-older-1', 100))
queue.pendingTasks.push(makePendingTask('job-older-2', 101))
// Queued at (in metadata on prompt[4])

View File

@@ -4,7 +4,6 @@ import { defineComponent, nextTick } from 'vue'
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { TaskItemImpl } from '@/stores/queueStore'
const QueueJobItemStub = defineComponent({
name: 'QueueJobItemStub',
@@ -26,25 +25,20 @@ const QueueJobItemStub = defineComponent({
template: '<div class="queue-job-item-stub"></div>'
})
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
const { taskRef, ...rest } = overrides
return {
id: 'job-id',
title: 'Example job',
meta: 'Meta text',
state: 'running',
iconName: 'icon',
iconImageUrl: 'https://example.com/icon.png',
showClear: true,
taskRef: (taskRef ?? {
workflow: { id: 'workflow-id' }
}) as TaskItemImpl,
progressTotalPercent: 60,
progressCurrentPercent: 30,
runningNodeName: 'Node A',
...rest
}
}
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-id',
title: 'Example job',
meta: 'Meta text',
state: 'running',
iconName: 'icon',
iconImageUrl: 'https://example.com/icon.png',
showClear: true,
taskRef: { workflow: { id: 'workflow-id' } },
progressTotalPercent: 60,
progressCurrentPercent: 30,
runningNodeName: 'Node A',
...overrides
})
const mountComponent = (groups: JobGroup[]) =>
mount(JobGroupsList, {

View File

@@ -12,7 +12,7 @@
v-for="ji in group.items"
:key="ji.id"
:job-id="ji.id"
:workflow-id="ji.taskRef?.workflowId"
:workflow-id="ji.taskRef?.workflow?.id"
:state="ji.state"
:title="ji.title"
:right-text="ji.meta"

View File

@@ -50,22 +50,20 @@
<div
v-if="
props.state === 'running' &&
hasAnyProgressPercent(
props.progressTotalPercent,
props.progressCurrentPercent
)
(props.progressTotalPercent !== undefined ||
props.progressCurrentPercent !== undefined)
"
:class="progressBarContainerClass"
class="absolute inset-0"
>
<div
v-if="hasProgressPercent(props.progressTotalPercent)"
:class="progressBarPrimaryClass"
:style="progressPercentStyle(props.progressTotalPercent)"
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="hasProgressPercent(props.progressCurrentPercent)"
:class="progressBarSecondaryClass"
:style="progressPercentStyle(props.progressCurrentPercent)"
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>
@@ -203,7 +201,6 @@ import { useI18n } from 'vue-i18n'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
@@ -248,14 +245,6 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
hasProgressPercent,
hasAnyProgressPercent,
progressPercentStyle
} = useProgressBarBackground()
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))

View File

@@ -2,49 +2,116 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import type { ComputedRef } from 'vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobErrorDialogService } from '@/components/queue/job/useJobErrorReporting'
import { useJobErrorReporting } from '@/components/queue/job/useJobErrorReporting'
import type { ExecutionError } from '@/platform/remote/comfyui/jobs/jobTypes'
import type {
JobErrorDialogService,
UseJobErrorReportingOptions
} from '@/components/queue/job/useJobErrorReporting'
import * as jobErrorReporting from '@/components/queue/job/useJobErrorReporting'
const createTaskWithError = (
promptId: string,
errorMessage?: string,
executionError?: ExecutionError,
createTime?: number
const createExecutionErrorMessage = (
overrides: Partial<ExecutionErrorWsMessage> = {}
): ExecutionErrorWsMessage => ({
prompt_id: 'prompt',
timestamp: 100,
node_id: 'node-1',
node_type: 'KSampler',
executed: [],
exception_message: 'default failure',
exception_type: 'RuntimeError',
traceback: ['Trace line'],
current_inputs: {},
current_outputs: {},
...overrides
})
const createTaskWithMessages = (
messages: Array<[string, unknown]> | undefined = []
): TaskItemImpl =>
({
promptId,
errorMessage,
executionError,
createTime: createTime ?? Date.now()
}) as Partial<TaskItemImpl> as TaskItemImpl
status: {
status_str: 'error',
completed: false,
messages
}
}) as TaskItemImpl
describe('extractExecutionError', () => {
it('returns null when task has no execution error messages', () => {
expect(jobErrorReporting.extractExecutionError(null)).toBeNull()
expect(
jobErrorReporting.extractExecutionError({
status: undefined
} as TaskItemImpl)
).toBeNull()
expect(
jobErrorReporting.extractExecutionError({
status: {
status_str: 'error',
completed: false,
messages: {} as unknown as Array<[string, unknown]>
}
} as TaskItemImpl)
).toBeNull()
expect(
jobErrorReporting.extractExecutionError(createTaskWithMessages([]))
).toBeNull()
expect(
jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_start', { prompt_id: 'prompt', timestamp: 1 }]
] as Array<[string, unknown]>)
)
).toBeNull()
})
it('returns detail and message for execution_error entries', () => {
const detail = createExecutionErrorMessage({ exception_message: 'Kaboom' })
const result = jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_success', { prompt_id: 'prompt', timestamp: 2 }],
['execution_error', detail]
] as Array<[string, unknown]>)
)
expect(result).toEqual({
detail,
message: 'Kaboom'
})
})
it('falls back to an empty message when the tuple lacks detail', () => {
const result = jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_error'] as unknown as [string, ExecutionErrorWsMessage]
])
)
expect(result).toEqual({ detail: undefined, message: '' })
})
})
describe('useJobErrorReporting', () => {
let taskState = ref<TaskItemImpl | null>(null)
let taskForJob: ComputedRef<TaskItemImpl | null>
let copyToClipboard: ReturnType<typeof vi.fn>
let showErrorDialog: ReturnType<typeof vi.fn>
let showExecutionErrorDialog: ReturnType<typeof vi.fn>
let copyToClipboard: UseJobErrorReportingOptions['copyToClipboard']
let showExecutionErrorDialog: JobErrorDialogService['showExecutionErrorDialog']
let showErrorDialog: JobErrorDialogService['showErrorDialog']
let dialog: JobErrorDialogService
let composable: ReturnType<typeof useJobErrorReporting>
let composable: ReturnType<typeof jobErrorReporting.useJobErrorReporting>
beforeEach(() => {
vi.clearAllMocks()
taskState = ref<TaskItemImpl | null>(null)
taskForJob = computed(() => taskState.value)
copyToClipboard = vi.fn()
showErrorDialog = vi.fn()
showExecutionErrorDialog = vi.fn()
showErrorDialog = vi.fn()
dialog = {
showErrorDialog,
showExecutionErrorDialog
} as unknown as JobErrorDialogService
composable = useJobErrorReporting({
showExecutionErrorDialog,
showErrorDialog
}
composable = jobErrorReporting.useJobErrorReporting({
taskForJob,
copyToClipboard: copyToClipboard as (
value: string
) => void | Promise<void>,
copyToClipboard,
dialog
})
})
@@ -54,87 +121,73 @@ describe('useJobErrorReporting', () => {
})
it('exposes a computed message that reflects the current task error', () => {
taskState.value = createTaskWithError('job-1', 'First failure')
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'First failure' })
]
])
expect(composable.errorMessageValue.value).toBe('First failure')
taskState.value = createTaskWithError('job-2', 'Second failure')
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'Second failure' })
]
])
expect(composable.errorMessageValue.value).toBe('Second failure')
})
it('returns empty string when no error message', () => {
taskState.value = createTaskWithError('job-1')
expect(composable.errorMessageValue.value).toBe('')
})
it('returns empty string when task is null', () => {
taskState.value = null
expect(composable.errorMessageValue.value).toBe('')
})
it('only calls the copy handler when a message exists', () => {
taskState.value = createTaskWithError('job-1', 'Clipboard failure')
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'Clipboard failure' })
]
])
composable.copyErrorMessage()
expect(copyToClipboard).toHaveBeenCalledTimes(1)
expect(copyToClipboard).toHaveBeenCalledWith('Clipboard failure')
copyToClipboard.mockClear()
taskState.value = createTaskWithError('job-2')
vi.mocked(copyToClipboard).mockClear()
taskState.value = createTaskWithMessages([])
composable.copyErrorMessage()
expect(copyToClipboard).not.toHaveBeenCalled()
})
it('shows simple error dialog when only errorMessage present', () => {
taskState.value = createTaskWithError('job-1', 'Queue job error')
it('prefers the detailed execution dialog when detail is available', () => {
const detail = createExecutionErrorMessage({
exception_message: 'Detailed failure'
})
taskState.value = createTaskWithMessages([['execution_error', detail]])
composable.reportJobError()
expect(showErrorDialog).toHaveBeenCalledTimes(1)
const [errorArg, optionsArg] = showErrorDialog.mock.calls[0]
expect(errorArg).toBeInstanceOf(Error)
expect(errorArg.message).toBe('Queue job error')
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
})
it('does nothing when no task exists', () => {
taskState.value = null
composable.reportJobError()
expect(showErrorDialog).not.toHaveBeenCalled()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
})
it('shows rich error dialog when execution_error available on task', () => {
const executionError: ExecutionError = {
prompt_id: 'job-1',
timestamp: 12345,
node_id: '5',
node_type: 'KSampler',
executed: ['1', '2'],
exception_message: 'CUDA out of memory',
exception_type: 'RuntimeError',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
taskState.value = createTaskWithError(
'job-1',
'CUDA out of memory',
executionError,
12345
)
composable.reportJobError()
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
expect(showExecutionErrorDialog).toHaveBeenCalledWith(executionError)
expect(showExecutionErrorDialog).toHaveBeenCalledWith(detail)
expect(showErrorDialog).not.toHaveBeenCalled()
})
it('does nothing when no error message and no execution_error', () => {
taskState.value = createTaskWithError('job-1')
it('shows a fallback dialog when only a message is available', () => {
const message = 'Queue job error'
taskState.value = createTaskWithMessages([])
const valueSpy = vi
.spyOn(composable.errorMessageValue, 'value', 'get')
.mockReturnValue(message)
expect(composable.errorMessageValue.value).toBe(message)
composable.reportJobError()
expect(showErrorDialog).not.toHaveBeenCalled()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
expect(showErrorDialog).toHaveBeenCalledTimes(1)
const [errorArg, optionsArg] = vi.mocked(showErrorDialog).mock.calls[0]
expect(errorArg).toBeInstanceOf(Error)
expect(errorArg.message).toBe(message)
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
valueSpy.mockRestore()
})
it('does nothing when no error could be extracted', () => {
taskState.value = createTaskWithMessages([])
composable.reportJobError()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
expect(showErrorDialog).not.toHaveBeenCalled()
})
})

View File

@@ -1,13 +1,13 @@
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import type { ExecutionErrorDialogInput } from '@/services/dialogService'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { TaskItemImpl } from '@/stores/queueStore'
type CopyHandler = (value: string) => void | Promise<void>
export type JobErrorDialogService = {
showExecutionErrorDialog: (executionError: ExecutionErrorDialogInput) => void
showExecutionErrorDialog: (error: ExecutionErrorWsMessage) => void
showErrorDialog: (
error: Error,
options?: {
@@ -17,7 +17,30 @@ export type JobErrorDialogService = {
) => void
}
type UseJobErrorReportingOptions = {
type JobExecutionError = {
detail?: ExecutionErrorWsMessage
message: string
}
export const extractExecutionError = (
task: TaskItemImpl | null
): JobExecutionError | null => {
const status = (task as TaskItemImpl | null)?.status
const messages = (status as { messages?: unknown[] } | undefined)?.messages
if (!Array.isArray(messages) || !messages.length) return null
const record = messages.find((entry: unknown) => {
return Array.isArray(entry) && entry[0] === 'execution_error'
}) as [string, ExecutionErrorWsMessage?] | undefined
if (!record) return null
const detail = record[1]
const message = String(detail?.exception_message ?? '')
return {
detail,
message
}
}
export type UseJobErrorReportingOptions = {
taskForJob: ComputedRef<TaskItemImpl | null>
copyToClipboard: CopyHandler
dialog: JobErrorDialogService
@@ -28,7 +51,10 @@ export const useJobErrorReporting = ({
copyToClipboard,
dialog
}: UseJobErrorReportingOptions) => {
const errorMessageValue = computed(() => taskForJob.value?.errorMessage ?? '')
const errorMessageValue = computed(() => {
const error = extractExecutionError(taskForJob.value)
return error?.message ?? ''
})
const copyErrorMessage = () => {
if (errorMessageValue.value) {
@@ -37,12 +63,11 @@ export const useJobErrorReporting = ({
}
const reportJobError = () => {
const executionError = taskForJob.value?.executionError
if (executionError) {
dialog.showExecutionErrorDialog(executionError)
const error = extractExecutionError(taskForJob.value)
if (error?.detail) {
dialog.showExecutionErrorDialog(error.detail)
return
}
if (errorMessageValue.value) {
dialog.showErrorDialog(new Error(errorMessageValue.value), {
reportType: 'queueJobError'

View File

@@ -1,41 +1,32 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, provide, ref, watchEffect } from 'vue'
import { computed, ref, toValue, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import TabInfo from './info/TabInfo.vue'
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
import TabNodes from './parameters/TabNodes.vue'
import TabNormalInputs from './parameters/TabNormalInputs.vue'
import TabSubgraphInputs from './parameters/TabSubgraphInputs.vue'
import TabGlobalSettings from './settings/TabGlobalSettings.vue'
import TabParameters from './parameters/TabParameters.vue'
import TabSettings from './settings/TabSettings.vue'
import {
GetNodeParentGroupKey,
useFlatAndCategorizeSelectedItems
} from './shared'
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
const canvasStore = useCanvasStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { findParentGroup } = useGraphHierarchy()
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
const { selectedItems } = storeToRefs(canvasStore)
const { activeTab, isEditingSubgraph } = storeToRefs(rightSidePanelStore)
const sidebarLocation = computed<'left' | 'right'>(() =>
@@ -49,31 +40,29 @@ const panelIcon = computed(() =>
: 'icon-[lucide--panel-right]'
)
const { flattedItems, selectedNodes, selectedGroups, nodeToParentGroup } =
useFlatAndCategorizeSelectedItems(directlySelectedItems)
const hasSelection = computed(() => selectedItems.value.length > 0)
const shouldShowGroupNames = computed(() => {
return !(
directlySelectedItems.value.length === 1 &&
(selectedGroups.value.length === 1 || selectedNodes.value.length === 1)
)
const selectedNodes = computed((): LGraphNode[] => {
return selectedItems.value.filter(isLGraphNode)
})
provide(GetNodeParentGroupKey, (node: LGraphNode) => {
if (!shouldShowGroupNames.value) return null
return nodeToParentGroup.value.get(node) ?? findParentGroup(node)
const isSubgraphNode = computed(() => {
return selectedNode.value instanceof SubgraphNode
})
const hasSelection = computed(() => flattedItems.value.length > 0)
const isSingleNodeSelected = computed(() => selectedNodes.value.length === 1)
const selectedSingleNode = computed(() => {
return selectedNodes.value.length === 1 && flattedItems.value.length === 1
? selectedNodes.value[0]
: null
const selectedNode = computed(() => {
return isSingleNodeSelected.value ? selectedNodes.value[0] : null
})
const isSingleSubgraphNode = computed(() => {
return selectedSingleNode.value instanceof SubgraphNode
const selectionCount = computed(() => selectedItems.value.length)
const panelTitle = computed(() => {
if (isSingleNodeSelected.value && selectedNode.value) {
return selectedNode.value.title || selectedNode.value.type || 'Node'
}
return t('rightSidePanel.title', { count: selectionCount.value })
})
function closePanel() {
@@ -86,40 +75,25 @@ type RightSidePanelTabList = Array<{
}>
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []
list.push({
label: () =>
flattedItems.value.length > 1
? t('rightSidePanel.nodes')
: t('rightSidePanel.parameters'),
value: 'parameters'
})
if (!hasSelection.value) {
const list: RightSidePanelTabList = [
{
label: () => t('rightSidePanel.parameters'),
value: 'parameters'
},
{
label: () => t('g.settings'),
value: 'settings'
}
]
if (
!hasSelection.value ||
(isSingleNodeSelected.value && !isSubgraphNode.value)
) {
list.push({
label: () => t('rightSidePanel.nodes'),
value: 'nodes'
label: () => t('rightSidePanel.info'),
value: 'info'
})
}
if (hasSelection.value) {
if (selectedSingleNode.value && !isSingleSubgraphNode.value) {
list.push({
label: () => t('rightSidePanel.info'),
value: 'info'
})
}
}
list.push({
label: () =>
hasSelection.value
? t('g.settings')
: t('rightSidePanel.globalSettings.title'),
value: 'settings'
})
return list
})
@@ -127,59 +101,27 @@ const tabs = computed<RightSidePanelTabList>(() => {
watchEffect(() => {
if (
!tabs.value.some((tab) => tab.value === activeTab.value) &&
!(activeTab.value === 'subgraph' && isSingleSubgraphNode.value)
!(activeTab.value === 'subgraph' && isSubgraphNode.value)
) {
rightSidePanelStore.openPanel(tabs.value[0].value)
}
})
function resolveTitle() {
const items = flattedItems.value
const nodes = selectedNodes.value
const groups = selectedGroups.value
if (items.length === 0) {
return t('rightSidePanel.workflowOverview')
}
if (directlySelectedItems.value.length === 1) {
if (groups.length === 1) {
return groups[0].title || t('rightSidePanel.fallbackGroupTitle')
}
if (nodes.length === 1) {
return (
nodes[0].title || nodes[0].type || t('rightSidePanel.fallbackNodeTitle')
)
}
}
return t('rightSidePanel.title', { count: items.length })
}
const panelTitle = ref(resolveTitle())
watchEffect(() => (panelTitle.value = resolveTitle()))
const isEditing = ref(false)
const allowTitleEdit = computed(() => {
return (
directlySelectedItems.value.length === 1 &&
(selectedGroups.value.length === 1 || selectedNodes.value.length === 1)
)
})
function handleTitleEdit(newTitle: string) {
isEditing.value = false
const trimmedTitle = newTitle.trim()
if (!trimmedTitle) return
const node = selectedGroups.value[0] || selectedNodes.value[0]
const node = toValue(selectedNode)
if (!node) return
if (trimmedTitle === node.title) return
node.title = trimmedTitle
panelTitle.value = trimmedTitle
canvasStore.canvas?.setDirty(true, true)
canvasStore.canvas?.setDirty(true, false)
}
function handleTitleCancel() {
@@ -190,28 +132,21 @@ function handleTitleCancel() {
<template>
<div
data-testid="properties-panel"
class="flex size-full flex-col bg-comfy-menu-bg"
class="flex size-full flex-col bg-interface-panel-surface"
>
<!-- Panel Header -->
<section class="pt-1">
<div class="flex items-center justify-between pl-4 pr-3">
<h3 class="my-3.5 text-sm font-semibold line-clamp-2 cursor-default">
<template v-if="allowTitleEdit">
<EditableText
:model-value="panelTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
class="cursor-text"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
@click="isEditing = true"
/>
<i
v-if="!isEditing"
class="icon-[lucide--pencil] size-4 text-muted-foreground ml-2 content-center relative top-[2px] hover:text-base-foreground cursor-pointer shrink-0"
@click="isEditing = true"
/>
</template>
<h3 class="my-3.5 text-sm font-semibold line-clamp-2">
<EditableText
v-if="isSingleNodeSelected"
:model-value="panelTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
@dblclick="isEditing = true"
/>
<template v-else>
{{ panelTitle }}
</template>
@@ -219,7 +154,7 @@ function handleTitleCancel() {
<div class="flex gap-2">
<Button
v-if="isSingleSubgraphNode"
v-if="isSubgraphNode"
variant="secondary"
size="icon"
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
@@ -242,7 +177,7 @@ function handleTitleCancel() {
</Button>
</div>
</div>
<nav class="px-4 pb-2 pt-1 overflow-x-auto">
<nav v-if="hasSelection" class="px-4 pb-2 pt-1">
<TabList
:model-value="activeTab"
@update:model-value="
@@ -254,7 +189,7 @@ function handleTitleCancel() {
<Tab
v-for="tab in tabs"
:key="tab.value"
class="text-sm py-1 px-2 font-inter transition-all active:scale-95"
class="text-sm py-1 px-2 font-inter"
:value="tab.value"
>
{{ tab.label() }}
@@ -265,29 +200,25 @@ function handleTitleCancel() {
<!-- Panel Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<template v-if="!hasSelection">
<TabGlobalParameters v-if="activeTab === 'parameters'" />
<TabNodes v-else-if="activeTab === 'nodes'" />
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
</template>
<div
v-if="!hasSelection"
class="flex size-full p-4 items-start justify-start text-sm text-muted-foreground"
>
{{ $t('rightSidePanel.noSelection') }}
</div>
<SubgraphEditor
v-else-if="isSingleSubgraphNode && isEditingSubgraph"
:node="selectedSingleNode"
v-else-if="isSubgraphNode && isEditingSubgraph"
:node="selectedNode"
/>
<template v-else>
<TabSubgraphInputs
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
:node="selectedSingleNode as SubgraphNode"
/>
<TabNormalInputs
v-else-if="activeTab === 'parameters'"
<TabParameters
v-if="activeTab === 'parameters'"
:nodes="selectedNodes"
:must-show-node-title="selectedGroups.length > 0"
/>
<TabInfo v-else-if="activeTab === 'info'" :nodes="selectedNodes" />
<TabSettings
v-else-if="activeTab === 'settings'"
:nodes="flattedItems"
:nodes="selectedNodes"
/>
</template>
</div>

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