Compare commits

..

1 Commits

Author SHA1 Message Date
Benjamin Lu
bc241bbef2 Move utils 2026-01-15 12:25:05 -08:00
290 changed files with 6357 additions and 9738 deletions

View File

@@ -122,7 +122,7 @@ echo " pnpm build - Build for production"
echo " pnpm test:unit - Run unit tests"
echo " pnpm typecheck - Run TypeScript checks"
echo " pnpm lint - Run ESLint"
echo " pnpm format - Format code with oxfmt"
echo " pnpm format - Format code with Prettier"
echo ""
echo "Next steps:"
echo "1. Run 'pnpm dev' to start developing"

View File

@@ -0,0 +1,21 @@
---
description: Creating unit tests
globs:
alwaysApply: false
---
# Creating unit tests
- This project uses `vitest` for unit testing
- Tests are stored in the `test/` directory
- Tests should be cross-platform compatible; able to run on Windows, macOS, and linux
- e.g. the use of `path.resolve`, or `path.join` and `path.sep` to ensure that tests work the same on all platforms
- Tests should be mocked properly
- Mocks should be cleanly written and easy to understand
- Mocks should be re-usable where possible
## Unit test style
- Prefer the use of `test.extend` over loose variables
- To achieve this, import `test as baseTest` from `vitest`
- Never use `it`; `test` should be used in place of this

14
.github/AGENTS.md vendored
View File

@@ -1,14 +0,0 @@
# PR Review Context
Context for automated PR review system.
## Review Scope
This automated review performs comprehensive analysis:
- Architecture and design patterns
- Security vulnerabilities
- Performance implications
- Code quality and maintainability
- Integration concerns
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.

39
.github/CLAUDE.md vendored
View File

@@ -1,3 +1,36 @@
<!-- A rose by any other name would smell as sweet,
But Claude insists on files named for its own conceit. -->
@AGENTS.md
# ComfyUI Frontend - Claude Review Context
This file provides additional context for the automated PR review system.
## Quick Reference
### PrimeVue Component Migrations
When reviewing, flag these deprecated components:
- `Dropdown` → Use `Select` from 'primevue/select'
- `OverlayPanel` → Use `Popover` from 'primevue/popover'
- `Calendar` → Use `DatePicker` from 'primevue/datepicker'
- `InputSwitch` → Use `ToggleSwitch` from 'primevue/toggleswitch'
- `Sidebar` → Use `Drawer` from 'primevue/drawer'
- `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
- `TabMenu` → Use `Tabs` without panels
- `Steps` → Use `Stepper` without panels
- `InlineMessage` → Use `Message` component
### API Utilities Reference
- `api.apiURL()` - Backend API calls (/prompt, /queue, /view, etc.)
- `api.fileURL()` - Static file access (templates, extensions)
- `$t()` / `i18n.global.t()` - Internationalization
- `DOMPurify.sanitize()` - HTML sanitization
## Review Scope
This automated review performs comprehensive analysis including:
- Architecture and design patterns
- Security vulnerabilities
- Performance implications
- Code quality and maintainability
- Integration concerns
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.

View File

@@ -42,7 +42,7 @@ jobs:
- name: Run Stylelint with auto-fix
run: pnpm stylelint:fix
- name: Run oxfmt with auto-format
- name: Run Prettier with auto-format
run: pnpm format
- name: Check for changes
@@ -60,7 +60,7 @@ jobs:
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add .
git commit -m "[automated] Apply ESLint and Oxfmt fixes"
git commit -m "[automated] Apply ESLint and Prettier fixes"
git push
- name: Final validation
@@ -80,7 +80,7 @@ jobs:
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting'
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting'
})
- name: Comment on PR about manual fix needed

View File

@@ -1,7 +1,7 @@
// This file is intentionally kept in CommonJS format (.cjs)
// to resolve compatibility issues with dependencies that require CommonJS.
// Do not convert this file to ESModule format unless all dependencies support it.
const { defineConfig } = require('@lobehub/i18n-cli')
const { defineConfig } = require('@lobehub/i18n-cli');
module.exports = defineConfig({
modelName: 'gpt-4.1',
@@ -10,19 +10,7 @@ module.exports = defineConfig({
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', 'fa'],
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.
@@ -38,4 +26,4 @@ module.exports = defineConfig({
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
- Maintain consistency with terminology used in Persian software and design applications.
`
})
});

View File

@@ -1,20 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none",
"printWidth": 80,
"ignorePatterns": [
"packages/registry-types/src/comfyRegistryTypes.ts",
"src/types/generatedManagerTypes.ts",
"**/*.md",
"**/*.json",
"**/*.css",
"**/*.yaml",
"**/*.yml",
"**/*.html",
"**/*.svg",
"**/*.xml"
]
}

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
packages/registry-types/src/comfyRegistryTypes.ts
src/types/generatedManagerTypes.ts

11
.prettierrc Normal file
View File

@@ -0,0 +1,11 @@
{
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none",
"printWidth": 80,
"importOrder": ["^@core/(.*)$", "<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"]
}

View File

@@ -1,17 +0,0 @@
# Storybook Guidelines
See `@docs/guidance/storybook.md` for story patterns (auto-loaded for `*.stories.ts`).
## Available Context
Stories have access to:
- All ComfyUI stores
- PrimeVue with ComfyUI theming
- i18n system
- CSS variables and styling
## Troubleshooting
1. **Import Errors**: Verify `@/` alias works
2. **Missing Styles**: Check CSS imports in `preview.ts`
3. **Store Errors**: Check store initialization in setup

View File

@@ -1,3 +1,197 @@
<!-- Though standards bloom in open fields so wide,
Anthropic walks a path of lonely pride. -->
@AGENTS.md
# Storybook Development Guidelines for Claude
## Quick Commands
- `pnpm storybook`: Start Storybook development server
- `pnpm build-storybook`: Build static Storybook
- `pnpm test:unit`: Run unit tests (includes Storybook components)
## Development Workflow for Storybook
1. **Creating New Stories**:
- Place `*.stories.ts` files alongside components
- Follow the naming pattern: `ComponentName.stories.ts`
- Use realistic mock data that matches ComfyUI schemas
2. **Testing Stories**:
- Verify stories render correctly in Storybook UI
- Test different component states and edge cases
- Ensure proper theming and styling
3. **Code Quality**:
- Run `pnpm typecheck` to verify TypeScript
- Run `pnpm lint` to check for linting issues
- Follow existing story patterns and conventions
## Story Creation Guidelines
### Basic Story Structure
```typescript
import type { Meta, StoryObj } from '@storybook/vue3'
import ComponentName from './ComponentName.vue'
const meta: Meta<typeof ComponentName> = {
title: 'Category/ComponentName',
component: ComponentName,
parameters: {
layout: 'centered' // or 'fullscreen', 'padded'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
// Component props
}
}
```
### Mock Data Patterns
For ComfyUI components, use realistic mock data:
```typescript
// Node definition mock
const mockNodeDef = {
input: {
required: {
prompt: ["STRING", { multiline: true }]
}
},
output: ["CONDITIONING"],
output_is_list: [false],
category: "conditioning"
}
// Component instance mock
const mockComponent = {
id: "1",
type: "CLIPTextEncode",
// ... other properties
}
```
### Common Story Variants
Always include these story variants when applicable:
- **Default**: Basic component with minimal props
- **WithData**: Component with realistic data
- **Loading**: Component in loading state
- **Error**: Component with error state
- **LongContent**: Component with edge case content
- **Empty**: Component with no data
### Storybook-Specific Code Patterns
#### Store Access
```typescript
// In stories, access stores through the setup function
export const WithStore: Story = {
render: () => ({
setup() {
const store = useMyStore()
return { store }
},
template: '<MyComponent :data="store.data" />'
})
}
```
#### Event Testing
```typescript
export const WithEvents: Story = {
args: {
onUpdate: fn() // Use Storybook's fn() for action logging
}
}
```
## Configuration Notes
### Vue App Setup
The Storybook preview is configured with:
- Pinia stores initialized
- PrimeVue with ComfyUI theme
- i18n internationalization
- All necessary CSS imports
### Build Configuration
- Vite integration with proper alias resolution
- Manual chunking for better performance
- TypeScript support with strict checking
- CSS processing for Vue components
## Troubleshooting
### Common Issues
1. **Import Errors**: Verify `@/` alias is working correctly
2. **Missing Styles**: Ensure CSS imports are in `preview.ts`
3. **Store Errors**: Check store initialization in setup
4. **Type Errors**: Use proper TypeScript types for story args
### Debug Commands
```bash
# Check TypeScript issues
pnpm typecheck
# Lint Storybook files
pnpm lint .storybook/
# Build to check for production issues
pnpm build-storybook
```
## File Organization
```
.storybook/
├── main.ts # Core configuration
├── preview.ts # Global setup and decorators
├── README.md # User documentation
└── CLAUDE.md # This file - Claude guidelines
src/
├── components/
│ └── MyComponent/
│ ├── MyComponent.vue
│ └── MyComponent.stories.ts
```
## Integration with ComfyUI
### Available Context
Stories have access to:
- All ComfyUI stores (widgetStore, colorPaletteStore, etc.)
- PrimeVue components with ComfyUI theming
- Internationalization system
- ComfyUI CSS variables and styling
### Testing Components
When testing ComfyUI-specific components:
1. Use realistic node definitions and data structures
2. Test with different node types (sampling, conditioning, etc.)
3. Verify proper CSS theming and dark/light modes
4. Check component behavior with various input combinations
### Performance Considerations
- Use manual chunking for large dependencies
- Minimize bundle size by avoiding unnecessary imports
- Leverage Storybook's lazy loading capabilities
- Profile build times and optimize as needed
## Best Practices
1. **Keep Stories Focused**: Each story should demonstrate one specific use case
2. **Use Descriptive Names**: Story names should clearly indicate what they show
3. **Document Complex Props**: Use JSDoc comments for complex prop types
4. **Test Edge Cases**: Create stories for unusual but valid use cases
5. **Maintain Consistency**: Follow established patterns in existing stories

View File

@@ -96,15 +96,15 @@ const config: StorybookConfig = {
}
]
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main
minifyIdentifiers: false,
keepNames: true
},
build: {
rolldownOptions: {
experimental: {
strictExecutionOrder: true
},
rollupOptions: {
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
treeshake: false,
output: {
keepNames: true
},
onwarn: (warning, warn) => {
// Suppress specific warnings
if (

View File

@@ -1,22 +1,25 @@
{
"recommendations": [
"antfu.vite",
"austenc.tailwind-docs",
"bradlc.vscode-tailwindcss",
"davidanson.vscode-markdownlint",
"dbaeumer.vscode-eslint",
"donjayamanne.githistory",
"eamodio.gitlens",
"esbenp.prettier-vscode",
"figma.figma-vscode-extension",
"github.vscode-github-actions",
"github.vscode-pull-request-github",
"hbenl.vscode-test-explorer",
"kisstkondoros.vscode-codemetrics",
"lokalise.i18n-ally",
"ms-playwright.playwright",
"oxc.oxc-vscode",
"sonarsource.sonarlint-vscode",
"vitest.explorer",
"vue.volar",
"wix.vscode-import-cost"
"sonarsource.sonarlint-vscode",
"deque-systems.vscode-axe-linter",
"kisstkondoros.vscode-codemetrics",
"donjayamanne.githistory",
"wix.vscode-import-cost",
"prograhammer.tslint-vue",
"antfu.vite"
]
}

View File

@@ -1,7 +1,5 @@
# Repository Guidelines
See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob).
## Project Structure & Module Organization
- Source: `src/`
@@ -27,10 +25,10 @@ See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob)
- Build output: `dist/`
- Configs
- `vite.config.mts`
- `vitest.config.ts`
- `playwright.config.ts`
- `eslint.config.ts`
- `.oxfmtrc.json`
- `.oxlintrc.json`
- `.prettierrc`
- etc.
## Monorepo Architecture
@@ -46,23 +44,8 @@ The project uses **Nx** for build orchestration and task management
- `pnpm test:unit`: Run Vitest unit tests
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`)
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
- `pnpm format` / `pnpm format:check`: oxfmt
- `pnpm format` / `pnpm format:check`: Prettier
- `pnpm typecheck`: Vue TSC type checking
- `pnpm storybook`: Start Storybook development server
## Development Workflow
1. Make code changes
2. Run relevant tests
3. Run `pnpm typecheck`, `pnpm lint`, `pnpm format`
4. Check if README updates are needed
5. Suggest docs.comfy.org updates for user-facing changes
## Git Conventions
- Use `prefix:` format: `feat:`, `fix:`, `test:`
- Add "Fixes #n" to PR descriptions
- Never mention Claude/AI in commits
## Coding Style & Naming Conventions
@@ -72,7 +55,7 @@ The project uses **Nx** for build orchestration and task management
- Composition API only
- Tailwind 4 styling
- Avoid `<style>` blocks
- Style: (see `.oxfmtrc.json`)
- Style: (see `.prettierrc`)
- Indent 2 spaces
- single quotes
- no trailing semicolons

View File

@@ -1 +1,30 @@
@AGENTS.md
# Claude Code specific instructions
@Agents.md
## Repository Setup
For first-time setup, use the Claude command:
```sh
/setup_repo
```
This bootstraps the monorepo with dependencies, builds, tests, and dev server verification.
**Prerequisites:** Node.js >= 24, Git repository, available ports for dev server, storybook, etc.
## Development Workflow
1. **First-time setup**: Run `/setup_repo` Claude command
2. Make code changes
3. Run tests (see subdirectory CLAUDE.md files)
4. Run typecheck, lint, format
5. Check README updates
6. Consider docs.comfy.org updates
## Git Conventions
- Use `prefix:` format: `feat:`, `fix:`, `test:`
- Add "Fixes #n" to PR descriptions
- Never mention Claude/AI in commits

View File

@@ -64,7 +64,7 @@ export default defineConfig(() => {
})
],
build: {
minify: SHOULD_MINIFY,
minify: SHOULD_MINIFY ? ('esbuild' as const) : false,
target: 'es2022',
sourcemap: true
}

View File

@@ -1,8 +0,0 @@
# E2E Testing Guidelines
See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded for `*.spec.ts`).
## Directory Structure
- `assets/` - Test data (JSON workflows, fixtures)
- Tests use premade JSON workflows to load desired graph state

View File

@@ -1,3 +1,17 @@
<!-- In gardens where the agents freely play,
One stubborn flower turns the other way. -->
@AGENTS.md
# E2E Testing Guidelines
## Browser Tests
- Test user workflows
- Use Playwright fixtures
- Follow naming conventions
## Best Practices
- Check assets/ for test data
- Prefer specific selectors
- Test across viewports
## Testing Process
After code changes:
1. Create browser tests as appropriate
2. Run tests until passing
3. Then run typecheck, lint, format

View File

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

View File

@@ -79,15 +79,48 @@ export class SubgraphSlotReference {
const node =
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
const slots =
type === 'input' ? currentGraph.inputs : currentGraph.outputs
if (!node) {
throw new Error(`No ${type} node found in subgraph`)
}
// Calculate position for next available slot
// const nextSlotIndex = slots?.length || 0
// const slotHeight = 20
// const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight
// Find last slot position
const lastSlot = slots.at(-1)
let slotX: number
let slotY: number
if (lastSlot) {
// If there are existing slots, position the new one below the last one
const gapHeight = 20
slotX = lastSlot.pos[0]
slotY = lastSlot.pos[1] + gapHeight
} else {
// No existing slots - use slotAnchorX if available, otherwise calculate from node position
if (currentGraph.slotAnchorX !== undefined) {
// The actual slot X position seems to be slotAnchorX - 10
slotX = currentGraph.slotAnchorX - 10
} else {
// Fallback: calculate from node edge
slotX =
type === 'input'
? node.pos[0] + node.size[0] - 10 // Right edge for input node
: node.pos[0] + 10 // Left edge for output node
}
// For Y position when no slots exist, use middle of node
slotY = node.pos[1] + node.size[1] / 2
}
// Convert from offset to canvas coordinates
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
node.emptySlot.pos[0],
node.emptySlot.pos[1]
slotX,
slotY
])
return canvasPos
},

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

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: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 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: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -1,33 +0,0 @@
---
globs:
- '**/*.spec.ts'
---
# Playwright E2E Test Conventions
See `docs/testing/*.md` for detailed patterns.
## Best Practices
- Follow [Playwright Best Practices](https://playwright.dev/docs/best-practices)
- Do NOT use `waitForTimeout` - use Locator actions and retrying assertions
- Prefer specific selectors (role, label, test-id)
- Test across viewports
## Test Tags
Tags are respected by config:
- `@mobile` - Mobile viewport tests
- `@2x` - High DPI tests
## Test Data
- Check `browser_tests/assets/` for test data and fixtures
- Use realistic ComfyUI workflows for E2E tests
## Running Tests
```bash
pnpm test:browser # Run all E2E tests
pnpm test:browser -- --ui # Interactive UI mode
```

View File

@@ -1,55 +0,0 @@
---
globs:
- '**/*.stories.ts'
---
# Storybook Conventions
## File Placement
Place `*.stories.ts` files alongside their components:
```
src/components/MyComponent/
├── MyComponent.vue
└── MyComponent.stories.ts
```
## Story Structure
```typescript
import type { Meta, StoryObj } from '@storybook/vue3'
import ComponentName from './ComponentName.vue'
const meta: Meta<typeof ComponentName> = {
title: 'Category/ComponentName',
component: ComponentName,
parameters: { layout: 'centered' }
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: { /* props */ }
}
```
## Required Story Variants
Include when applicable:
- **Default** - Minimal props
- **WithData** - Realistic data
- **Loading** - Loading state
- **Error** - Error state
- **Empty** - No data
## Mock Data
Use realistic ComfyUI schemas for mocks (node definitions, components).
## Running Storybook
```bash
pnpm storybook # Development server
pnpm build-storybook # Production build
```

View File

@@ -1,37 +0,0 @@
---
globs:
- '**/*.ts'
- '**/*.tsx'
- '**/*.vue'
---
# TypeScript Conventions
## Type Safety
- Never use `any` type - use proper TypeScript types
- Never use `as any` type assertions - fix the underlying type issue
- Type assertions are a last resort; they lead to brittle code
- Avoid `@ts-expect-error` - fix the underlying issue instead
## Utility Libraries
- Use `es-toolkit` for utility functions (not lodash)
## API Utilities
When making API calls in `src/`:
```typescript
// ✅ Correct - use api helpers
const response = await api.get(api.apiURL('/prompt'))
const template = await fetch(api.fileURL('/templates/default.json'))
// ❌ Wrong - direct URL construction
const response = await fetch('/api/prompt')
```
## Security
- Sanitize HTML with `DOMPurify.sanitize()`
- Never log secrets or sensitive data

View File

@@ -1,36 +0,0 @@
---
globs:
- '**/*.test.ts'
---
# Vitest Unit Test Conventions
See `docs/testing/*.md` for detailed patterns.
## Test Quality
- Do not write change detector tests (tests that just assert defaults)
- Do not write tests dependent on non-behavioral features (styles, classes)
- Do not write tests that just test mocks - ensure real code is exercised
- Be parsimonious; avoid redundant tests
## Mocking
- Use Vitest's mocking utilities (`vi.mock`, `vi.spyOn`)
- Keep module mocks contained - no global mutable state
- Use `vi.hoisted()` for per-test mock manipulation
- Don't mock what you don't own
## Component Testing
- Use Vue Test Utils for component tests
- Follow advice about making components easy to test
- Wait for reactivity with `await nextTick()` after state changes
## Running Tests
```bash
pnpm test:unit # Run all unit tests
pnpm test:unit -- path/to/file # Run specific test
pnpm test:unit -- --watch # Watch mode
```

View File

@@ -1,46 +0,0 @@
---
globs:
- '**/*.vue'
---
# Vue Component Conventions
Applies to all `.vue` files anywhere in the codebase.
## Vue 3 Composition API
- Use `<script setup lang="ts">` for component logic
- Destructure props (Vue 3.5 style with defaults) like `const { color = 'blue' } = defineProps<...>()`
- Use `ref`/`reactive` for state
- Use `computed()` for derived state
- Use lifecycle hooks: `onMounted`, `onUpdated`, etc.
## Component Communication
- Prefer `emit/@event-name` for state changes (promotes loose coupling)
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
- Proper props and emits definitions
## VueUse Composables
Prefer VueUse composables over manual event handling:
- `useElementHover` instead of manual mouseover/mouseout listeners
- `useIntersectionObserver` for visibility detection instead of scroll handlers
- `useFocusTrap` for modal/dialog focus management
- `useEventListener` for auto-cleanup event listeners
Prefer Vue native options when available:
- `defineModel` instead of `useVModel` for two-way binding with props
## Styling
- Use inline Tailwind CSS only (no `<style>` blocks)
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
## Best Practices
- Extract complex conditionals to `computed`
- In unmounted hooks, implement cleanup for async operations

View File

@@ -4,7 +4,9 @@ import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { importX } from 'eslint-plugin-import-x'
import oxlint from 'eslint-plugin-oxlint'
// eslint-config-prettier disables ESLint rules that conflict with formatters (oxfmt)
// WORKAROUND: eslint-plugin-prettier causes segfault on Node.js 24 + Windows
// See: https://github.com/nodejs/node/issues/58690
// Prettier is still run separately in lint-staged, so this is safe to disable
import eslintConfigPrettier from 'eslint-config-prettier'
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
import unusedImports from 'eslint-plugin-unused-imports'
@@ -109,7 +111,7 @@ export default defineConfig([
tseslintConfigs.recommended,
// Difference in typecheck on CI vs Local
pluginVue.configs['flat/recommended'],
// Disables ESLint rules that conflict with formatters
// Use eslint-config-prettier instead of eslint-plugin-prettier to avoid Node 24 segfault
eslintConfigPrettier,
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
storybookConfigs['flat/recommended'],

23
lint-staged.config.mjs Normal file
View File

@@ -0,0 +1,23 @@
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

@@ -1,9 +1,6 @@
import path from 'node:path'
export default {
'tests-ui/**': () =>
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
@@ -17,7 +14,7 @@ function formatAndEslint(fileNames: string[]) {
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
return [
`pnpm exec oxfmt --write ${joinedPaths}`,
`pnpm exec prettier --cache --write ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.38.8",
"version": "1.38.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -22,8 +22,10 @@
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
"dev": "nx serve",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
"json-schema": "tsx scripts/generate-json-schema.ts",
"knip:no-cache": "knip",
"knip": "knip --cache",
@@ -61,12 +63,14 @@
"@nx/vite": "catalog:",
"@pinia/testing": "catalog:",
"@playwright/test": "catalog:",
"@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:",
"@trivago/prettier-plugin-sort-imports": "catalog:",
"@types/fs-extra": "catalog:",
"@types/jsdom": "catalog:",
"@types/node": "catalog:",
@@ -97,11 +101,11 @@
"markdown-table": "catalog:",
"mixpanel-browser": "catalog:",
"nx": "catalog:",
"oxfmt": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"picocolors": "catalog:",
"postcss-html": "catalog:",
"prettier": "catalog:",
"pretty-bytes": "catalog:",
"rollup-plugin-visualizer": "catalog:",
"storybook": "catalog:",
@@ -188,10 +192,5 @@
"yjs": "catalog:",
"zod": "catalog:",
"zod-validation-error": "catalog:"
},
"pnpm": {
"overrides": {
"vite": "^8.0.0-beta.8"
}
}
}

855
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@ catalog:
'@nx/vite': 22.2.6
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.57.0
'@prettier/plugin-oxc': ^0.1.3
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
'@primeuix/utils': ^0.3.2
@@ -32,6 +33,7 @@ catalog:
'@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9
'@tailwindcss/vite': ^4.1.12
'@trivago/prettier-plugin-sort-imports': ^5.2.0
'@types/fs-extra': ^11.0.4
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
@@ -68,12 +70,12 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.2.6
oxfmt: ^0.26.0
oxlint: ^1.33.0
oxlint-tsgolint: ^0.9.1
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
prettier: ^3.7.4
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
@@ -91,7 +93,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vite: ^8.0.0-beta.8
vite: ^7.3.0
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0

View File

@@ -12,7 +12,6 @@ declare global {
const __ALGOLIA_API_KEY__: string
const __USE_PROD_CONFIG__: boolean
const __DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
const __IS_NIGHTLY__: boolean
}
type GlobalWithDefines = typeof globalThis & {
@@ -23,7 +22,6 @@ type GlobalWithDefines = typeof globalThis & {
__ALGOLIA_API_KEY__: string
__USE_PROD_CONFIG__: boolean
__DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
__IS_NIGHTLY__: boolean
window?: Record<string, unknown>
}
@@ -38,7 +36,6 @@ globalWithDefines.__ALGOLIA_APP_ID__ = ''
globalWithDefines.__ALGOLIA_API_KEY__ = ''
globalWithDefines.__USE_PROD_CONFIG__ = false
globalWithDefines.__DISTRIBUTION__ = 'localhost'
globalWithDefines.__IS_NIGHTLY__ = false
// Provide a minimal window shim for Node environment
// This is needed for code that checks window existence during imports

View File

@@ -1,26 +0,0 @@
# Source Code Guidelines
## Error Handling
- User-friendly and actionable messages
- Proper error propagation
## Security
- Sanitize HTML with DOMPurify
- Validate trusted sources
- Never log secrets
## State Management (Stores)
- Follow domain-driven design for organizing files/folders
- Clear public interfaces
- Restrict extension access
- Clean up subscriptions
## General Guidelines
- Use `es-toolkit` for utility functions
- Use TypeScript for type safety
- Avoid `@ts-expect-error` - fix the underlying issue
- Use `vue-i18n` for ALL user-facing strings (`src/locales/en/main.json`)

View File

@@ -1,3 +1,57 @@
<!-- We forked the path, yet here we are again—
Maintaining two files where one would have been sane. -->
@AGENTS.md
# Source Code Guidelines
## Service Layer
### API Calls
- Use `api.apiURL()` for backend endpoints
- Use `api.fileURL()` for static files
#### ✅ Correct Usage
```typescript
// Backend API call
const response = await api.get(api.apiURL('/prompt'))
// Static file
const template = await fetch(api.fileURL('/templates/default.json'))
```
#### ❌ Incorrect Usage
```typescript
// WRONG - Direct URL construction
const response = await fetch('/api/prompt')
const template = await fetch('/templates/default.json')
```
### Error Handling
- User-friendly and actionable messages
- Proper error propagation
### Security
- Sanitize HTML with DOMPurify
- Validate trusted sources
- Never log secrets
## State Management (Stores)
### Store Design
- Follow domain-driven design
- Clear public interfaces
- Restrict extension access
### Best Practices
- Use TypeScript for type safety
- Implement proper error handling
- Clean up subscriptions
- Avoid @ts-expect-error
## General Guidelines
- Use es-toolkit for utility functions
- Implement proper TypeScript types
- Follow Vue 3 composition API style guide
- Use vue-i18n for ALL user-facing strings in `src/locales/en/main.json`

View File

@@ -1,6 +0,0 @@
# Component Guidelines
## Component Communication
- Prefer `emit/@event-name` for state changes
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)

View File

@@ -1,3 +1,45 @@
<!-- "Play nice with others," mother always said,
But Claude prefers its own file name instead. -->
@AGENTS.md
# Component Guidelines
## Vue 3 Composition API
- Use setup() function
- Destructure props (Vue 3.5 style)
- Use ref/reactive for state
- Implement computed() for derived state
- Use provide/inject for dependency injection
## Component Communication
- Prefer `emit/@event-name` for state changes
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
- Events promote loose coupling
## UI Framework
- Deprecated PrimeVue component replacements:
- Dropdown → Select
- OverlayPanel → Popover
- Calendar → DatePicker
- InputSwitch → ToggleSwitch
- Sidebar → Drawer
- Chips → AutoComplete with multiple enabled
- TabMenu → Tabs without panels
- Steps → Stepper without panels
- InlineMessage → Message
## Styling
- Use Tailwind CSS only (no custom CSS)
- Use the correct tokens from style.css in the design system package
- For common operations, try to use existing VueUse composables that automatically handle effect scope
- Example: Use `useElementHover` instead of manually managing mouseover/mouseout event listeners
- Example: Use `useIntersectionObserver` for visibility detection instead of custom scroll handlers
## Best Practices
- Extract complex conditionals to computed
- Implement cleanup for async operations
- Use vue-i18n for ALL UI strings
- Use lifecycle hooks: onMounted, onUpdated
- Use Teleport/Suspense when needed
- Proper props and emits definitions

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,82 +0,0 @@
<template>
<div class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<input
v-model.number="x"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<input
v-model.number="y"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<input
v-model.number="width"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<input
v-model.number="height"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Bounds } from '@/renderer/core/layout/types'
const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})
const x = computed({
get: () => modelValue.value.x,
set: (x) => {
modelValue.value = { ...modelValue.value, x }
}
})
const y = computed({
get: () => modelValue.value.y,
set: (y) => {
modelValue.value = { ...modelValue.value, y }
}
})
const width = computed({
get: () => modelValue.value.width,
set: (width) => {
modelValue.value = { ...modelValue.value, width }
}
})
const height = computed({
get: () => modelValue.value.height,
set: (height) => {
modelValue.value = { ...modelValue.value, height }
}
})
</script>

View File

@@ -28,7 +28,7 @@
/>
</div>
<div
class="node-actions flex gap-1 touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
class="node-actions touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
>
<slot name="actions" :node="props.node" />
</div>

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

@@ -10,7 +10,7 @@
class="selection-toolbox pointer-events-auto rounded-lg border border-interface-stroke bg-interface-panel-surface"
:pt="{
header: 'hidden',
content: 'p-1 h-10 flex flex-row gap-1'
content: 'p-2 h-12 flex flex-row gap-1'
}"
@wheel="canvasInteractions.forwardEventToCanvas"
>

View File

@@ -6,19 +6,67 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
import Button from '@/components/ui/button/Button.vue'
// NOTE: The component import must come after mocks so they take effect.
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
const { openPanelMock } = vi.hoisted(() => ({
openPanelMock: vi.fn()
const mockLGraphNode = {
type: 'TestNode',
title: 'Test Node'
}
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => true)
}))
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({
openPanel: openPanelMock
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: () => ({
id: 'node-library'
})
}))
const openHelpMock = vi.fn()
const closeHelpMock = vi.fn()
const nodeHelpState: { currentHelpNode: any } = { currentHelpNode: null }
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
useNodeHelpStore: () => ({
openHelp: (def: any) => {
nodeHelpState.currentHelpNode = def
openHelpMock(def)
},
closeHelp: () => {
nodeHelpState.currentHelpNode = null
closeHelpMock()
},
get currentHelpNode() {
return nodeHelpState.currentHelpNode
},
get isHelpOpen() {
return nodeHelpState.currentHelpNode !== null
}
})
}))
const toggleSidebarTabMock = vi.fn((id: string) => {
sidebarState.activeSidebarTabId =
sidebarState.activeSidebarTabId === id ? null : id
})
const sidebarState: { activeSidebarTabId: string | null } = {
activeSidebarTabId: 'other-tab'
}
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => ({
get activeSidebarTabId() {
return sidebarState.activeSidebarTabId
},
toggleSidebarTab: toggleSidebarTabMock
})
}))
describe('InfoButton', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let nodeDefStore: ReturnType<typeof useNodeDefStore>
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -33,6 +81,9 @@ describe('InfoButton', () => {
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
nodeDefStore = useNodeDefStore()
vi.clearAllMocks()
})
@@ -41,15 +92,58 @@ describe('InfoButton', () => {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
components: { Button }
stubs: {
'i-lucide:info': true,
Button: {
template:
'<button class="help-button" severity="secondary"><slot /></button>',
props: ['severity', 'text', 'class'],
emits: ['click']
}
}
}
})
}
it('should open the info panel on click', async () => {
it('should handle click without errors', async () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
const wrapper = mountComponent()
const button = wrapper.find('[data-testid="info-button"]')
const button = wrapper.find('button')
await button.trigger('click')
expect(openPanelMock).toHaveBeenCalledWith('info')
expect(button.exists()).toBe(true)
})
it('should have correct CSS classes', () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.classes()).toContain('help-button')
expect(button.attributes('severity')).toBe('secondary')
})
it('should have correct tooltip', () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
})
})

View File

@@ -1,100 +0,0 @@
<template>
<div
class="widget-expands relative flex h-full w-full flex-col gap-1"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<!-- Image preview container -->
<div
ref="containerEl"
class="relative min-h-0 flex-1 overflow-hidden rounded-[5px] bg-node-component-surface"
>
<div v-if="isLoading" class="flex size-full items-center justify-center">
<span class="text-sm">{{ $t('imageCrop.loading') }}</span>
</div>
<div
v-else-if="!imageUrl"
class="flex size-full flex-col items-center justify-center text-center"
>
<i class="mb-2 icon-[lucide--image] h-12 w-12" />
<p class="text-sm">{{ $t('imageCrop.noInputImage') }}</p>
</div>
<img
v-else
ref="imageEl"
:src="imageUrl"
:alt="$t('imageCrop.cropPreviewAlt')"
draggable="false"
class="block size-full object-contain select-none brightness-50"
@load="handleImageLoad"
@error="handleImageError"
@dragstart.prevent
/>
<div
v-if="imageUrl && !isLoading"
class="absolute box-content cursor-move overflow-hidden border-2 border-white"
:style="cropBoxStyle"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"
>
<div class="pointer-events-none size-full" :style="cropImageStyle" />
</div>
<div
v-for="handle in resizeHandles"
v-show="imageUrl && !isLoading"
:key="handle.direction"
:class="['absolute', handle.class]"
:style="handle.style"
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
/>
</div>
<WidgetBoundingBox v-model="modelValue" class="shrink-0" />
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue'
import { useImageCrop } from '@/composables/useImageCrop'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Bounds } from '@/renderer/core/layout/types'
const props = defineProps<{
nodeId: NodeId
}>()
const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')
const {
imageUrl,
isLoading,
cropBoxStyle,
cropImageStyle,
resizeHandles,
handleImageLoad,
handleImageError,
handleDragStart,
handleDragMove,
handleDragEnd,
handleResizeStart,
handleResizeMove,
handleResizeEnd
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
</script>

View File

@@ -70,17 +70,17 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import { computed } from 'vue'
import { useNodeHelpContent } from '@/composables/useNodeHelpContent'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const { node } = defineProps<{
node: ComfyNodeDefImpl
}>()
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
const { renderedHelpHtml, isLoading, error } = useNodeHelpContent(() => node)
const nodeHelpStore = useNodeHelpStore()
const { renderedHelpHtml, isLoading, error } = storeToRefs(nodeHelpStore)
const inputList = computed(() =>
Object.values(node.inputs).map((spec) => ({

View File

@@ -200,13 +200,7 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
if (item.state === 'running' || item.state === 'initialization') {
// Running/initializing jobs: interrupt execution
// Cloud backend uses deleteItem, local uses interrupt
if (isCloud) {
await api.deleteItem('queue', promptId)
} else {
await api.interrupt(promptId)
}
executionStore.clearInitializationByPromptId(promptId)
await api.interrupt(promptId)
await queueStore.update()
} else if (item.state === 'pending') {
// Pending jobs: remove from queue
@@ -268,21 +262,13 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
await openResultGallery(item)
openResultGallery(item)
await focusAssetInSidebar(item)
}
)
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
// Capture pending promptIds before clearing
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
// Clear initialization state for removed prompts
executionStore.clearInitializationByPromptIds(pendingPromptIds)
})
const interruptAll = wrapWithErrorHandlingAsync(async () => {
@@ -298,14 +284,10 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
// on cloud to ensure we cancel the workflow the user clicked.
if (isCloud) {
await Promise.all(promptIds.map((id) => api.deleteItem('queue', id)))
executionStore.clearInitializationByPromptIds(promptIds)
await queueStore.update()
return
}
await Promise.all(promptIds.map((id) => api.interrupt(id)))
executionStore.clearInitializationByPromptIds(promptIds)
await queueStore.update()
})
const showClearHistoryDialog = () => {

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

@@ -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,9 +1,11 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const { nodes } = defineProps<{
nodes: LGraphNode[]
@@ -11,10 +13,20 @@ const { nodes } = defineProps<{
const node = computed(() => nodes[0])
const nodeDefStore = useNodeDefStore()
const nodeHelpStore = useNodeHelpStore()
const nodeInfo = computed(() => {
return nodeDefStore.fromLGraphNode(node.value)
})
// Open node help when the selected node changes
whenever(
nodeInfo,
(info) => {
nodeHelpStore.openHelp(info)
},
{ immediate: true }
)
</script>
<template>

View File

@@ -25,31 +25,13 @@ const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes.map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden || w.options?.advanced)
)
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
.map((widget) => ({ node, widget }))
return { widgets: shownWidgets, node }
})
})
const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes
.map((node) => {
const { widgets = [] } = node
const advancedWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
)
.map((widget) => ({ node, widget }))
return { widgets: advancedWidgets, node }
})
.filter(({ widgets }) => widgets.length > 0)
})
const isMultipleNodesSelected = computed(
() => widgetsSectionDataList.value.length > 1
)
@@ -74,12 +56,6 @@ const label = computed(() => {
: t('rightSidePanel.inputsNone')
: undefined // SectionWidgets display node titles by default
})
const advancedLabel = computed(() => {
return !mustShowNodeTitle && !isMultipleNodesSelected.value
? t('rightSidePanel.advancedInputs')
: undefined // SectionWidgets display node titles by default
})
</script>
<template>
@@ -117,16 +93,4 @@ const advancedLabel = computed(() => {
class="border-b border-interface-stroke"
/>
</TransitionGroup>
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">
<SectionWidgets
v-for="{ widgets, node } in advancedWidgetsSectionDataList"
:key="`advanced-${node.id}`"
:collapse="true"
:node
:label="advancedLabel"
:widgets
:show-locate-button="isMultipleNodesSelected"
class="border-b border-interface-stroke"
/>
</template>
</template>

View File

@@ -16,8 +16,8 @@ import {
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { renameWidget } from '@/utils/widgetUtil'
import { renameWidget } from '../shared'
import WidgetActions from './WidgetActions.vue'
const {

View File

@@ -1,9 +1,11 @@
import type { InjectionKey, MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
@@ -203,3 +205,67 @@ function repeatItems<T>(items: T[]): T[] {
}
return result
}
/**
* Renames a widget and its corresponding input.
* Handles both regular widgets and proxy widgets in subgraphs.
*
* @param widget The widget to rename
* @param node The node containing the widget
* @param newLabel The new label for the widget (empty string or undefined to clear)
* @param parents Optional array of parent SubgraphNodes (for proxy widgets)
* @returns true if the rename was successful, false otherwise
*/
export function renameWidget(
widget: IBaseWidget,
node: LGraphNode,
newLabel: string,
parents?: SubgraphNode[]
): boolean {
// For proxy widgets in subgraphs, we need to rename the original interior widget
if (isProxyWidget(widget) && parents?.length) {
const subgraph = parents[0].subgraph
if (!subgraph) {
console.error('Could not find subgraph for proxy widget')
return false
}
const interiorNode = subgraph.getNodeById(parseInt(widget._overlay.nodeId))
if (!interiorNode) {
console.error('Could not find interior node for proxy widget')
return false
}
const originalWidget = interiorNode.widgets?.find(
(w) => w.name === widget._overlay.widgetName
)
if (!originalWidget) {
console.error('Could not find original widget for proxy widget')
return false
}
// Rename the original widget
originalWidget.label = newLabel || undefined
// Also rename the corresponding input on the interior node
const interiorInput = interiorNode.inputs?.find(
(inp) => inp.widget?.name === widget._overlay.widgetName
)
if (interiorInput) {
interiorInput.label = newLabel || undefined
}
}
// Always rename the widget on the current node (either regular widget or proxy widget)
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
// Intentionally mutate the widget object here as it's a reference
// to the actual widget in the graph
widget.label = newLabel || undefined
if (input) {
input.label = newLabel || undefined
}
return true
}

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
computed,
@@ -6,8 +7,7 @@ import {
onBeforeUnmount,
onMounted,
ref,
triggerRef,
watch
triggerRef
} from 'vue'
import Button from '@/components/ui/button/Button.vue'
@@ -225,10 +225,13 @@ function setDraggableState() {
activeWidgets.value = aw
}
}
watch(filteredActive, () => {
setDraggableState()
})
watchDebounced(
filteredActive,
() => {
setDraggableState()
},
{ debounce: 100 }
)
onMounted(() => {
setDraggableState()
if (activeNode.value) pruneDisconnected(activeNode.value)

View File

@@ -44,9 +44,7 @@
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
<ModeToggle
v-if="menuItemStore.hasSeenLinear || flags.linearToggleEnabled"
/>
<ModeToggle v-if="showLinearToggle" />
</div>
</div>
<HelpCenterPopups :is-small="isSmall" />
@@ -54,7 +52,7 @@
</template>
<script setup lang="ts">
import { useResizeObserver } from '@vueuse/core'
import { useResizeObserver, whenever } from '@vueuse/core'
import { debounce } from 'es-toolkit/compat'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
@@ -70,7 +68,6 @@ import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
@@ -86,11 +83,15 @@ const settingStore = useSettingStore()
const userStore = useUserStore()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const menuItemStore = useMenuItemStore()
const sideToolbarRef = ref<HTMLElement>()
const topToolbarRef = ref<HTMLElement>()
const bottomToolbarRef = ref<HTMLElement>()
const { flags } = useFeatureFlags()
const showLinearToggle = ref(useFeatureFlags().flags.linearToggleEnabled)
whenever(
() => canvasStore.linearMode,
() => (showLinearToggle.value = true)
)
const isSmall = computed(
() => settingStore.get('Comfy.Sidebar.Size') === 'small'

View File

@@ -5,7 +5,6 @@
:label="$t('menu.help')"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
:is-small="isSmall"
@click="toggleHelpCenter"
/>
@@ -22,3 +21,24 @@ defineProps<{
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
</script>
<style scoped>
:deep(.p-badge) {
background: #ff3b30;
color: #ff3b30;
min-width: 8px;
height: 8px;
padding: 0;
border-radius: 9999px;
font-size: 0;
margin-top: 4px;
margin-right: 4px;
border: none;
outline: none;
box-shadow: none;
}
:deep(.p-badge.p-badge-dot) {
width: 8px !important;
}
</style>

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import OverlayBadge from 'primevue/overlaybadge'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -32,7 +33,8 @@ describe('SidebarIcon', () => {
return mount(SidebarIcon, {
global: {
plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip }
directives: { tooltip: Tooltip },
components: { OverlayBadge }
},
props: { ...exampleProps, ...props },
...options
@@ -52,9 +54,9 @@ describe('SidebarIcon', () => {
it('creates badge when iconBadge prop is set', () => {
const badge = '2'
const wrapper = mountSidebarIcon({ iconBadge: badge })
const badgeEl = wrapper.find('.sidebar-icon-badge')
const badgeEl = wrapper.findComponent(OverlayBadge)
expect(badgeEl.exists()).toBe(true)
expect(badgeEl.text()).toEqual(badge)
expect(badgeEl.find('.p-badge').text()).toEqual(badge)
})
it('shows tooltip on hover', async () => {

View File

@@ -17,28 +17,22 @@
>
<div class="side-bar-button-content">
<slot name="icon">
<div class="sidebar-icon-wrapper relative">
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i
v-if="typeof icon === 'string'"
:class="icon + ' side-bar-button-icon'"
/>
<component
:is="icon"
v-else-if="typeof icon === 'object'"
class="side-bar-button-icon"
/>
<span
v-if="shouldShowBadge"
:class="
cn(
'sidebar-icon-badge absolute min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground',
badgeClass || '-top-1 -right-1'
)
"
>
{{ overlayValue }}
</span>
</div>
<component :is="icon" v-else class="side-bar-button-icon" />
</OverlayBadge>
<i
v-else-if="typeof icon === 'string'"
:class="icon + ' side-bar-button-icon'"
/>
<component
:is="icon"
v-else-if="typeof icon === 'object'"
class="side-bar-button-icon"
/>
</slot>
<span v-if="label && !isSmall" class="side-bar-button-label">{{
t(label)
@@ -48,6 +42,7 @@
</template>
<script setup lang="ts">
import OverlayBadge from 'primevue/overlaybadge'
import { computed } from 'vue'
import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -62,7 +57,6 @@ const {
tooltip = '',
tooltipSuffix = '',
iconBadge = '',
badgeClass = '',
label = '',
isSmall = false
} = defineProps<{
@@ -71,7 +65,6 @@ const {
tooltip?: string
tooltipSuffix?: string
iconBadge?: string | (() => string | null)
badgeClass?: string
label?: string
isSmall?: boolean
}>()

View File

@@ -5,7 +5,7 @@ import type { JobListItem } from '@/composables/queue/useJobList'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
import { setMockJobItems } from '@/storybook/mocks/useJobList'
import { iconForJobState } from '@/utils/queueDisplay'
import { iconForJobState } from '@/queue/utils/queueDisplay'
import AssetsSidebarListView from './AssetsSidebarListView.vue'

View File

@@ -44,15 +44,9 @@
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
{{ t('sideToolbar.generatedAssetsHeader') }}
</div>
</div>
@@ -124,14 +118,9 @@ import {
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const {
assets,
isSelected,
assetType = 'output'
} = defineProps<{
const { assets, isSelected } = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
assetType?: 'input' | 'output'
}>()
const emit = defineEmits<{

View File

@@ -100,7 +100,6 @@
v-if="isListView"
:assets="displayAssets"
:is-selected="isSelected"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@@ -241,24 +240,15 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { getJobDetail } from '@/services/jobOutputCache'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
interface JobOutputItem {
filename: string
subfolder: string
type: string
}
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output')
@@ -502,41 +492,6 @@ function handleContextMenuHide() {
})
}
const handleBulkDownload = (assets: AssetItem[]) => {
downloadMultipleAssets(assets)
clearSelection()
}
const handleBulkDelete = async (assets: AssetItem[]) => {
await deleteMultipleAssets(assets)
clearSelection()
}
const handleClearQueue = async () => {
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByPromptIds(pendingPromptIds)
}
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
await addMultipleToWorkflow(assets)
clearSelection()
}
const handleBulkOpenWorkflow = async (assets: AssetItem[]) => {
await openMultipleWorkflows(assets)
clearSelection()
}
const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
await exportMultipleWorkflows(assets)
clearSelection()
}
const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name)
@@ -564,16 +519,16 @@ const handleZoomClick = (asset: AssetItem) => {
}
}
const enterFolderView = async (asset: AssetItem) => {
const enterFolderView = (asset: AssetItem) => {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (!metadata) {
console.warn('Invalid output asset metadata')
return
}
const { promptId, allOutputs, executionTimeInSeconds, outputCount } = metadata
const { promptId, allOutputs, executionTimeInSeconds } = metadata
if (!promptId) {
if (!promptId || !Array.isArray(allOutputs) || allOutputs.length === 0) {
console.warn('Missing required folder view data')
return
}
@@ -581,48 +536,7 @@ const enterFolderView = async (asset: AssetItem) => {
folderPromptId.value = promptId
folderExecutionTime.value = executionTimeInSeconds
// Determine which outputs to display
let outputsToDisplay = allOutputs ?? []
// If outputCount indicates more outputs than we have, fetch full outputs
const needsFullOutputs =
typeof outputCount === 'number' &&
outputCount > 1 &&
outputsToDisplay.length < outputCount
if (needsFullOutputs) {
try {
const jobDetail = await getJobDetail(promptId)
if (jobDetail?.outputs) {
// Convert job outputs to ResultItemImpl array
outputsToDisplay = Object.entries(jobDetail.outputs).flatMap(
([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as JobOutputItem[])
.map(
(item) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
.filter((r) => r.supportsPreview)
)
)
}
} catch (error) {
console.error('Failed to fetch job detail for folder view:', error)
outputsToDisplay = []
}
}
if (outputsToDisplay.length === 0) {
console.warn('No outputs available for folder view')
return
}
folderAssets.value = outputsToDisplay.map((output) => ({
folderAssets.value = allOutputs.map((output) => ({
id: `${output.nodeId}-${output.filename}`,
name: output.filename,
size: 0,
@@ -695,6 +609,35 @@ const handleDeleteSelected = async () => {
clearSelection()
}
const handleBulkDownload = (assets: AssetItem[]) => {
downloadMultipleAssets(assets)
clearSelection()
}
const handleBulkDelete = async (assets: AssetItem[]) => {
await deleteMultipleAssets(assets)
clearSelection()
}
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
await addMultipleToWorkflow(assets)
clearSelection()
}
const handleBulkOpenWorkflow = async (assets: AssetItem[]) => {
await openMultipleWorkflows(assets)
clearSelection()
}
const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
await exportMultipleWorkflows(assets)
clearSelection()
}
const handleClearQueue = async () => {
await commandStore.execute('Comfy.ClearPendingTasks')
}
const handleApproachEnd = useDebounceFn(async () => {
if (
activeTab.value === 'output' &&

View File

@@ -139,15 +139,7 @@ import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import type { Ref } from 'vue'
import {
computed,
getCurrentInstance,
h,
nextTick,
onMounted,
ref,
render
} from 'vue'
import { computed, h, nextTick, onMounted, ref, render } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
@@ -179,8 +171,6 @@ import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue'
const instance = getCurrentInstance()!
const appContext = instance.appContext
const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeHelpStore = useNodeHelpStore()
@@ -282,7 +272,6 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
draggable: node.leaf,
renderDragPreview(container) {
const vnode = h(NodePreview, { nodeDef: node.data })
vnode.appContext = appContext
render(vnode, container)
return () => {
render(null, container)

View File

@@ -22,15 +22,7 @@
</template>
<script setup lang="ts">
import {
computed,
getCurrentInstance,
h,
nextTick,
ref,
render,
watch
} from 'vue'
import { computed, h, nextTick, ref, render, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FolderCustomizationDialog from '@/components/common/CustomizationDialog.vue'
@@ -49,8 +41,6 @@ import type {
TreeNode
} from '@/types/treeExplorerTypes'
const instance = getCurrentInstance()!
const appContext = instance.appContext
const props = defineProps<{
filteredNodeDefs: ComfyNodeDefImpl[]
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
@@ -164,7 +154,6 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
},
renderDragPreview(container) {
const vnode = h(NodePreview, { nodeDef: node.data })
vnode.appContext = appContext
render(vnode, container)
return () => {
render(null, container)

View File

@@ -0,0 +1,100 @@
import { flushPromises, mount } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import NodeHelpPage from '@/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
vi.mock('@/composables/graph/useSelectionState')
vi.mock('@/stores/workspace/nodeHelpStore')
const baseNode = {
nodePath: 'NodeA',
display_name: 'Node A',
description: '',
inputs: {},
outputs: []
}
describe('NodeHelpPage', () => {
const selection = ref<any | null>(null)
let openHelp: ReturnType<typeof vi.fn>
const mountPage = () =>
mount(NodeHelpPage, {
props: { node: baseNode as any },
global: {
mocks: {
$t: (key: string) => key
},
stubs: {
ProgressSpinner: true,
Button: true
}
}
})
beforeEach(() => {
vi.resetAllMocks()
selection.value = null
openHelp = vi.fn()
vi.mocked(useSelectionState).mockReturnValue({
nodeDef: computed(() => selection.value)
} as any)
vi.mocked(useNodeHelpStore).mockReturnValue({
renderedHelpHtml: ref('<p>help</p>'),
isLoading: ref(false),
error: ref(null),
isHelpOpen: true,
currentHelpNode: { nodePath: 'NodeA' },
openHelp,
closeHelp: vi.fn()
} as any)
})
test('opens help for a newly selected node while help is open', async () => {
const wrapper = mountPage()
selection.value = { nodePath: 'NodeB' }
await flushPromises()
expect(openHelp).toHaveBeenCalledWith({ nodePath: 'NodeB' })
wrapper.unmount()
})
test('does not reopen help when the same node stays selected', async () => {
const wrapper = mountPage()
selection.value = { nodePath: 'NodeA' }
await flushPromises()
expect(openHelp).not.toHaveBeenCalled()
wrapper.unmount()
})
test('does not react to selection when help is closed', async () => {
vi.mocked(useNodeHelpStore).mockReturnValueOnce({
renderedHelpHtml: ref('<p>help</p>'),
isLoading: ref(false),
error: ref(null),
isHelpOpen: false,
currentHelpNode: null,
openHelp,
closeHelp: vi.fn()
} as any)
const wrapper = mountPage()
selection.value = { nodePath: 'NodeB' }
await flushPromises()
expect(openHelp).not.toHaveBeenCalled()
wrapper.unmount()
})
})

View File

@@ -21,13 +21,32 @@
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
defineEmits<{
(e: 'close'): void
}>()
const nodeHelpStore = useNodeHelpStore()
const { nodeDef } = useSelectionState()
const activeHelpDef = computed(() =>
nodeHelpStore.isHelpOpen ? nodeDef.value : null
)
// Keep the open help page synced with the current selection while help is open.
whenever(activeHelpDef, (def) => {
const currentHelpNode = nodeHelpStore.currentHelpNode
if (currentHelpNode?.nodePath === def.nodePath) return
nodeHelpStore.openHelp(def)
})
</script>

View File

@@ -57,7 +57,7 @@
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('g.learnMore')"
@click.stop="onHelpClick"
@click.stop="props.openNodeHelp(nodeDef)"
>
<i class="pi pi-question size-3.5" />
</Button>
@@ -85,7 +85,6 @@ import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
@@ -113,13 +112,6 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
const toggleBookmark = async () => {
await nodeBookmarkStore.toggleBookmark(nodeDef.value)
}
const onHelpClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'node_library_help_button'
})
props.openNodeHelp(nodeDef.value)
}
const editBlueprint = async () => {
if (!props.node.data)
throw new Error(

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