Compare commits
1 Commits
feat/queue
...
feat/whats
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f9afa4049 |
@@ -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"
|
||||
|
||||
21
.cursor/rules/unit-test.mdc
Normal 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
@@ -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
@@ -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`.
|
||||
6
.github/workflows/ci-lint-format.yaml
vendored
@@ -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
|
||||
|
||||
18
.i18nrc.cjs
@@ -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.
|
||||
`
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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
@@ -0,0 +1,2 @@
|
||||
packages/registry-types/src/comfyRegistryTypes.ts
|
||||
src/types/generatedManagerTypes.ts
|
||||
11
.prettierrc
Normal 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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 (
|
||||
|
||||
15
.vscode/extensions.json
vendored
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
25
AGENTS.md
@@ -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
|
||||
|
||||
31
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -64,7 +64,7 @@ export default defineConfig(() => {
|
||||
})
|
||||
],
|
||||
build: {
|
||||
minify: SHOULD_MINIFY,
|
||||
minify: SHOULD_MINIFY ? ('esbuild' as const) : false,
|
||||
target: 'es2022',
|
||||
sourcemap: true
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
164
browser_tests/fixtures/utils/taskHistory.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
@@ -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}`
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`
|
||||
]
|
||||
|
||||
17
package.json
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
@@ -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`
|
||||
|
||||
@@ -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()`)
|
||||
@@ -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
|
||||
|
||||
@@ -36,8 +36,7 @@ function createWrapper() {
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
viewJobHistory: 'View job history',
|
||||
expandCollapsedQueue: 'Expand collapsed queue',
|
||||
activeJobsShort: '{count} active | {count} active'
|
||||
expandCollapsedQueue: 'Expand collapsed queue'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,8 +48,6 @@ function createWrapper() {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
|
||||
stubs: {
|
||||
SubgraphBreadcrumb: true,
|
||||
ComfyActionbar: true,
|
||||
QueueInlineProgressSummary: true,
|
||||
QueueProgressOverlay: true,
|
||||
CurrentUserButton: true,
|
||||
LoginButton: true
|
||||
|
||||
@@ -1,94 +1,96 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!workspaceStore.focusMode"
|
||||
class="ml-1 flex flex-col gap-1 pt-1"
|
||||
class="ml-1 flex gap-x-0.5 pt-1"
|
||||
@mouseenter="isTopMenuHovered = true"
|
||||
@mouseleave="isTopMenuHovered = false"
|
||||
>
|
||||
<div class="flex gap-x-0.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="managerState.shouldShowManagerButtons.value"
|
||||
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.bottom="customNodesManagerTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('menu.customNodesManager')"
|
||||
class="relative"
|
||||
@click="openCustomNodeManager"
|
||||
>
|
||||
<i class="icon-[lucide--puzzle] size-4" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
class="actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 overflow-hidden rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar
|
||||
v-model:docked="isActionbarDocked"
|
||||
v-model:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
type="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<QueueProgressOverlay
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
:menu-hovered="isTopMenuHovered"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
|
||||
<div v-if="isActionbarEnabled && !isActionbarFloating">
|
||||
<QueueInlineProgressSummary
|
||||
class="pr-1"
|
||||
:hidden="isQueueOverlayExpanded"
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="managerState.shouldShowManagerButtons.value"
|
||||
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.bottom="customNodesManagerTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('menu.customNodesManager')"
|
||||
class="relative"
|
||||
@click="openCustomNodeManager"
|
||||
>
|
||||
<i class="icon-[lucide--puzzle] size-4" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="actionbar-container pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<Button
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="destructive"
|
||||
size="icon"
|
||||
:aria-pressed="isQueueOverlayExpanded"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
"
|
||||
@click="toggleQueueOverlay"
|
||||
>
|
||||
<i class="icon-[lucide--history] size-4" />
|
||||
<span
|
||||
v-if="queuedCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
</Button>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
type="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<QueueProgressOverlay
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
:menu-hovered="isTopMenuHovered"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
@@ -100,7 +102,8 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
@@ -116,6 +119,8 @@ const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const releaseStore = useReleaseStore()
|
||||
@@ -123,18 +128,13 @@ const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
useConflictAcknowledgment()
|
||||
const isTopMenuHovered = ref(false)
|
||||
const actionbarContainerRef = ref<HTMLElement>()
|
||||
const isActionbarDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
||||
const actionbarPosition = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||
const isActionbarEnabled = computed(
|
||||
() => actionbarPosition.value !== 'Disabled'
|
||||
)
|
||||
const isActionbarFloating = computed(
|
||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||
)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.customNodesManager'))
|
||||
)
|
||||
@@ -160,6 +160,10 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const toggleQueueOverlay = () => {
|
||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||
}
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
try {
|
||||
await managerState.openManager({
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
|
||||
const queueStoreMock = reactive({
|
||||
pendingTasks: [] as Array<{ promptId?: string }>,
|
||||
runningTasks: [] as Array<{ promptId?: string }>
|
||||
})
|
||||
|
||||
const executionStoreMock = reactive({
|
||||
isIdle: true,
|
||||
clearInitializationByPromptIds: vi.fn()
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn((key: string) => (key === 'Comfy.UseNewMenu' ? 'Top' : null))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackUiButtonClicked: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => executionStoreMock
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => queueStoreMock
|
||||
}))
|
||||
|
||||
function createWrapper() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
activeJobsShort: '{count} active | {count} active',
|
||||
clearQueueTooltip: 'Clear queue',
|
||||
viewJobHistory: 'View job history',
|
||||
expandCollapsedQueue: 'Expand job queue'
|
||||
}
|
||||
},
|
||||
menu: {
|
||||
interrupt: 'Interrupt'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return mount(ComfyActionbar, {
|
||||
props: {
|
||||
docked: true,
|
||||
queueOverlayExpanded: false
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Panel: true,
|
||||
ComfyRunButton: true,
|
||||
QueueInlineProgress: true,
|
||||
QueueInlineProgressSummary: true,
|
||||
ContextMenu: {
|
||||
name: 'ContextMenu',
|
||||
props: ['model'],
|
||||
template: '<div />'
|
||||
}
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyActionbar', () => {
|
||||
beforeEach(() => {
|
||||
queueStoreMock.pendingTasks = []
|
||||
queueStoreMock.runningTasks = []
|
||||
})
|
||||
|
||||
it('shows the active jobs label with the current count', async () => {
|
||||
const wrapper = createWrapper()
|
||||
queueStoreMock.pendingTasks = [{ promptId: 'pending-1' }]
|
||||
queueStoreMock.runningTasks = [
|
||||
{ promptId: 'running-1' },
|
||||
{ promptId: 'running-2' }
|
||||
]
|
||||
|
||||
await nextTick()
|
||||
|
||||
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
expect(queueButton.text()).toContain('3 active')
|
||||
})
|
||||
|
||||
it('disables the clear queue context menu item when no queued jobs exist', () => {
|
||||
const wrapper = createWrapper()
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
expect(model[0]?.label).toBe('Clear queue')
|
||||
expect(model[0]?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables the clear queue context menu item when queued jobs exist', async () => {
|
||||
const wrapper = createWrapper()
|
||||
queueStoreMock.pendingTasks = [{ promptId: 'pending-1' }]
|
||||
|
||||
await nextTick()
|
||||
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
expect(model[0]?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
@@ -9,108 +9,40 @@
|
||||
{{ t('actionbar.dockToTop') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="actionbarWrapperRef"
|
||||
class="flex flex-col items-stretch"
|
||||
<Panel
|
||||
class="pointer-events-auto"
|
||||
:style="style"
|
||||
:class="panelClass"
|
||||
:pt="{
|
||||
header: { class: 'hidden' },
|
||||
content: { class: isDocked ? 'p-0' : 'p-1' }
|
||||
}"
|
||||
>
|
||||
<Panel
|
||||
class="pointer-events-auto"
|
||||
:class="panelRootClass"
|
||||
:pt="{
|
||||
header: { class: 'hidden' },
|
||||
content: { class: isDocked ? 'p-0' : 'p-1' }
|
||||
}"
|
||||
>
|
||||
<div
|
||||
ref="panelRef"
|
||||
class="relative flex items-center select-none gap-2 overflow-hidden"
|
||||
>
|
||||
<span
|
||||
ref="dragHandleRef"
|
||||
:class="
|
||||
cn(
|
||||
'drag-handle cursor-grab w-3 h-max',
|
||||
isDragging && 'cursor-grabbing'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<Suspense @resolve="comfyRunButtonResolved">
|
||||
<ComfyRunButton />
|
||||
</Suspense>
|
||||
<Button
|
||||
v-tooltip.bottom="cancelJobTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:disabled="isExecutionIdle"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="cancelCurrentJob"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
variant="destructive"
|
||||
size="md"
|
||||
class="px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
:aria-pressed="queueOverlayExpanded"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
>
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<span class="sr-only">
|
||||
{{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
|
||||
</span>
|
||||
</Button>
|
||||
<ContextMenu
|
||||
ref="queueContextMenu"
|
||||
:model="queueContextMenuItems"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'rounded-lg border border-border-default bg-base-background p-2 shadow-[0px_2px_12px_0_rgba(0,0,0,0.1)] font-inter'
|
||||
},
|
||||
rootList: { class: 'm-0 flex list-none flex-col gap-1 p-0' },
|
||||
item: { class: 'm-0 p-0' }
|
||||
}"
|
||||
>
|
||||
<template #item="{ item, props }">
|
||||
<a
|
||||
v-bind="props.action"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center gap-2 rounded-sm px-2 text-sm font-normal',
|
||||
item.class,
|
||||
item.disabled && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i v-if="item.icon" :class="cn(item.icon, 'size-4')" />
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</Panel>
|
||||
<div v-if="isFloating" class="flex justify-end pt-1 pr-1">
|
||||
<QueueInlineProgressSummary
|
||||
class="pr-1"
|
||||
:hidden="queueOverlayExpanded"
|
||||
<div ref="panelRef" class="flex items-center select-none gap-2">
|
||||
<span
|
||||
ref="dragHandleRef"
|
||||
:class="
|
||||
cn(
|
||||
'drag-handle cursor-grab w-3 h-max',
|
||||
isDragging && 'cursor-grabbing'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<Suspense @resolve="comfyRunButtonResolved">
|
||||
<ComfyRunButton />
|
||||
</Suspense>
|
||||
<Button
|
||||
v-tooltip.bottom="cancelJobTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:disabled="isExecutionIdle"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="cancelCurrentJob"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
|
||||
<QueueInlineProgress
|
||||
:hidden="queueOverlayExpanded"
|
||||
data-testid="queue-inline-progress"
|
||||
/>
|
||||
</Teleport>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -123,120 +55,48 @@ import {
|
||||
} from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
|
||||
const { topMenuContainer = null } = defineProps<{
|
||||
topMenuContainer?: HTMLElement | null
|
||||
}>()
|
||||
|
||||
const queueOverlayExpanded = defineModel<boolean>('queueOverlayExpanded', {
|
||||
default: false
|
||||
})
|
||||
const isDocked = defineModel<boolean>('docked', { default: true })
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const { t, n } = useI18n()
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
|
||||
const { t } = useI18n()
|
||||
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
|
||||
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
|
||||
const actionbarWrapperRef = ref<HTMLElement | null>(null)
|
||||
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)
|
||||
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
const { x, y, style, isDragging } = useDraggable(actionbarWrapperRef, {
|
||||
const { x, y, style, isDragging } = useDraggable(panelRef, {
|
||||
initialValue: { x: 0, y: 0 },
|
||||
handle: dragHandleRef,
|
||||
containerElement: document.body
|
||||
})
|
||||
|
||||
const wrapperElement = computed(() => {
|
||||
const element = actionbarWrapperRef.value
|
||||
return element instanceof HTMLElement ? element : null
|
||||
})
|
||||
|
||||
const panelElement = computed(() => {
|
||||
const element = panelRef.value
|
||||
return element instanceof HTMLElement ? element : null
|
||||
})
|
||||
|
||||
// Queue and Execution logic
|
||||
const activeJobsCount = computed(
|
||||
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
|
||||
)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.activeJobsShort',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
|
||||
function toggleQueueOverlay() {
|
||||
void commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||
}
|
||||
|
||||
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
|
||||
icon: 'icon-[lucide--list-x]',
|
||||
class: 'text-destructive-background',
|
||||
disabled: queueStore.pendingTasks.length === 0,
|
||||
command: () => {
|
||||
void handleClearQueue()
|
||||
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
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
function showQueueContextMenu(event: MouseEvent) {
|
||||
queueContextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
async function handleClearQueue() {
|
||||
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)
|
||||
}
|
||||
|
||||
async function cancelCurrentJob() {
|
||||
if (isExecutionIdle.value) return
|
||||
await commandStore.execute('Comfy.Interrupt')
|
||||
}
|
||||
})
|
||||
|
||||
// Update storedPosition when x or y changes
|
||||
watchDebounced(
|
||||
@@ -249,12 +109,11 @@ watchDebounced(
|
||||
|
||||
// Set initial position to bottom center
|
||||
const setInitialPosition = () => {
|
||||
const containerEl = wrapperElement.value
|
||||
if (containerEl) {
|
||||
if (panelRef.value) {
|
||||
const screenWidth = window.innerWidth
|
||||
const screenHeight = window.innerHeight
|
||||
const menuWidth = containerEl.offsetWidth
|
||||
const menuHeight = containerEl.offsetHeight
|
||||
const menuWidth = panelRef.value.offsetWidth
|
||||
const menuHeight = panelRef.value.offsetHeight
|
||||
|
||||
if (menuWidth === 0 || menuHeight === 0) {
|
||||
return
|
||||
@@ -330,12 +189,11 @@ watch(
|
||||
)
|
||||
|
||||
const adjustMenuPosition = () => {
|
||||
const containerEl = wrapperElement.value
|
||||
if (containerEl) {
|
||||
if (panelRef.value) {
|
||||
const screenWidth = window.innerWidth
|
||||
const screenHeight = window.innerHeight
|
||||
const menuWidth = containerEl.offsetWidth
|
||||
const menuHeight = containerEl.offsetHeight
|
||||
const menuWidth = panelRef.value.offsetWidth
|
||||
const menuHeight = panelRef.value.offsetHeight
|
||||
|
||||
// Calculate distances to all edges
|
||||
const distanceLeft = lastDragState.value.x
|
||||
@@ -419,12 +277,14 @@ watch(isDragging, (dragging) => {
|
||||
}
|
||||
})
|
||||
|
||||
const isFloating = computed(() => visible.value && !isDocked.value)
|
||||
const inlineProgressTarget = computed(() => {
|
||||
if (!visible.value) return null
|
||||
if (isFloating.value) return panelElement.value
|
||||
return topMenuContainer
|
||||
})
|
||||
const cancelJobTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.interrupt'))
|
||||
)
|
||||
|
||||
const cancelCurrentJob = async () => {
|
||||
if (isExecutionIdle.value) return
|
||||
await commandStore.execute('Comfy.Interrupt')
|
||||
}
|
||||
|
||||
const actionbarClass = computed(() =>
|
||||
cn(
|
||||
@@ -440,15 +300,9 @@ const panelClass = computed(() =>
|
||||
cn(
|
||||
'actionbar pointer-events-auto z-1300',
|
||||
isDragging.value && 'select-none pointer-events-none',
|
||||
isDocked.value ? 'p-0 static border-none bg-transparent' : 'fixed'
|
||||
)
|
||||
)
|
||||
const panelRootClass = computed(() =>
|
||||
cn(
|
||||
'overflow-hidden rounded-[var(--p-panel-border-radius)]',
|
||||
isDocked.value
|
||||
? 'border-none bg-transparent'
|
||||
: 'border border-interface-stroke bg-comfy-menu-bg shadow-interface'
|
||||
? 'p-0 static border-none bg-transparent'
|
||||
: 'fixed shadow-interface'
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Common/StatusBadge',
|
||||
component: StatusBadge,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
severity: {
|
||||
control: 'select',
|
||||
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['label', 'dot', 'circle']
|
||||
}
|
||||
},
|
||||
args: {
|
||||
label: 'Status',
|
||||
severity: 'default'
|
||||
}
|
||||
} satisfies Meta<typeof StatusBadge>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
label: 'Failed',
|
||||
severity: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export const Finished: Story = {
|
||||
args: {
|
||||
label: 'Finished',
|
||||
severity: 'contrast'
|
||||
}
|
||||
}
|
||||
|
||||
export const Dot: Story = {
|
||||
args: {
|
||||
label: undefined,
|
||||
variant: 'dot',
|
||||
severity: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export const Circle: Story = {
|
||||
args: {
|
||||
label: '3',
|
||||
variant: 'circle'
|
||||
}
|
||||
}
|
||||
|
||||
export const AllSeverities: Story = {
|
||||
render: () => ({
|
||||
components: { StatusBadge },
|
||||
template: `
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusBadge label="Default" severity="default" />
|
||||
<StatusBadge label="Secondary" severity="secondary" />
|
||||
<StatusBadge label="Warn" severity="warn" />
|
||||
<StatusBadge label="Danger" severity="danger" />
|
||||
<StatusBadge label="Contrast" severity="contrast" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { StatusBadge },
|
||||
template: `
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge label="Label" variant="label" />
|
||||
<span class="text-xs text-muted">label</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge variant="dot" severity="danger" />
|
||||
<span class="text-xs text-muted">dot</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<StatusBadge label="5" variant="circle" />
|
||||
<span class="text-xs text-muted">circle</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,27 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { statusBadgeVariants } from './statusBadge.variants'
|
||||
import type { StatusBadgeVariants } from './statusBadge.variants'
|
||||
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
|
||||
|
||||
const {
|
||||
label,
|
||||
severity = 'default',
|
||||
variant
|
||||
} = defineProps<{
|
||||
label?: string | number
|
||||
severity?: StatusBadgeVariants['severity']
|
||||
variant?: StatusBadgeVariants['variant']
|
||||
const { label, severity = 'default' } = defineProps<{
|
||||
label: string
|
||||
severity?: Severity
|
||||
}>()
|
||||
|
||||
function badgeClasses(sev: Severity): string {
|
||||
const baseClasses =
|
||||
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
|
||||
|
||||
switch (sev) {
|
||||
case 'danger':
|
||||
return `${baseClasses} bg-destructive-background text-white`
|
||||
case 'contrast':
|
||||
return `${baseClasses} bg-base-foreground text-base-background`
|
||||
case 'warn':
|
||||
return `${baseClasses} bg-warning-background text-base-background`
|
||||
case 'secondary':
|
||||
return `${baseClasses} bg-secondary-background text-base-foreground`
|
||||
default:
|
||||
return `${baseClasses} bg-primary-background text-base-foreground`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
statusBadgeVariants({
|
||||
severity,
|
||||
variant: variant ?? (label == null ? 'dot' : 'label')
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<span :class="badgeClasses(severity)">{{ label }}</span>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
<div
|
||||
v-for="item in renderedItems"
|
||||
:key="item.key"
|
||||
class="transition-[width] duration-150 ease-out"
|
||||
data-virtual-grid-item
|
||||
>
|
||||
<div ref="container" class="scroll-container">
|
||||
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" />
|
||||
<div :style="gridStyle">
|
||||
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item>
|
||||
<slot name="item" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
<div :style="bottomSpacerStyle" />
|
||||
<div
|
||||
:style="{
|
||||
height: `${((items.length - state.end) / cols) * itemHeight}px`
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -32,22 +28,19 @@ type GridState = {
|
||||
|
||||
const {
|
||||
items,
|
||||
gridStyle,
|
||||
bufferRows = 1,
|
||||
scrollThrottle = 64,
|
||||
resizeDebounce = 64,
|
||||
defaultItemHeight = 200,
|
||||
defaultItemWidth = 200,
|
||||
maxColumns = Infinity
|
||||
defaultItemWidth = 200
|
||||
} = defineProps<{
|
||||
items: (T & { key: string })[]
|
||||
gridStyle: CSSProperties
|
||||
gridStyle: Partial<CSSProperties>
|
||||
bufferRows?: number
|
||||
scrollThrottle?: number
|
||||
resizeDebounce?: number
|
||||
defaultItemHeight?: number
|
||||
defaultItemWidth?: number
|
||||
maxColumns?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -66,18 +59,7 @@ const { y: scrollY } = useScroll(container, {
|
||||
eventListenerOptions: { passive: true }
|
||||
})
|
||||
|
||||
const cols = computed(() =>
|
||||
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
|
||||
)
|
||||
|
||||
const mergedGridStyle = computed<CSSProperties>(() => {
|
||||
if (maxColumns === Infinity) return gridStyle
|
||||
return {
|
||||
...gridStyle,
|
||||
gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))`
|
||||
}
|
||||
})
|
||||
|
||||
const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1)
|
||||
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
|
||||
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
|
||||
const isValidGrid = computed(() => height.value && width.value && items?.length)
|
||||
@@ -101,16 +83,6 @@ const renderedItems = computed(() =>
|
||||
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
|
||||
)
|
||||
|
||||
function rowsToHeight(rows: number): string {
|
||||
return `${(rows / cols.value) * itemHeight.value}px`
|
||||
}
|
||||
const topSpacerStyle = computed<CSSProperties>(() => ({
|
||||
height: rowsToHeight(state.value.start)
|
||||
}))
|
||||
const bottomSpacerStyle = computed<CSSProperties>(() => ({
|
||||
height: rowsToHeight(items.length - state.value.end)
|
||||
}))
|
||||
|
||||
whenever(
|
||||
() => state.value.isNearEnd,
|
||||
() => {
|
||||
@@ -137,6 +109,15 @@ const onResize = debounce(updateItemSize, resizeDebounce)
|
||||
watch([width, height], onResize, { flush: 'post' })
|
||||
whenever(() => items, updateItemSize, { flush: 'post' })
|
||||
onBeforeUnmount(() => {
|
||||
onResize.cancel()
|
||||
onResize.cancel() // Clear pending debounced calls
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--dialog-surface) transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const statusBadgeVariants = cva({
|
||||
base: 'inline-flex items-center justify-center rounded-full',
|
||||
variants: {
|
||||
severity: {
|
||||
default: 'bg-primary-background text-base-foreground',
|
||||
secondary: 'bg-secondary-background text-base-foreground',
|
||||
warn: 'bg-warning-background text-base-background',
|
||||
danger: 'bg-destructive-background text-white',
|
||||
contrast: 'bg-base-foreground text-base-background'
|
||||
},
|
||||
variant: {
|
||||
label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
|
||||
dot: 'size-2',
|
||||
circle: 'size-3.5 text-xxxs font-semibold'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
severity: 'default',
|
||||
variant: 'label'
|
||||
}
|
||||
})
|
||||
|
||||
export type StatusBadgeVariants = VariantProps<typeof statusBadgeVariants>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="shouldShow"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-[3px]"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
|
||||
:style="{ width: `${totalPercent}%` }"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
|
||||
:style="{ width: `${currentNodePercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
|
||||
const { hidden = false } = defineProps<{
|
||||
hidden?: boolean
|
||||
}>()
|
||||
|
||||
const { totalPercent, currentNodePercent } = useQueueProgress()
|
||||
|
||||
const shouldShow = computed(
|
||||
() => !hidden && (totalPercent.value > 0 || currentNodePercent.value > 0)
|
||||
)
|
||||
</script>
|
||||
@@ -1,62 +0,0 @@
|
||||
<template>
|
||||
<div v-if="shouldShow" class="flex justify-end">
|
||||
<div
|
||||
class="flex items-center gap-4 whitespace-nowrap text-[0.75rem] leading-[normal] drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div class="flex items-center gap-1 text-base-foreground">
|
||||
<span class="font-normal">
|
||||
{{ t('sideToolbar.queueProgressOverlay.inlineTotalLabel') }}:
|
||||
</span>
|
||||
<span class="w-[5ch] shrink-0 text-right font-bold tabular-nums">
|
||||
{{ totalPercentFormatted }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 text-muted-foreground">
|
||||
<span
|
||||
class="w-[16ch] shrink-0 truncate text-right"
|
||||
:title="currentNodeName"
|
||||
>
|
||||
{{ currentNodeName }}:
|
||||
</span>
|
||||
<span class="w-[5ch] shrink-0 text-right tabular-nums">
|
||||
{{ currentNodePercentFormatted }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentNodeName } from '@/composables/queue/useCurrentNodeName'
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
const { hidden = false } = defineProps<{
|
||||
hidden?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionStore = useExecutionStore()
|
||||
const { currentNodeName } = useCurrentNodeName()
|
||||
const {
|
||||
totalPercent,
|
||||
totalPercentFormatted,
|
||||
currentNodePercent,
|
||||
currentNodePercentFormatted
|
||||
} = useQueueProgress()
|
||||
|
||||
const shouldShow = computed(
|
||||
() =>
|
||||
!hidden &&
|
||||
(!executionStore.isIdle ||
|
||||
totalPercent.value > 0 ||
|
||||
currentNodePercent.value > 0)
|
||||
)
|
||||
</script>
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
@@ -43,7 +43,7 @@ const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const isEditing = ref(false)
|
||||
|
||||
const widgetComponent = computed(() => {
|
||||
const component = getComponent(widget.type)
|
||||
const component = getComponent(widget.type, widget.name)
|
||||
return component || WidgetLegacy
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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<{
|
||||
|
||||