Compare commits

...

41 Commits

Author SHA1 Message Date
Alexander Brown
a05a18e3c0 Vite migrate 2026-02-05 15:26:55 -08:00
Alexander Brown
9faabd80e2 Update vite to beta.12 2026-02-03 10:57:34 -08:00
Alexander Brown
09d63c5ba3 deps: Update vite 2026-02-03 10:51:45 -08:00
Johnpaul Chiwetelu
b2c8ea3e50 refactor: use emit events pattern instead of mutating props in widget components (#8549) 2026-02-03 11:21:14 +01:00
Terry Jia
f9af2cc4bd feat: add aspect ratio lock for ImageCrop widget (#8533)
## Summary

Add ratio preset dropdown (1:1, 3:4, 4:3, 16:9, 9:16, custom) and lock
toggle to the ImageCrop widget. When locked, only corner handles are
shown and resizing maintains the selected aspect ratio using diagonal
projection for smooth, jump-free interaction.
Locking with custom selected captures the current crop's ratio.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/a7b5c0a0-c18c-4785-940f-59793702e892

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8533-feat-add-aspect-ratio-lock-for-ImageCrop-widget-2fa6d73d365081a98546e43ed0d7d4fe)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Image crop widget adds a ratio selector with preset and custom
options, plus a lock/unlock toggle to preserve aspect while cropping.
* Constrained corner-resize behavior enforces locked aspect ratios
during resizing.

* **Documentation**
* Added UI text entries for ratio controls (labels for ratio,
lock/unlock, custom).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-03 04:24:33 -05:00
Christian Byrne
a3fba58c79 refactor: move cancellable states to module-level constant (#8570)
## Summary

Moves static data outside the `useJobActions` composable to
module-level.

## Changes

- **What**: Extracted `cancellableStates` array to a module-level
`CANCELLABLE_STATES` constant. The `cancelAction` object remains inside
the composable because it depends on `t()` for i18n reactivity.

## Review Focus

This is a pure refactor with no behavior change - the constant is now
allocated once per module instead of per-composable-call.

Fixes #7946

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8570-refactor-move-cancellable-states-to-module-level-constant-2fc6d73d3650814fa1fefa172f7ace2f)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
* Internal code refactoring to improve maintainability of job
cancellation logic.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-02 21:46:12 -08:00
Christian Byrne
ae7728cd91 feat: add welcome message for Linear Mode (#8562)
## Summary

Adds a welcome/onboarding message for App Mode (Linear Mode) that
displays when no workflow output is selected, targeting first-time
users.


| Before | After |
|
---------------------------------------------------------------------------------------------------------------------------------------------
|
---------------------------------------------------------------------------------------------------------------------------------------------
|
| <img width="1914" height="1084" alt="Screenshot from 2026-02-02
20-47-30"
src="https://github.com/user-attachments/assets/f3693422-b6ee-496a-a354-d9cede501330"
/> | <img width="1919" height="1085" alt="Screenshot from 2026-02-02
21-32-20"
src="https://github.com/user-attachments/assets/dbfda4d0-251c-4099-ad36-3d7c0220d264"
/> |


## Changes

- **LinearWelcome.vue**: New component with 4 sections explaining App
Mode interface, sharing workflows, and widget control
- **LinearPreview.vue**: Replace dimmed logo fallback with LinearWelcome
component
- **i18n**: Add `linearMode.welcome.*` translation keys

## Review Focus

- Copy targets users who have never used ComfyUI—assumes no knowledge of
nodes/graphs
- Component uses semantic Tailwind classes from design system

Fixes COM-14274

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8562-feat-add-welcome-message-for-Linear-Mode-2fc6d73d365081b2b736f211152e1cce)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a comprehensive welcome screen in App Mode that introduces users
to the simplified interface hiding node graphs, explains the layout
structure with outputs and controls, provides instructions for sharing
workflows as simple tools, and guides users on customizing which
settings are visible through widget promotion.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-02 21:44:30 -08:00
Jin Yi
5847071bc1 [feat] Add node replacement store and types (#8364)
## Summary
Add infrastructure for automatic node replacement feature that allows
missing/deprecated nodes to be replaced with their newer equivalents.

## Changes
- **Types**: `NodeReplacement`, `NodeReplacementResponse` types matching
backend API spec (PR #12014)
- **Service**: `fetchNodeReplacements()` API wrapper
- **Store**: `useNodeReplacementStore` with `getReplacementFor()`,
`hasReplacement()`, `isEnabled()`
- **Setting**: `Comfy.NodeReplacement.Enabled` toggle (experimental)
- **Tests**: 11 unit tests covering store functionality

## Related
- Backend PR: Comfy-Org/ComfyUI#12014

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8364-feat-Add-node-replacement-store-and-types-2f66d73d3650816bb771c9cc6a8e1774)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Automatic node replacement is now available, allowing missing nodes to
be replaced with their newer equivalents when replacement mappings
exist.
* Added "Enable automatic node replacement" experimental setting in
Workflow preferences (enabled by default).
  * Replacement data is loaded during app initialization.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-03 14:15:05 +09:00
Christian Byrne
cc10684c19 feat: enable linear mode toggle for nightly builds (#8569)
Enables the linear mode toggle for all nightly build users by
short-circuiting the `linearToggleEnabled` feature flag.

## Changes
- Adds `isNightly` check in `linearToggleEnabled` getter  
- Returns `true` for nightly builds, bypassing remote config/server
feature checks
- Adds unit tests for the new behavior

## Reviewers
- @AustinMroz (linear mode maintainer)
- @christian-byrne (isNightly author)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8569-feat-enable-linear-mode-toggle-for-nightly-builds-2fc6d73d3650819681f8dcdc23b6eefe)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Enabled linear toggle feature for nightly distribution builds.
* Feature flag system now respects nightly vs. standard build
configurations.

* **Tests**
* Added test coverage for nightly build feature flag behavior and remote
configuration handling.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-02 19:54:50 -08:00
Christian Byrne
380db3ee10 chore: add deprecation warning for legacy node templates extension (#8463)
## Summary

Adds the legacy `nodeTemplates` extension to the `DEPRECATED_FILES` list
in the build plugin, causing a console warning when external extensions
import from this file.

## Changes

Adds `'extensions/core/nodeTemplates'` to `DEPRECATED_FILES` in
`build/plugins/comfyAPIPlugin.ts`.

## Effect

When an external extension imports from
`extensions/core/nodeTemplates.js`, they will see:
```
[ComfyUI Deprecated] Importing from "extensions/core/nodeTemplates.js" is deprecated and will be removed in v1.34.
```

## Related

- Follows pattern from PR #6090 (feat: deprecated API alert)
- Related issue: #4056 (Migrate Node Templates to Workflows)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8463-chore-add-deprecation-warning-for-legacy-node-templates-extension-2f86d73d3650811e9f53fa3e5a572332)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 19:22:07 -08:00
Alexander Brown
cbdc7d030f feat: code splitting optimization - reduce initial bundle by 36% (#8542)
## Summary

Reduces initial module preload from 12.94 MB to 8.24 MB (-4.7 MB, -36%).

## Changes

- Split vendor chunks for better cache isolation (firebase, sentry,
i18n, zod, etc.)
- Exclude heavy optional chunks from initial preload: THREE.js, xterm,
tiptap, chart.js, yjs
- Lazy load 16 dialog components in dialogService
- Add \endor-yjs\ chunk for CRDT library

## Metrics

| Metric | Before | After |
|--------|--------|-------|
| Initial preload | 12.94 MB | 8.24 MB |
| vendor-other | 4,006 KB | 2,156 KB |
| dialogService | 1,860 KB | 1,304 KB |

All excluded chunks still load on-demand when their features are used.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8542-feat-code-splitting-optimization-reduce-initial-bundle-by-36-2fb6d73d36508146aaf7fdaed3274033)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-02 19:05:28 -08:00
Alexander Brown
21492ecca5 fix: update imports for VueUse v14 compatibility (#8550)
Update imports for VueUse v14 compatibility:
- `toValue` → import from `vue` (dropped from VueUse v14)
- `MaybeRef` type → import from `vue` (dropped from VueUse v14)

These APIs were deprecated in VueUse v12 and removed in v14 in favor of
Vue's native exports.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8550-fix-update-imports-for-VueUse-v14-compatibility-2fb6d73d365081b0bac1d01d4092c176)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Updated VueUse dependencies to newer versions for improved
compatibility
  * Minor package entry reorganization (no functional changes)

* **Refactor**
  * Consolidated imports to use native Vue utilities where applicable

* **Tests**
* Updated unit tests: improved outside-click simulation and cleanup;
migrated tests to use the real store setup for more realistic test
behavior
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 18:58:14 -08:00
Christian Byrne
22755d2cb2 fix: display active jobs in oldest-first order in media assets panel (#8561)
## Summary

Active jobs (pending/running) in the media assets panel now display in
FIFO order - oldest jobs first (first to be processed at top). This
matches the queue processing order and the old queue panel behavior.

## Changes

- **AssetsSidebarListView.vue**: Add `.toReversed()` to `activeJobItems`
computed to reverse job order for display
- **AssetsSidebarGridView.vue**: Same change for grid view consistency
- **AssetsSidebarListView.test.ts**: Unit test verifying oldest-first
ordering

## Root Cause

PR #8225 changed sorting from `queueIndex` to `createTime` descending in
`useJobList.ts`, which placed newest jobs first. For active jobs, users
expect oldest first (FIFO - first to be processed appears at top).

## Solution

Rather than modifying the shared `useJobList` composable (which serves
both the assets panel and queue overlay), the fix applies
`.toReversed()` at the view layer for the active jobs section only.
This:
- Preserves the newest-first order for completed/history jobs
- Displays active jobs in oldest-first order
- Maintains backward compatibility with the queue overlay

## Testing

- Unit test added to verify FIFO ordering of active jobs
- All existing `useJobList` tests pass

Fixes COM-14151

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Corrected the display order of active jobs in the sidebar to show jobs
in oldest-first (FIFO) sequence.

* **Tests**
* Added unit tests for the assets sidebar list view to verify job
ordering and filtering behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8561-fix-display-active-jobs-in-oldest-first-order-in-media-assets-panel-2fc6d73d365081c6bf31cb076a8d6014)
by [Unito](https://www.unito.io)
2026-02-02 18:57:42 -08:00
Luke Mino-Altherr
15f4f11ee2 feat: remove obsolete model asset feature flags (#8566)
## Summary
Remove feature flags that are now enabled for all users and clean up
dead code paths.

## Changes

**Removed feature flags:**
- `huggingfaceModelImportEnabled` - always show generic URL input with
both Civitai/HuggingFace support
- `assetDeletionEnabled` - delete button always shown for mutable assets
- `asyncModelUploadEnabled` - always use async upload path (removed sync
upload and file size warnings)

**Kept feature flags:**
- `modelUploadButtonEnabled` - controls upload button visibility (local
vs cloud)
- `privateModelsEnabled` - controls upgrade modal vs upload dialog

**Deleted unused component:**
- `UploadModelUrlInputCivitai.vue` (replaced by generic input)

## Testing
- `pnpm typecheck` passes
- `pnpm lint` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8566-feat-remove-obsolete-model-asset-feature-flags-2fc6d73d365081149ec6e78d995c8a44)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Improvements**
  * Asset deletion option is now always available
  * Model upload experience simplified with unified import flow
  * Model uploads now consistently use asynchronous processing
  * HuggingFace model imports are now always supported

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 18:53:41 -08:00
Luke Mino-Altherr
bc19bb60fb feat: add user secrets management panel (#8473)
## Summary

Add a Secrets panel to user settings for managing third-party API keys
(HuggingFace, Civitai). Secrets are encrypted server-side; plaintext
values are never returned.

## Changes

- Add `SecretsPanel` to settings for managing third-party API keys
- Create `secretsApi` service following the `workspaceApi` pattern
- Add `SecretListItem` and `SecretFormDialog` components
- Add `user_secrets_enabled` feature flag
- Support HuggingFace and Civitai providers
- Add i18n translations for secrets UI

## Files Added

- `src/platform/secrets/types.ts` - TypeScript types
- `src/platform/secrets/api/secretsApi.ts` - Axios-based API service
- `src/platform/secrets/components/SecretsPanel.vue` - Main settings
panel
- `src/platform/secrets/components/SecretListItem.vue` - Individual
secret row
- `src/platform/secrets/components/SecretFormDialog.vue` - Create/edit
dialog

## Files Modified

- `src/platform/remoteConfig/types.ts` - Add `user_secrets_enabled` flag
type
- `src/composables/useFeatureFlags.ts` - Add flag getter
- `src/platform/settings/composables/useSettingUI.ts` - Integrate
secrets panel
- `src/locales/en/main.json` - Add translations

## Testing

Panel appears in Settings under:
- "Workspace" group when team workspaces is enabled
- "Account" group in legacy mode

Only visible when user is logged in AND `user_secrets_enabled` feature
flag is enabled.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8473-feat-add-user-secrets-management-panel-2f86d73d36508187b4a1ed04ce07ce51)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Secrets management UI in Settings with create/edit/delete for API
keys/secrets; settings dialog entry and contextual Secrets hint in
upload/import flows (feature-flag gated).
* **APIs**
* Added backend-facing secrets CRUD surface and client-side
form/composable support for managing secrets.
* **Localization**
* New English translations for Secrets UI and many expanded asset
import/upload error and hint messages.
* **Tests**
* Comprehensive unit tests for secrets UI, form flows, and composables.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 17:28:58 -08:00
Christian Byrne
139ee32d78 fix: properly parse PNG iTXt chunks per specification (#8530)
## Summary
Fixes PNG iTXt chunk parsing to comply with the PNG specification.

## Problem
The current iTXt parser incorrectly reads the text content immediately
after the keyword null terminator, but iTXt chunks have additional
fields:
- Compression flag (1 byte)
- Compression method (1 byte)
- Language tag (null-terminated)
- Translated keyword (null-terminated)
- Text content

This caused PNGs that correctly follow the spec to fail loading with
JSON parse errors due to leading null bytes.

## Solution
Skip the compression flag, method, language tag, and translated keyword
fields before reading the text content.

Fixes #8150

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8530-fix-properly-parse-PNG-iTXt-chunks-per-specification-2fa6d73d36508189bef4cc5fa3899096)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-02 17:12:11 -08:00
Comfy Org PR Bot
7928e8797d 1.39.5 (#8535)
Patch version increment to 1.39.5

**Base branch:** `main`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added and updated localized UI text across multiple languages: new
assetBrowser.baseModel and sortDefault labels, nodeFilters group (filter
labels/descriptions), templates.logoProviderSeparator, and
legacyManagerSearchTip; unified account-deletion messaging to a
contactSupport entry.
* **Chores**
  * Updated front-end version to 1.39.5.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-02 16:20:51 -08:00
Benjamin Lu
cd6cc7a51d fix: make custom nodes checkbox optional (#8546)
## Summary

Lower the barrier for bug reports by making custom-node info optional
rather than a required prerequisite. Many users run custom nodes and can
still hit core or interaction bugs that aren't caused by those nodes;
even when custom nodes are involved, the issue may still be ours, so
this change unblocks reports and feature requests while preserving
context.

## Changes

- **What**: Replace the required "tested with all custom nodes disabled"
checkbox with a neutral "I'm using custom nodes" checkbox to capture
context without blocking submissions.

## Review Focus

Confirm the new checkbox wording captures custom-node context without
discouraging reports from users who can't or shouldn't disable custom
nodes.

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8546-fix-make-custom-nodes-checkbox-optional-2fb6d73d3650816fa743e355f1877de1)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Updated the bug report issue template to clarify prerequisites: the
primary prerequisite remains required, while the custom-nodes option is
now optional. This reduces mandatory checklist items and improves
guidance on which setup details are essential for effective bug reports.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-02 13:08:51 -08:00
Benjamin Lu
aa979aa98c Enable CodeRabbit issue enrichment (#8543)
## Summary

Enable CodeRabbit issue enrichment and remove duplicate-check
requirements to lower the barrier for reporting problems.

## Changes

- **What**: Add `.coderabbit.yaml` enabling issue enrichment; remove
dedupe checkboxes from bug and feature issue templates.

## Review Focus

Confirm we want CodeRabbit/Dosu to handle deduplication so users aren’t
blocked by a mandatory “search for duplicates” step.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8543-Enable-CodeRabbit-issue-enrichment-2fb6d73d36508132947acf5f01c9cb22)
by [Unito](https://www.unito.io)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
  * Enabled automatic issue enrichment to enhance issue tracking.
* Streamlined issue submission forms by removing prerequisite
verification steps from bug report and feature request templates.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-02 12:06:34 -08:00
Christian Byrne
cc0307032a deprecate: add warning for unsupported widgets_up property (#8541)
## Summary
Adds deprecation warning for the `widgets_up` property which has known
layout bugs and is not officially supported.

## Changes
- Added `@deprecated` JSDoc comment on `widgets_up` property
- Added console warning during configure path
- Warning directs users to use `widgets_start_y` or custom `arrange()`
override instead

## Context
The `widgets_up` property is an undocumented legacy LiteGraph feature
that positions widgets at the top of nodes. However, the `arrange()`
method doesn't properly handle slot positioning when this is enabled,
causing widgets and slots to overlap (see #7878).

Rather than fix this edge case in an unsupported feature, we're formally
deprecating it with a warning.

- Closes #7878

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8541-deprecate-add-warning-for-unsupported-widgets_up-property-2fb6d73d365081a89bb6e41038d94455)
by [Unito](https://www.unito.io)
2026-02-02 11:53:28 -08:00
Christian Byrne
bc3b9585f9 fix: localize node definition filter names and descriptions (#8540)
## Summary

Localizes node definition filter names and descriptions using vue-i18n,
following the project's coding guidelines.

## Changes

- Updated `registerCoreNodeDefFilters()` in `src/stores/nodeDefStore.ts`
to use `t()` function calls
- Added translation keys under `nodeFilters` namespace in
`src/locales/en/main.json`

## Affected Filters

| Filter ID | Name |
|-----------|------|
| `core.deprecated` | Hide Deprecated Nodes |
| `core.experimental` | Hide Experimental Nodes |
| `core.dev_only` | Hide Dev-Only Nodes |
| `core.subgraph` | Hide Subgraph Nodes |

Fixes #8390

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8540-fix-localize-node-definition-filter-names-and-descriptions-2fb6d73d365081f29506ed804c233b5a)
by [Unito](https://www.unito.io)
2026-02-02 11:48:00 -08:00
Christian Byrne
1af9e00dd2 fix: add Frame Nodes to core menu items for multi-selection context menu (#8524)
## Summary

Fix "Frame Nodes" option being incorrectly categorized as an extension
item instead of appearing in the core menu section when multiple nodes
are selected.

## Changes

- **What**: Added "Frame Nodes" to `CORE_MENU_ITEMS` set and
`MENU_ORDER` array in contextMenuConverter.ts, and added frame
equivalents to duplicate detection to prevent duplicate menu items

## Review Focus

The fix ensures the Vue-based "Frame Nodes" menu option (i18n key
`g.frameNodes`) is recognized as a core menu item alongside the legacy
LiteGraph "Frame selection" label. Both are now treated as equivalent
for deduplication.

Fixes COM-13922

## Screenshots (if applicable)

N/A - menu item positioning fix

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8524-fix-add-Frame-Nodes-to-core-menu-items-for-multi-selection-context-menu-2fa6d73d365081989799f7bc74c65868)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 11:46:55 -08:00
Johnpaul Chiwetelu
2f8bd7b04f Road to No Explicit Any Part 9 (#8498)
## Summary

This PR removes `any` types from core source files and replaces them
with proper TypeScript types.

### Key Changes

#### Type Safety Improvements
- Replaced `any` with `unknown`, explicit types, or proper interfaces
across core files
- Introduced new type definitions: `SceneConfig`, `Load3DNode`,
`ElectronWindow`
- Used `Object.assign` instead of `as any` for dynamic property
assignment
- Replaced `as any` casts with proper type assertions

### Files Changed

Source files:
- src/extensions/core/widgetInputs.ts - Removed unnecessary `as any`
cast
- src/platform/cloud/onboarding/auth.ts - Used `Record<string, unknown>`
and Sentry types
- src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts -
Used `AuditLog[]` type
- src/platform/workflow/management/stores/workflowStore.ts - Used
`typeof ComfyWorkflow` constructor type
- src/scripts/app.ts - Used `ResultItem[]` for Clipspace images
- src/services/colorPaletteService.ts - Used `Object.assign` instead of
`as any`
- src/services/customerEventsService.ts - Used `unknown` instead of
`any`
- src/services/load3dService.ts - Added proper interface types for
Load3D nodes
- src/types/litegraph-augmentation.d.ts - Used `TWidgetValue[]` type
- src/utils/envUtil.ts - Added ElectronWindow interface
- src/workbench/extensions/manager/stores/comfyManagerStore.ts - Typed
event as `CustomEvent<{ ui_id?: string }>`

### Testing
- All TypeScript type checking passes (`pnpm typecheck`)
- Linting passes without errors (`pnpm lint`)
- Code formatting applied (`pnpm format`)

Part of the "Road to No Explicit Any" initiative.

### Previous PRs in this series:
- Part 2: #7401
- Part 3: #7935
- Part 4: #7970
- Part 5: #8064
- Part 6: #8083
- Part 7: #8092
- Part 8 Group 1: #8253
- Part 8 Group 2: #8258
- Part 8 Group 3: #8304
- Part 8 Group 4: #8314
- Part 8 Group 5: #8329
- Part 8 Group 6: #8344
- Part 8 Group 7: #8459
- Part 8 Group 8: #8496
- Part 9: #8498 (this PR)
2026-02-02 17:30:49 +01:00
Johnpaul Chiwetelu
cfdd002b7c Road to No Explicit Any Part 10 (#8499)
## Summary

This PR removes `any` types from UI component files and replaces them
with proper TypeScript types.

### Key Changes

#### Type Safety Improvements
- Replaced `any` with `unknown`, explicit types, or proper interfaces
across UI components
- Used `ComponentPublicInstance` with explicit method signatures for
component refs
- Used `Record<string, unknown>` for dynamic property access
- Added generics for form components with flexible value types
- Used `CSSProperties` for style objects

### Files Changed

UI Components:
- src/components/common/ComfyImage.vue - Used proper class prop type
- src/components/common/DeviceInfo.vue - Used `string | number` for
formatValue
- src/components/common/FormItem.vue - Used `unknown` for model value
- src/components/common/FormRadioGroup.vue - Added generic type
parameter
- src/components/common/TreeExplorer.vue - Used proper async function
signature
- src/components/custom/widget/WorkflowTemplateSelectorDialog.vue -
Fixed duplicate import
- src/components/graph/CanvasModeSelector.vue - Used
`ComponentPublicInstance` for ref
- src/components/node/NodePreview.vue - Changed `any` to `unknown`
- src/components/queue/job/JobDetailsPopover.vue - Removed unnecessary
casts
- src/components/queue/job/JobFiltersBar.vue - Removed `as any` casts
- src/platform/assets/components/MediaAssetContextMenu.vue - Added
`ContextMenuInstance` type
- src/renderer/extensions/minimap/MiniMapPanel.vue - Used
`CSSProperties`
- src/renderer/extensions/vueNodes/composables/useNodeTooltips.ts -
Added `PrimeVueTooltipElement` interface
-
src/renderer/extensions/vueNodes/widgets/components/form/FormSelectButton.vue
- Used `Record<string, unknown>`
-
src/workbench/extensions/manager/components/manager/infoPanel/tabs/DescriptionTabPanel.vue
- Added `LicenseObject` interface

### Testing
- All TypeScript type checking passes (`pnpm typecheck`)
- Linting passes without errors (`pnpm lint`)

Part of the "Road to No Explicit Any" initiative.

### Previous PRs in this series:
- Part 2: #7401
- Part 3: #7935
- Part 4: #7970
- Part 5: #8064
- Part 6: #8083
- Part 7: #8092
- Part 8 Group 1: #8253
- Part 8 Group 2: #8258
- Part 8 Group 3: #8304
- Part 8 Group 4: #8314
- Part 8 Group 5: #8329
- Part 8 Group 6: #8344
- Part 8 Group 7: #8459
- Part 8 Group 8: #8496
- Part 9: #8498
- Part 10: #8499

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8499-Road-to-No-Explicit-Any-Part-10-2f86d73d365081aab129f165c7d02434)
by [Unito](https://www.unito.io)
2026-02-02 17:16:40 +01:00
Benjamin Lu
7dd3098af5 fix: use NodeId for output key parts (#8547)
## Summary

Use the shared NodeId type for output asset key construction to keep
node id typing consistent across assets and workflow schemas.

## Changes

- **What**: replace the local `string | number` nodeId type in output
key parts with the shared `NodeId` alias

## Review Focus

Type consistency for `nodeId` used in output asset key generation.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8547-fix-use-NodeId-for-output-key-parts-2fb6d73d36508127a022cc6052e5f59e)
by [Unito](https://www.unito.io)
2026-02-02 03:42:07 -08:00
Benjamin Lu
2740c7cdd5 Add expandable output stacks to assets list view (#8283)
Add expandable output stacks to the assets list view.

Monolith ver. of https://github.com/Comfy-Org/ComfyUI_frontend/pull/8298
and its children

List view currently collapses multi-output jobs into a single row, which
makes sibling outputs easy to miss and causes selection/zoom behavior to
drift once items are expanded elsewhere. This change adds a stack toggle
to list rows, expands child outputs derived from job data, and keeps
list-view selection and gallery navigation aligned with the expanded
list. Output mapping and “load full outputs” checks are centralized so
folder view and stacks share the same helper, and job-detail parsing now
yields previewable outputs for the list view. Asset actions now prefer
metadata prompt IDs to support the composite IDs used by stacked
outputs.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8283-Add-expandable-output-stacks-to-assets-list-view-2f16d73d365081a99fc6f1519ac2e57c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2026-02-02 03:31:01 -08:00
Comfy Org PR Bot
22daf48748 docs: Weekly Documentation Update (#7849)
# Documentation Audit and Corrections

## Summary

- Fixed incorrect test directory reference in AGENTS.md (`tests-ui/` →
removed)
- Updated outdated timestamps in composables and stores READMEs
- Fixed critical workflow name error in create-frontend-release command
- Removed misleading i18n workflow warnings from release documentation

## Changes Made

### AGENTS.md
- **Line 24**: Removed non-existent `tests-ui/` directory from test file
locations
  - Unit/component tests are located in `src/**/*.test.ts` only
- **Line 267**: Removed `tests-ui` from repository navigation guidance

### src/composables/README.md
- **Line 88**: Updated timestamp from "2025-01-30" to "2026-01-30" to
reflect current state

### src/stores/README.md
- **Line 105**: Updated timestamp from "2025-09-01" to "2026-01-29" to
reflect last modification date

### .claude/commands/create-frontend-release.md
- **Line 394**: Fixed critical workflow name error
  - Changed `version-bump.yaml` → `release-version-bump.yaml`
  - This was causing command failures as the workflow file doesn't exist
- **Lines 446-450**: Removed outdated warning about `update-locales`
workflow adding `[skip ci]`
  - No such workflow exists in the repository
- Current i18n workflows are: `i18n-update-core.yaml`,
`i18n-update-custom-nodes.yaml`, `i18n-update-nodes.yaml`
  - None of these add `[skip ci]` to commits
- **Lines 464-465**: Removed duplicate critical warning about
non-existent `update-locales` workflow

## Review Notes

### Documentation Accuracy Verification Process

Conducted comprehensive fact-checking of all documentation against the
current codebase:

1. **Configuration Files**: Verified all referenced config files exist
(vite.config.mts, playwright.config.ts, eslint.config.ts, .oxfmtrc.json,
.oxlintrc.json)
2. **Package Scripts**: Validated all npm/pnpm commands referenced in
documentation
3. **Directory Structure**: Confirmed project structure matches
documented layout
4. **Extension Documentation**: Verified TypeScript interface paths and
external resource links
5. **Testing Documentation**: Confirmed test file patterns and framework
references
6. **Command Documentation**: Validated workflow names and GitHub CLI
commands

### Known Documentation Gaps (Not Addressed)

These items were identified but not changed as they represent
aspirational guidance or current migration paths:

1. **docs/guidance/vue-components.md**: Documents Vue 3.5 prop
destructuring pattern (`const { prop } = defineProps<>()`) while
codebase still uses `withDefaults()` in many files
   - This is intentional - guidance shows preferred pattern for new code
2. **docs/guidance/playwright.md**: Advises against `waitForTimeout` but
4 test files still use it
   - Existing violations are technical debt
3. **AGENTS.md Line 163**: States "Avoid new usage of PrimeVue
components" but some components still use PrimeVue
   - Guidance is for new code; existing usage being gradually migrated
4. **docs/guidance/typescript.md**: Discourages `as any` but 11
instances exist in codebase
   - Known technical debt

### Additional Findings

- All README files in key directories (composables, stores, services,
extensions, testing) are accurate and comprehensive
- External documentation links (Vue, Tailwind, VueUse, etc.) are valid
- Code examples in documentation are syntactically correct
- i18n structure and paths are correctly documented

## Verification Commands

To verify the fixes:

```bash
# Verify tests-ui directory doesn't exist
ls tests-ui 2>&1 | grep "No such file"

# Verify correct workflow file exists
ls .github/workflows/release-version-bump.yaml

# Verify no workflows add [skip ci]
grep -r "skip ci" .github/workflows/*.yaml || echo "None found (expected)"

# Verify test files are in src/
find src -name "*.test.ts" | wc -l  # Should show many test files
```

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-02 01:30:34 -08:00
Christian Byrne
2ec954459c fix: show Missing Nodes dialog for missing node prompt errors (#8511)
## Summary
When a workflow is queued with a missing node type, show the Missing
Nodes dialog instead of a generic error toast. This gives users
actionable options like "Open Manager" and "Install All".

## Changes
- Detect `missing_node_type` error from backend in `queuePrompt()` catch
block
- Construct `MissingNodeType` object with class type and contextual hint
- Reuse existing `showMissingNodesError()` to trigger the dialog

## Dependencies
⚠️ **Requires backend PR:**
https://github.com/Comfy-Org/ComfyUI/pull/12177

The backend PR changes the error type from `invalid_prompt` to
`missing_node_type` and adds `extra_info` with node details.

## Related
- Fixes COM-12528
- Addresses ~49 GitHub issues with confusing "missing class_type" errors

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8511-fix-show-Missing-Nodes-dialog-for-missing-node-prompt-errors-2f96d73d3650812b95f0f08e51abaabb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 01:28:17 -08:00
Terry Jia
4e9f581625 fix: align BOUNDING_BOX type with backend and target ImageCropV2 node (#8531)
## Summary
- Update imageCrop extension to target new ImageCropV2 comfyClass
- Fix BOUNDING_BOX io_type mismatch (was BOUNDINGBOX, backend sends
BOUNDING_BOX)
- Keep old ImageCrop node_id unchanged for backward compatibility

BE Changes is https://github.com/Comfy-Org/ComfyUI/pull/11594

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8531-fix-align-BOUNDING_BOX-type-with-backend-and-target-ImageCropV2-node-2fa6d73d36508160a06ad06108b8a15e)
by [Unito](https://www.unito.io)
2026-02-02 04:03:55 -05:00
Christian Byrne
d34d6a8a92 feat: show legacy manager search tip in no-results empty state (#8537)
## Summary

Show a non-intrusive tip in the no-results empty state when users search
legacy manager-related terms in the new manager UI.

## Changes

- **What**: Add `useLegacySearchTip` composable that detects when search
query matches legacy manager keywords ("manager", "comfyui-manager",
etc.) and appends a tip to the empty state message suggesting the
`--enable-manager-legacy-ui` flag
- **Integration**: Tip appears as secondary muted text below "Try a
different search" message when no results found
- **Tests**: 9 test cases covering detection logic and edge cases

## Review Focus

- Keyword list covers common legacy manager search terms
- Non-intrusive approach: tip only shows in no-results state, no dismiss
button needed

Fixes COM-12509
2026-02-01 23:43:42 -08:00
Christian Byrne
2a167a675d fix: use fileURL for static template logo index path (#8539)
## Summary

Fix template logo loading by using `api.fileURL()` instead of
`api.fetchApi()` for the static logo index file.

## Problem

`fetchLogoIndex()` was using
`api.fetchApi('/templates/index_logo.json')` which adds an `/api` prefix
to URLs, resulting in requests to `/api/templates/index_logo.json`
instead of `/templates/index_logo.json`.

Since `/templates/index_logo.json` is a static asset file (not an API
endpoint), it should use `api.fileURL()` which doesn't add the `/api`
prefix.

## Changes

- Change `api.fetchApi('/templates/index_logo.json')` to
`fetch(api.fileURL('/templates/index_logo.json'))`

## Testing

- Template logos should now load correctly in the workflow template
selector dialog

Fixes COM-14279

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8539-fix-use-fileURL-for-static-template-logo-index-path-2fb6d73d365081178788dc0cf196ada4)
by [Unito](https://www.unito.io)
2026-02-01 22:42:04 -08:00
Christian Byrne
e1385b0d18 refactor: use singleton mock pattern for useDialogStore in escape test (#8538)
## Summary

Refactors `keybindingService.escape.test.ts` to use the singleton mock
pattern with reactive state, following project testing guidance.

## Changes

- **What**: Replaced `vi.fn(() => ({dialogStack: []}))` anti-pattern
with `reactive()` array defined inline in `vi.mock()` factory. Tests now
use array mutation (`.push()`, `.length = 0`) instead of
`mockReturnValue` calls.

## Review Focus

Pattern aligns with docs/testing/unit-testing.md section "Mocking
Composables with Reactive State".

Fixes #8467

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8538-refactor-use-singleton-mock-pattern-for-useDialogStore-in-escape-test-2fb6d73d3650812a95c6e223e38d50ad)
by [Unito](https://www.unito.io)
2026-02-01 22:09:10 -08:00
Alexander Brown
eaa3ff1579 feat: add ownership and base model filtering, unify asset/dropdown types (#8497)
Add ownership and base model filtering to AssetBrowserModal and
FormDropdown widgets.

## Changes

- **Ownership filter**: Filter by All/My Models/Public Models (uses
`is_immutable` field)
- **Base model filter**: Multi-select filter with Clear Filters button
- **Type unification**: Replace `AssetDropdownItem` with
`FormDropdownItem`
- **Sorting unification**: Extract shared utilities to
`assetSortUtils.ts`
- **UI refactor**: Use `Button` component, Vue 3.5 prop shorthand, i18n
improvements

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-01 20:01:18 -08:00
Benjamin Lu
4e20b7522b Forward scroll unless focused (#6597)
## Summary

Forward wheel events to the canvas unless a wheel-capturing element is
focused, and ensure the Load3D scene becomes focusable on pointer
interaction so its wheel zoom/pan works after the user clicks into it.

## Changes

- **What**: gate wheel forwarding on focused capture elements; focus the
Load3D scene container on pointerdown to opt into wheel capture.
- **Dependencies**: none

## Review Focus

- Validate wheel forwarding behavior across focusable inputs vs.
non-focusable capture zones.
- Confirm Load3D zoom/pan only captures wheel after a user click (canvas
pan should still work when merely hovering).

## Screenshots (if applicable)

N/A

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-02-01 09:32:10 -05:00
Christian Byrne
544ef5bb70 fix: dedupe queueStore.update() to prevent race conditions (#8523)
## Summary

Prevents stale API responses from overwriting fresh queue state during
rapid consecutive `update()` calls.

## Problem

When `queueStore.update()` is called multiple times in quick succession
(e.g., during websocket reconnection), responses could resolve
out-of-order. A stale response resolving after a fresh one would
overwrite the current state with outdated data.

## Solution

Added request ID tracking that:
1. Increments a counter on each `update()` call
2. Guards all state mutations with a staleness check
3. Only sets `isLoading=false` for the latest request

This pattern matches the existing approach in `jobOutputCache.ts`.

## Changes

- `src/stores/queueStore.ts`: Added `updateRequestId` counter and
staleness guards
- `src/stores/queueStore.test.ts`: Added tests for deduplication
behavior

## Testing

- All 49 existing tests pass
- Added 3 new tests for race condition handling:
  - Stale responses are discarded
  - isLoading state is correctly managed
  - Stale request failures don't affect latest state

## Fixes COM-12784

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8523-fix-dedupe-queueStore-update-to-prevent-race-conditions-2fa6d73d3650810daa0dc63af359e1ea)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-31 22:01:14 -08:00
Alexander Brown
69129414d0 fix: use PR_GH_TOKEN to trigger e2e after updating expectations (#8525)
## Summary

See https://github.com/Comfy-Org/ComfyUI_frontend/pull/8484

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8525-fix-use-PR_GH_TOKEN-to-trigger-e2e-after-updating-expectations-2fa6d73d3650817c81fccd1cbb3ddadb)
by [Unito](https://www.unito.io)
2026-01-31 21:07:13 -08:00
Christian Byrne
1bbbcfedf0 feat: add provider logo overlays to workflow template thumbnails (#8365)
## Summary

Add support for overlaying provider logos on workflow template
thumbnails at runtime.

## Changes

- **What**: 
  - Add `LogoInfo` interface and `logos` field to `TemplateInfo` type
  - Create `LogoOverlay.vue` component for rendering positioned logos
  - Fetch logo index from `templates/index_logo.json` in store
  - Add `getLogoUrl` helper to `useTemplateWorkflows` composable
  - Integrate `LogoOverlay` into `WorkflowTemplateSelectorDialog`

## Review Focus

- Logo positioning uses Tailwind classes (e.g. `absolute bottom-2
right-2`)
- Supports multiple logos per template with configurable size/opacity
- Gracefully handles missing logos (returns empty string, renders
nothing)
- Templates must explicitly declare logos - no magic inference from
models

## Dependencies

Requires separate PR in workflow_templates repo to:
1. Update `index.schema.json` with logos definition
2. Add `logos` field to templates in `index.json`

## Screenshots (if applicable)

<img width="869" height="719" alt="image"
src="https://github.com/user-attachments/assets/65ed1ee4-fbb4-42c9-95d4-7e37813b3655"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8365-feat-add-provider-logo-overlays-to-workflow-template-thumbnails-2f66d73d365081309236c6b991cb6f7b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-31 19:18:13 -08:00
Christian Byrne
4f2872460c fix: remove delete account button and direct users to support (#8515)
## Summary
Reverts PR #5216 delete account functionality. The delete button only
removed Firebase accounts without canceling Stripe subscriptions,
causing orphaned accounts and support issues.

## Changes
- Removed delete account button from UserPanel.vue
- Added text directing users to contact support@comfy.org for account
deletion (clickable mailto: link)
- Cleaned up related code: removed `handleDeleteAccount` from
useCurrentUser.ts, `deleteAccount` from useFirebaseAuthActions.ts,
`_deleteAccount` from firebaseAuthStore.ts
- Updated en/main.json locale with `contactSupport` key using {email}
placeholder

## Testing
- Typecheck and lint pass
- Manual verification: user settings panel shows contact support text
instead of delete button

## Related Issues
Fixes COM-14243

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8515-fix-remove-delete-account-button-and-direct-users-to-support-2fa6d73d3650819dbc83efb41c07a809)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-01-31 19:14:44 -08:00
Comfy Org PR Bot
ea6928268f 1.39.4 (#8513)
Patch version increment to 1.39.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8513-1-39-4-2fa6d73d365081f1b8c2d5ae8e764eb3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-31 18:18:57 -08:00
Christian Byrne
4a85bffb1f fix: node header on preview has a gap on the right (not flush) (#8487)
## Summary

After changes:

[Screencast from 2026-01-30
00-34-59.webm](https://github.com/user-attachments/assets/9111b8ce-936d-4b30-8b56-6f44aabfe009)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8487-fix-node-header-on-preview-has-a-gap-on-the-right-not-flush-2f86d73d365081f38cabfca9ab9e04e4)
by [Unito](https://www.unito.io)
2026-01-31 15:57:38 -08:00
AustinMroz
be7c34e28b Update control_after_generate schema (#8505)
Updates `control_after_generate` in the schema to support specifying the
default control value as a string

See Comfy-Org/ComfyUI#12187

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8505-Update-control_after_generate-schema-2f96d73d365081f9bf73c804072bb415)
by [Unito](https://www.unito.io)
2026-01-30 21:34:50 -08:00
530 changed files with 10260 additions and 2474 deletions

View File

@@ -391,7 +391,7 @@ echo "Last stable release: $LAST_STABLE"
```bash
# Trigger the workflow
gh workflow run version-bump.yaml -f version_type=${VERSION_TYPE}
gh workflow run release-version-bump.yaml -f version_type=${VERSION_TYPE}
# Workflow runs quickly - usually creates PR within 30 seconds
echo "Workflow triggered. Waiting for PR creation..."
@@ -443,28 +443,21 @@ echo "Workflow triggered. Waiting for PR creation..."
gh pr view ${PR_NUMBER} --json labels | jq -r '.labels[].name' | grep -q "Release" || \
echo "ERROR: Release label missing! Add it immediately!"
```
2. Check for update-locales commits:
```bash
# WARNING: update-locales may add [skip ci] which blocks release workflow!
gh pr view ${PR_NUMBER} --json commits | grep -q "skip ci" && \
echo "WARNING: [skip ci] detected - release workflow may not trigger!"
```
3. Verify version number in package.json
4. Review all changed files
5. Ensure no unintended changes included
6. Wait for required PR checks:
2. Verify version number in package.json
3. Review all changed files
4. Ensure no unintended changes included
5. Wait for required PR checks:
```bash
gh pr checks ${PR_NUMBER} --watch
```
7. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
6. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
### Step 12: Pre-Merge Validation
1. **Review Requirements**: Release PRs require approval
2. Monitor CI checks - watch for update-locales
3. **CRITICAL WARNING**: If update-locales adds [skip ci], the release workflow won't trigger!
4. Check no new commits to main since PR creation
5. **DEPLOYMENT READINESS**: Ready to merge?
2. Monitor CI checks
3. Check no new commits to main since PR creation
4. **DEPLOYMENT READINESS**: Ready to merge?
### Step 13: Execute Release

3
.coderabbit.yaml Normal file
View File

@@ -0,0 +1,3 @@
issue_enrichment:
auto_enrich:
enabled: true

View File

@@ -10,10 +10,7 @@ body:
options:
- label: I am running the latest version of ComfyUI
required: true
- label: I have searched existing issues to make sure this isn't a duplicate
required: true
- label: I have tested with all custom nodes disabled ([see how](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled))
required: true
- label: I have custom nodes enabled
- type: textarea
id: description

View File

@@ -4,13 +4,6 @@ labels: []
type: Feature
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the problem you're experiencing, and that it's not addressed in a recent build/commit.
options:
- label: I have searched the existing issues and checked the recent builds/commits
required: true
- type: markdown
attributes:
value: |

View File

@@ -173,6 +173,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ needs.setup.outputs.branch }}
token: ${{ secrets.PR_GH_TOKEN }}
# Download all changed snapshot files from shards
- name: Download snapshot artifacts

2
.gitignore vendored
View File

@@ -96,3 +96,5 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
# .vite-plus*.tmp.json

View File

@@ -1,14 +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",
"public/materialdesignicons.min.css",
"src/types/generatedManagerTypes.ts",
"**/__fixtures__/**/*.json"
]
}

View File

@@ -1,115 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
".i18nrc.cjs",
".nx/*",
"**/vite.config.*.timestamp*",
"**/vitest.config.*.timestamp*",
"components.d.ts",
"coverage/*",
"dist/*",
"packages/registry-types/src/comfyRegistryTypes.ts",
"playwright-report/*",
"src/extensions/core/*",
"src/scripts/*",
"src/types/generatedManagerTypes.ts",
"src/types/vue-shim.d.ts",
"test-results/*",
"vitest.setup.ts"
],
"plugins": [
"eslint",
"import",
"oxc",
"typescript",
"unicorn",
"vitest",
"vue"
],
"rules": {
"no-async-promise-executor": "off",
"no-console": [
"error",
{
"allow": ["warn", "error"]
}
],
"no-control-regex": "off",
"no-eval": "off",
"no-redeclare": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "primevue/calendar",
"message": "Calendar is deprecated in PrimeVue 4+. Use DatePicker instead: import DatePicker from 'primevue/datepicker'"
},
{
"name": "primevue/dropdown",
"message": "Dropdown is deprecated in PrimeVue 4+. Use Select instead: import Select from 'primevue/select'"
},
{
"name": "primevue/inputswitch",
"message": "InputSwitch is deprecated in PrimeVue 4+. Use ToggleSwitch instead: import ToggleSwitch from 'primevue/toggleswitch'"
},
{
"name": "primevue/overlaypanel",
"message": "OverlayPanel is deprecated in PrimeVue 4+. Use Popover instead: import Popover from 'primevue/popover'"
},
{
"name": "primevue/sidebar",
"message": "Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from 'primevue/drawer'"
},
{
"name": "@/i18n--to-enable",
"importNames": ["st", "t", "te", "d"],
"message": "Don't import `@/i18n` directly, prefer `useI18n()`"
}
]
}
],
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
"no-useless-rename": "off",
"import/default": "error",
"import/export": "error",
"import/namespace": "error",
"import/no-duplicates": "error",
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"jest/expect-expect": "off",
"jest/no-conditional-expect": "off",
"jest/no-disabled-tests": "off",
"jest/no-standalone-expect": "off",
"jest/valid-title": "off",
"typescript/no-this-alias": "off",
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off",
"unicorn/no-new-array": "off",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-useless-fallback-in-spread": "off",
"unicorn/no-useless-spread": "off",
"typescript/await-thenable": "off",
"typescript/no-base-to-string": "off",
"typescript/no-duplicate-type-constituents": "off",
"typescript/no-for-in-array": "off",
"typescript/no-meaningless-void-operator": "off",
"typescript/no-redundant-type-constituents": "off",
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},
"overrides": [
{
"files": ["**/*.{stories,test,spec}.ts", "**/*.stories.vue"],
"rules": {
"no-console": "allow"
}
}
]
}

View File

@@ -98,12 +98,10 @@ const config: StorybookConfig = {
},
build: {
rolldownOptions: {
experimental: {
strictExecutionOrder: true
},
treeshake: false,
output: {
keepNames: true
keepNames: true,
strictExecutionOrder: true
},
onwarn: (warning, warn) => {
// Suppress specific warnings

View File

@@ -21,7 +21,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
- i18n: `src/i18n.ts`,
- Entry Point: `src/main.ts`.
- Tests:
- unit/component in `tests-ui/` and `src/**/*.test.ts`
- unit/component in `src/**/*.test.ts`
- E2E (Playwright) in `browser_tests/**/*.spec.ts`
- Public assets: `public/`
- Build output: `dist/`
@@ -264,7 +264,7 @@ A particular type of complexity is over-engineering, where developers have made
## Repository Navigation
- Check README files in key folders (tests-ui, browser_tests, composables, etc.)
- Check README files in key folders (browser_tests, composables, etc.)
- Prefer running single tests for performance
- Use --help for unfamiliar CLI tools
@@ -312,3 +312,58 @@ When using `take_snapshot` to inspect dropdowns, listboxes, or other components
- Put scripts used under `/temp/scripts/`
- Put summaries of work performed under `/temp/summaries/`
- Put TODOs and status updates under `/temp/in_progress/`
<!--VITE PLUS START-->
# Using Vite+, the Unified Toolchain for the Web
This project is using Vite+, a modern toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, and Oxfmt. Vite+ wraps these tools and package manager commands in a single, global CLI called `vite`. Vite+ is distinct from Vite, but it invokes Vite through `vite dev` and `vite build`.
## Vite+ Workflow
`vite` is a global binary that handles the full development lifecycle. Run `vite help` to print a list of commands and `vite <command> --help` for information about a specific command.
### Vite+ Commands
- dev - Run the development server
- build - Build for production
- lint - Lint code
- test - Run tests
- fmt - Format code
- lib - Build library
- migrate - Migrate an existing project to Vite+
- new - Create a new monorepo package (in-project) or a new project (global)
- run - Run tasks from `package.json` scripts
These commands map to their corresponding tools. For example, `vite dev --port 3000` runs Vite's dev server and works the same as Vite. `vite test` runs JavaScript tests through the bundled Vitest. The version of all tools can be checked using `vite --version`. This is useful when researching documentation, features, and bugs.
### Package Manager Commands
Vite+ automatically detects and wraps the underlying package manager such as pnpm, npm, or Yarn through the `packageManager` field in `package.json` or package manager-specific lockfiles.
- install - Install all dependencies, or add packages if package names are provided
- add - Add packages to dependencies
- remove - Remove packages from dependencies
- dlx - Execute a package binary without installing it as a dependency
- info - View package information from the registry, including latest versions
- link - Link packages for local development
- outdated - Check for outdated packages
- pm - Forward a command to the package manager
- unlink - Unlink packages
- update - Update packages to their latest versions
- why - Show why a package is installed
## Common Pitfalls
- **Using the package manager directly:** Do not use pnpm, npm, or Yarn directly. Vite+ can handle all package manager operations.
- **Always use Vite commands to run tools:** Don't attempt to run `vite vitest` or `vite oxlint`. They do not exist. Use `vite test` and `vite lint` instead.
- **Running scripts:** Vite+ commands take precedence over `package.json` scripts. If there is a `test` script defined in `scripts` that conflicts with the built-in `vite test` command, run it using `vite run test`.
- **Do not install Vitest, Oxlint, Oxfmt, or tsdown directly:** Vite+ wraps these tools. They must not be installed directly. You cannot upgrade these tools by installing their latest versions. Always use Vite+ commands.
- **Import JavaScript modules from `vite-plus`:** Instead of importing from `vite` or `vitest`, all modules should be imported from the project's `vite-plus` dependency. For example, `import { defineConfig } from 'vite-plus';` or `import { expect, test, vi } from 'vite-plus/test';`. You must not install `vitest` to import test utilities.
- **Type-Aware Linting:** There is no need to install `oxlint-tsgolint`, `vite lint --type-aware` works out of the box.
## Review Checklist for Agents
- [ ] Run `vite install` after pulling remote changes and before getting started.
- [ ] Run `vite lint`, `vite fmt`, and `vite test` to validate changes.
<!--VITE PLUS END-->

View File

@@ -30,6 +30,7 @@
"vite": "catalog:",
"vite-plugin-html": "catalog:",
"vite-plugin-vue-devtools": "catalog:",
"vite-plus": "catalog:",
"vue-tsc": "catalog:"
},
"nx": {

View File

@@ -7,7 +7,7 @@ import { FileSystemIconLoader } from 'unplugin-icons/loaders'
import IconsResolver from 'unplugin-icons/resolver'
import Icons from 'unplugin-icons/vite'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
import { defineConfig } from 'vite-plus'
import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

View File

@@ -16,6 +16,7 @@ test.describe(
'no_workflow.webp',
'large_workflow.webp',
'workflow_prompt_parameters.png',
'workflow_itxt.png',
'workflow.webm',
// Skipped due to 3d widget unstable visual result.
// 3d widget shows grid after fully loaded.

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,8 +1,8 @@
import {
type ComfyPage,
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
test.describe('Vue Multiline String Widget', () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -1,5 +1,5 @@
import path from 'path'
import type { Plugin } from 'vite'
import type { Plugin } from 'vite-plus'
interface ShimResult {
code: string
@@ -9,7 +9,11 @@ interface ShimResult {
const SKIP_WARNING_FILES = new Set(['scripts/app', 'scripts/api'])
/** Files that will be removed in v1.34 */
const DEPRECATED_FILES = ['scripts/ui', 'extensions/core/groupNode'] as const
const DEPRECATED_FILES = [
'scripts/ui',
'extensions/core/groupNode',
'extensions/core/nodeTemplates'
] as const
function getWarningMessage(
fileKey: string,

View File

@@ -8,7 +8,7 @@ export default {
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
...formatAndEslint(stagedFiles),
'pnpm typecheck'
'vite run typecheck'
]
}
@@ -17,8 +17,8 @@ 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 oxlint --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
`vite fmt ${joinedPaths}`,
`vite lint --fix ${joinedPaths}`,
`vite dlx eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.39.3",
"version": "1.39.5",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -22,20 +22,20 @@
"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": "vite fmt --check",
"format": "vite fmt --write",
"json-schema": "tsx scripts/generate-json-schema.ts",
"knip:no-cache": "knip",
"knip": "knip --cache",
"lint:fix:no-cache": "oxlint src --type-aware --fix && eslint src --fix",
"lint:fix": "oxlint src --type-aware --fix && eslint src --cache --fix",
"lint:no-cache": "oxlint src --type-aware && eslint src",
"lint:fix:no-cache": "vite lint src --type-aware --fix && eslint src --fix",
"lint:fix": "vite lint src --type-aware --fix && eslint src --cache --fix",
"lint:no-cache": "vite lint src --type-aware && eslint src",
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint": "oxlint src --type-aware && eslint src --cache",
"lint": "vite lint src --type-aware && eslint src --cache",
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src --type-aware",
"oxlint": "vite lint src --type-aware",
"preinstall": "pnpm dlx only-allow pnpm",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
@@ -160,9 +160,6 @@
"markdown-table": "catalog:",
"mixpanel-browser": "catalog:",
"nx": "catalog:",
"oxfmt": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"picocolors": "catalog:",
"postcss-html": "catalog:",
"pretty-bytes": "catalog:",
@@ -183,6 +180,7 @@
"vite-plugin-dts": "catalog:",
"vite-plugin-html": "catalog:",
"vite-plugin-vue-devtools": "catalog:",
"vite-plus": "catalog:",
"vitest": "catalog:",
"vue-component-type-helpers": "catalog:",
"vue-eslint-parser": "catalog:",
@@ -190,9 +188,8 @@
"zip-dir": "^2.0.0",
"zod-to-json-schema": "catalog:"
},
"packageManager": "pnpm@10.28.2",
"pnpm": {
"overrides": {
"vite": "^8.0.0-beta.8"
}
"overrides": {}
}
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vite-plus/test'
import { getMediaTypeFromFilename, truncateFilename } from './formatUtil'

1573
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,8 +41,8 @@ catalog:
'@vitest/coverage-v8': ^4.0.16
'@vitest/ui': ^4.0.16
'@vue/test-utils': ^2.4.6
'@vueuse/core': ^11.0.0
'@vueuse/integrations': ^13.9.0
'@vueuse/core': ^14.2.0
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
axios: ^1.8.2
@@ -62,16 +62,13 @@ catalog:
happy-dom: ^20.0.11
husky: ^9.1.7
jiti: 2.6.1
jsonata: ^2.1.0
jsdom: ^27.4.0
jsonata: ^2.1.0
knip: ^5.75.1
lint-staged: ^16.2.7
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
@@ -92,11 +89,11 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vite: ^8.0.0-beta.8
vite: npm:@voidzero-dev/vite-plus-core@latest
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0
vitest: ^4.0.16
vitest: npm:@voidzero-dev/vite-plus-test@latest
vue: ^3.5.13
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.2.0
@@ -109,6 +106,7 @@ catalog:
zod: ^3.23.8
zod-to-json-schema: ^3.24.1
zod-validation-error: ^3.3.0
vite-plus: latest
cleanupUnusedCatalogs: true
@@ -130,3 +128,12 @@ onlyBuiltDependencies:
overrides:
'@types/eslint': '-'
vite: 'catalog:'
vitest: 'catalog:'
peerDependencyRules:
allowAny:
- vite
- vitest
allowedVersions:
vite: '*'
vitest: '*'

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { downloadFile } from '@/base/common/downloadUtil'

View File

@@ -1,4 +1,4 @@
import { describe, expect, test } from 'vitest'
import { describe, expect, test } from 'vite-plus/test'
import {
CREDITS_PER_USD,

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
import type { Component } from 'vue'
import { createI18n } from 'vue-i18n'

View File

@@ -1,5 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { ref } from 'vue'
import WorkspaceAuthGate from './WorkspaceAuthGate.vue'

View File

@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vite-plus/test'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
import type { ComfyCommandImpl } from '@/stores/commandStore'

View File

@@ -1,8 +1,8 @@
import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import type { Mock } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Mock } from 'vite-plus/test'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'

View File

@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
import ColorPicker from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import SelectButton from 'primevue/selectbutton'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
import { createApp, nextTick } from 'vue'
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'

View File

@@ -38,7 +38,10 @@ const {
alt = 'Image content'
} = defineProps<{
src: string
class?: any
class?:
| string
| Record<string, boolean>
| (string | Record<string, boolean>)[]
contain?: boolean
alt?: string
}>()

View File

@@ -28,13 +28,17 @@ const deviceColumns: { field: keyof DeviceStats; header: string }[] = [
{ field: 'torch_vram_free', header: 'Torch VRAM Free' }
]
const formatValue = (value: any, field: string) => {
const formatValue = (value: string | number, field: string) => {
if (
['vram_total', 'vram_free', 'torch_vram_total', 'torch_vram_free'].includes(
field
)
) {
return formatSize(value)
const num = Number(value)
if (Number.isFinite(num)) {
return formatSize(num)
}
return value
}
return value
}

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import { beforeAll, describe, expect, it } from 'vitest'
import { beforeAll, describe, expect, it } from 'vite-plus/test'
import { createApp } from 'vue'
import EditableText from './EditableText.vue'

View File

@@ -47,7 +47,7 @@ import InputSlider from '@/components/common/InputSlider.vue'
import UrlInput from '@/components/common/UrlInput.vue'
import type { FormItem } from '@/platform/settings/types'
const formValue = defineModel<any>('formValue')
const formValue = defineModel<unknown>('formValue')
const props = defineProps<{
item: FormItem
id?: string
@@ -61,7 +61,7 @@ function getFormAttrs(item: FormItem) {
attrs['renderFunction'] = () =>
inputType(
props.item.name,
(v: any) => (formValue.value = v),
(v: unknown) => (formValue.value = v),
formValue.value,
item.attrs
)

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import RadioButton from 'primevue/radiobutton'
import { beforeAll, describe, expect, it } from 'vitest'
import { beforeAll, describe, expect, it } from 'vite-plus/test'
import { createApp } from 'vue'
import type { SettingOption } from '@/platform/settings/types'

View File

@@ -20,14 +20,14 @@
</div>
</template>
<script setup lang="ts">
<script setup lang="ts" generic="T extends string | number | boolean | null">
import RadioButton from 'primevue/radiobutton'
import { computed } from 'vue'
import type { SettingOption } from '@/platform/settings/types'
const props = defineProps<{
modelValue: any
modelValue: T
options?: (string | SettingOption | Record<string, string>)[]
optionLabel?: string
optionValue?: string
@@ -35,7 +35,7 @@ const props = defineProps<{
}>()
defineEmits<{
'update:modelValue': [value: any]
'update:modelValue': [value: T]
}>()
const normalizedOptions = computed<SettingOption[]>(() => {

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'

View File

@@ -221,7 +221,7 @@ const wrapCommandWithErrorHandler = (
) => {
return isAsync
? errorHandling.wrapWithErrorHandlingAsync(
command as (...args: any[]) => Promise<any>,
command as (event: MenuItemCommandEvent) => Promise<void>,
menuTargetNode.value?.handleError
)
: errorHandling.wrapWithErrorHandling(

View File

@@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'
import Badge from 'primevue/badge'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vite-plus/test'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'

View File

@@ -3,7 +3,7 @@ import PrimeVue from 'primevue/config'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
import { createApp, nextTick } from 'vue'
import UrlInput from './UrlInput.vue'

View File

@@ -3,7 +3,7 @@ import type { ComponentProps } from 'vue-component-type-helpers'
import { mount } from '@vue/test-utils'
import Avatar from 'primevue/avatar'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
import { createApp, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }

View File

@@ -256,6 +256,11 @@
"
/>
</template>
<LogoOverlay
v-if="template.logos?.length"
:logos="template.logos"
:get-logo-url="workflowTemplatesStore.getLogoUrl"
/>
<ProgressSpinner
v-if="loadingTemplate === template.name"
class="absolute inset-0 z-10 m-auto h-12 w-12"
@@ -397,6 +402,7 @@ import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
@@ -406,8 +412,8 @@ import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
@@ -788,7 +794,7 @@ watch(
)
// Methods
const onLoadWorkflow = async (template: any) => {
const onLoadWorkflow = async (template: TemplateInfo) => {
loadingTemplate.value = template.name
try {
await loadWorkflowTemplate(

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Message from 'primevue/message'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { nextTick } from 'vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'

View File

@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vite-plus/test'
import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue'

View File

@@ -3,7 +3,7 @@ import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tag from 'primevue/tag'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
import SettingItem from '@/platform/settings/components/SettingItem.vue'

View File

@@ -8,7 +8,7 @@ import DataTable from 'primevue/datatable'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import Tooltip from 'primevue/tooltip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'

View File

@@ -63,14 +63,18 @@
<i class="pi pi-sign-out" />
{{ $t('auth.signOut.signOut') }}
</Button>
<Button
<i18n-t
v-if="!isApiKeyLogin"
class="w-fit"
variant="destructive-textonly"
@click="handleDeleteAccount"
keypath="auth.deleteAccount.contactSupport"
tag="p"
class="text-muted text-sm"
>
{{ $t('auth.deleteAccount.deleteAccount') }}
</Button>
<template #email>
<a href="mailto:support@comfy.org" class="underline"
>support@comfy.org</a
>
</template>
</i18n-t>
</div>
</div>
@@ -116,7 +120,6 @@ const {
providerName,
providerIcon,
handleSignOut,
handleSignIn,
handleDeleteAccount
handleSignIn
} = useCurrentUser()
</script>

View File

@@ -7,7 +7,7 @@ import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'

View File

@@ -7,7 +7,7 @@ import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import ToastService from 'primevue/toastservice'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'

View File

@@ -55,6 +55,7 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import type { ComponentPublicInstance } from 'vue'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
@@ -66,7 +67,7 @@ interface Props {
}
defineProps<Props>()
const buttonRef = ref<InstanceType<typeof Button>>()
const buttonRef = ref<ComponentPublicInstance | null>(null)
const popover = ref<InstanceType<typeof Popover>>()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
@@ -92,7 +93,7 @@ const lockCommandText = computed(() =>
)
const toggle = (event: Event) => {
const el = (buttonRef.value as any)?.$el || buttonRef.value
const el = buttonRef.value?.$el || buttonRef.value
popover.value?.toggle(event, el)
}

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
import ZoomControlsModal from '@/components/graph/modals/ZoomControlsModal.vue'

View File

@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'

View File

@@ -1,9 +1,9 @@
import type { Mock } from 'vitest'
import type { Mock } from 'vite-plus/test'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
// Import after mocks

View File

@@ -3,7 +3,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'

View File

@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'

View File

@@ -1,6 +1,6 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { defineComponent, h, nextTick, ref } from 'vue'
import HoneyToast from './HoneyToast.vue'

View File

@@ -57,6 +57,41 @@
/>
</div>
<div class="flex shrink-0 items-center gap-2">
<label class="text-xs text-muted-foreground">
{{ $t('imageCrop.ratio') }}
</label>
<Select v-model="selectedRatio">
<SelectTrigger class="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="key in ratioKeys" :key="key" :value="key">
{{ key === 'custom' ? $t('imageCrop.custom') : key }}
</SelectItem>
</SelectContent>
</Select>
<Button
size="icon"
:variant="isLockEnabled ? 'primary' : 'secondary'"
class="size-7"
:aria-label="
isLockEnabled
? $t('imageCrop.unlockRatio')
: $t('imageCrop.lockRatio')
"
@click="isLockEnabled = !isLockEnabled"
>
<i
:class="
isLockEnabled
? 'icon-[lucide--lock] size-3.5'
: 'icon-[lucide--lock-open] size-3.5'
"
/>
</Button>
</div>
<WidgetBoundingBox v-model="modelValue" class="shrink-0" />
</div>
</template>
@@ -65,7 +100,13 @@
import { useTemplateRef } from 'vue'
import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue'
import { useImageCrop } from '@/composables/useImageCrop'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { ASPECT_RATIOS, useImageCrop } from '@/composables/useImageCrop'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Bounds } from '@/renderer/core/layout/types'
@@ -80,10 +121,15 @@ const modelValue = defineModel<Bounds>({
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')
const ratioKeys = Object.keys(ASPECT_RATIOS)
const {
imageUrl,
isLoading,
selectedRatio,
isLockEnabled,
cropBoxStyle,
cropImageStyle,
resizeHandles,

View File

@@ -3,7 +3,8 @@
ref="container"
class="relative h-full w-full min-h-[200px]"
data-capture-wheel="true"
@pointerdown.stop
tabindex="-1"
@pointerdown.stop="focusContainer"
@pointermove.stop
@pointerup.stop
@mousedown.stop
@@ -45,6 +46,10 @@ const props = defineProps<{
const container = ref<HTMLElement | null>(null)
function focusContainer() {
container.value?.focus()
}
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
useLoad3dDrag({
onModelDrop: async (file) => {

View File

@@ -121,8 +121,9 @@ const mutationObserver = ref<MutationObserver | null>(null)
const isStandaloneMode = !props.node && props.modelUrl
// Use sync version since useLoad3dViewer is already imported (module is loaded)
const viewer = props.node
? useLoad3dService().getOrCreateViewer(toRaw(props.node))
? useLoad3dService().getOrCreateViewerSync(toRaw(props.node), useLoad3dViewer)
: useLoad3dViewer()
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { beforeAll, describe, expect, it, vi } from 'vite-plus/test'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -76,14 +76,6 @@ describe('NodePreview', () => {
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
})
it('applies text-ellipsis class to node header for text truncation', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader.classes()).toContain('text-ellipsis')
expect(nodeHeader.classes()).toContain('mr-4')
})
it('sets title attribute on node header with full display name', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')

View File

@@ -10,7 +10,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<div v-else class="_sb_node_preview bg-component-node-background">
<div class="_sb_table">
<div
class="node_header mr-4 text-ellipsis"
class="node_header text-ellipsis"
:title="nodeDef.display_name"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,
@@ -124,7 +124,10 @@ const slotInputDefs = allInputDefs.filter(
const widgetInputDefs = allInputDefs.filter((input) =>
widgetStore.inputIsWidget(input)
)
const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
const truncateDefaultValue = (
value: unknown,
charLimit: number = 32
): string => {
let stringValue: string
if (typeof value === 'object' && value !== null) {

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
import QueueOverlayActive from './QueueOverlayActive.vue'

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
import QueueOverlayEmpty from './QueueOverlayEmpty.vue'

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
import { defineComponent } from 'vue'

View File

@@ -264,6 +264,7 @@ const focusAssetInSidebar = async (item: JobListItem) => {
throw new Error('Asset not found in media assets panel')
}
assetSelectionStore.setSelection([assetId])
assetSelectionStore.setLastSelectedAssetId(assetId)
}
const inspectJobAsset = wrapWithErrorHandlingAsync(

View File

@@ -296,7 +296,7 @@ const extraRows = computed<DetailRow[]>(() => {
]
}
if (jobState.value === 'completed') {
const task = taskForJob.value as any
const task = taskForJob.value
const endTs: number | undefined = task?.executionEndTimestamp
const execMs: number | undefined = task?.executionTime
const generatedOnValue = endTs ? formatClockTime(endTs, locale.value) : ''
@@ -321,7 +321,7 @@ const extraRows = computed<DetailRow[]>(() => {
return rows
}
if (jobState.value === 'failed') {
const task = taskForJob.value as any
const task = taskForJob.value
const execMs: number | undefined = task?.executionTime
const failedAfterValue =
execMs !== undefined ? formatElapsedTime(execMs) : ''

View File

@@ -179,7 +179,7 @@ const onFilterClick = (event: Event) => {
}
}
const selectWorkflowFilter = (value: 'all' | 'current') => {
;(filterPopoverRef.value as any)?.hide?.()
filterPopoverRef.value?.hide()
emit('update:selectedWorkflowFilter', value)
}
@@ -190,7 +190,7 @@ const onSortClick = (event: Event) => {
}
const selectSortMode = (value: JobSortMode) => {
;(sortPopoverRef.value as any)?.hide?.()
sortPopoverRef.value?.hide()
emit('update:selectedSortMode', value)
}

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import { defineComponent, nextTick } from 'vue'
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { computed, ref } from 'vue'
import type { ComputedRef } from 'vue'

View File

@@ -1,5 +1,5 @@
import { computed, ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vite-plus/test'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'

View File

@@ -8,6 +8,7 @@ import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
import { st } from '@/i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -190,6 +191,12 @@ function handleTitleEdit(newTitle: string) {
function handleTitleCancel() {
isEditing.value = false
}
function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
if (!selectedSingleNode.value) return
;(selectedSingleNode.value as SubgraphNode).properties.proxyWidgets = value
canvasStore.canvas?.setDirty(true, true)
}
</script>
<template>
@@ -283,6 +290,7 @@ function handleTitleCancel() {
<TabSubgraphInputs
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
:node="selectedSingleNode as SubgraphNode"
@update:proxy-widgets="handleProxyWidgetsUpdate"
/>
<TabNormalInputs
v-else-if="activeTab === 'parameters'"

View File

@@ -118,6 +118,15 @@ function handleLocateNode() {
}
}
function handleWidgetValueUpdate(
widget: IBaseWidget,
newValue: string | number | boolean | object
) {
widget.value = newValue
widget.callback?.(newValue)
canvasStore.canvas?.setDirty(true, true)
}
defineExpose({
widgetsContainer,
rootElement
@@ -179,6 +188,7 @@ defineExpose({
:show-node-name="showNodeName"
:parents="parents"
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
@update:widget-value="handleWidgetValueUpdate(widget, $event)"
/>
</TransitionGroup>
</div>

View File

@@ -32,6 +32,10 @@ const { node } = defineProps<{
node: SubgraphNode
}>()
const emit = defineEmits<{
'update:proxyWidgets': [value: ProxyWidgetsProperty]
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const rightSidePanelStore = useRightSidePanelStore()
@@ -51,8 +55,7 @@ const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
set(value?: ProxyWidgetsProperty) {
trigger()
if (!value) return
// eslint-disable-next-line vue/no-mutating-props
node.properties.proxyWidgets = value
emit('update:proxyWidgets', value)
}
}))

View File

@@ -41,6 +41,10 @@ const {
isShownOnParents?: boolean
}>()
const emit = defineEmits<{
'update:widgetValue': [value: string | number | boolean | object]
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
@@ -83,10 +87,7 @@ const widgetValue = computed({
return widget.value
},
set: (newValue: string | number | boolean | object) => {
// eslint-disable-next-line vue/no-mutating-props
widget.value = newValue
widget.callback?.(newValue)
canvasStore.canvas?.setDirty(true, true)
emit('update:widgetValue', newValue)
}
})

View File

@@ -1,7 +1,7 @@
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import { describe, expect, it, beforeEach } from 'vitest'
import { describe, expect, it, beforeEach } from 'vite-plus/test'
import { flatAndCategorizeSelectedItems, searchWidgets } from './shared'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
import SidebarIcon from './SidebarIcon.vue'

View File

@@ -2,7 +2,7 @@
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="isQueuePanelV2Enabled && activeJobItems.length"
v-if="!isInFolderView && isQueuePanelV2Enabled && activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
@@ -70,12 +70,14 @@ import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
isSelected,
isInFolderView = false,
assetType = 'output',
showOutputCount,
getOutputCount
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
isInFolderView?: boolean
assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
@@ -100,7 +102,7 @@ const isQueuePanelV2Enabled = computed(() =>
type AssetGridItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state))
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
)
const assetItems = computed<AssetGridItem[]>(() =>

View File

@@ -1,7 +1,9 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { toRef } from 'vue'
import type { JobAction } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
import { setMockJobItems } from '@/storybook/mocks/useJobList'
@@ -138,16 +140,33 @@ function renderAssetsSidebarListView(args: StoryArgs) {
setup() {
setMockJobItems(args.jobs)
setMockJobActions(args.actionsByJobId ?? {})
const { assetItems, selectableAssets, isStackExpanded, toggleStack } =
useOutputStacks({
assets: toRef(args, 'assets')
})
const selectedIds = new Set(args.selectedAssetIds ?? [])
function isSelected(assetId: string) {
return selectedIds.has(assetId)
}
return { args, isSelected }
return {
args,
assetItems,
selectableAssets,
isSelected,
isStackExpanded,
toggleStack
}
},
template: `
<div class="h-[520px] w-[320px] overflow-hidden rounded-lg border border-panel-border">
<AssetsSidebarListView :assets="args.assets" :is-selected="isSelected" />
<AssetsSidebarListView
:asset-items="assetItems"
:selectable-assets="selectableAssets"
:is-selected="isSelected"
:is-stack-expanded="isStackExpanded"
:toggle-stack="toggleStack"
/>
</div>
`
}

View File

@@ -0,0 +1,150 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { ref } from 'vue'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/composables/queue/useJobActions', () => ({
useJobActions: () => ({
cancelAction: { variant: 'ghost', label: 'Cancel', icon: 'pi pi-times' },
canCancelJob: ref(false),
runCancelJob: vi.fn()
})
}))
const mockJobItems = ref<
Array<{
id: string
title: string
meta: string
state: string
createTime?: number
}>
>([])
vi.mock('@/composables/queue/useJobList', () => ({
useJobList: () => ({
jobItems: mockJobItems
})
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
isAssetDeleting: () => false
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => key === 'Comfy.Queue.QPOV2'
})
}))
vi.mock('@/utils/queueUtil', () => ({
isActiveJobState: (state: string) =>
state === 'pending' || state === 'running'
}))
vi.mock('@/utils/queueDisplay', () => ({
iconForJobState: () => 'pi pi-spinner'
}))
vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: () => undefined
}))
vi.mock('@/platform/assets/utils/mediaIconUtil', () => ({
iconForMediaType: () => 'pi pi-file'
}))
vi.mock('@/utils/formatUtil', () => ({
formatDuration: (d: number) => `${d}s`,
formatSize: (s: number) => `${s}B`,
getMediaTypeFromFilename: () => 'image',
truncateFilename: (name: string) => name
}))
describe('AssetsSidebarListView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockJobItems.value = []
})
const defaultProps = {
assetItems: [],
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {}
}
it('displays active jobs in oldest-first order (FIFO)', () => {
mockJobItems.value = [
{
id: 'newest',
title: 'Newest Job',
meta: '',
state: 'pending',
createTime: 3000
},
{
id: 'middle',
title: 'Middle Job',
meta: '',
state: 'running',
createTime: 2000
},
{
id: 'oldest',
title: 'Oldest Job',
meta: '',
state: 'pending',
createTime: 1000
}
]
const wrapper = mount(AssetsSidebarListView, {
props: defaultProps,
shallow: true
})
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(jobListItems).toHaveLength(3)
const displayedTitles = jobListItems.map((item) =>
item.props('primaryText')
)
expect(displayedTitles).toEqual(['Oldest Job', 'Middle Job', 'Newest Job'])
})
it('excludes completed and failed jobs from active jobs section', () => {
mockJobItems.value = [
{ id: 'pending', title: 'Pending', meta: '', state: 'pending' },
{ id: 'completed', title: 'Completed', meta: '', state: 'completed' },
{ id: 'failed', title: 'Failed', meta: '', state: 'failed' },
{ id: 'running', title: 'Running', meta: '', state: 'running' }
]
const wrapper = mount(AssetsSidebarListView, {
props: defaultProps,
shallow: true
})
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(jobListItems).toHaveLength(2)
const displayedTitles = jobListItems.map((item) =>
item.props('primaryText')
)
expect(displayedTitles).toContain('Running')
expect(displayedTitles).toContain('Pending')
expect(displayedTitles).not.toContain('Completed')
expect(displayedTitles).not.toContain('Failed')
})
})

View File

@@ -40,7 +40,7 @@
</div>
<div
v-if="assets.length"
v-if="assetItems.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div
@@ -79,7 +79,12 @@
type: getMediaTypeFromFilename(item.asset.name)
})
"
:class="getAssetCardClass(isSelected(item.asset.id))"
:class="
cn(
getAssetCardClass(isSelected(item.asset.id)),
item.isChild && 'pl-6'
)
"
:preview-url="item.asset.preview_url"
:preview-alt="item.asset.name"
:icon-name="
@@ -87,10 +92,14 @@
"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
:stack-count="getStackCount(item.asset)"
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
:stack-expanded="isStackExpanded(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('select-asset', item.asset)"
@click.stop="emit('select-asset', item.asset, selectableAssets)"
@stack-toggle="void toggleStack(item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
<Button
@@ -120,6 +129,7 @@ import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
@@ -136,19 +146,25 @@ import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
assetItems,
selectableAssets,
isSelected,
isStackExpanded,
toggleStack,
assetType = 'output'
} = defineProps<{
assets: AssetItem[]
assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
isSelected: (assetId: string) => boolean
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => Promise<void>
assetType?: 'input' | 'output'
}>()
const assetsStore = useAssetsStore()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
}>()
@@ -162,11 +178,8 @@ const isQueuePanelV2Enabled = computed(() =>
)
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)
type AssetListItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state))
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
)
const hoveredJob = computed(() =>
hoveredJobId.value
@@ -176,13 +189,6 @@ const hoveredJob = computed(() =>
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
const assetItems = computed<AssetListItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
)
const listGridStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)',
@@ -212,6 +218,19 @@ function getAssetSecondaryText(asset: AssetItem): string {
return ''
}
function getStackCount(asset: AssetItem): number | undefined {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.outputCount === 'number') {
return metadata.outputCount
}
if (Array.isArray(metadata?.allOutputs)) {
return metadata.allOutputs.length
}
return undefined
}
function getAssetCardClass(selected: boolean): string {
return cn(
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover',

View File

@@ -53,7 +53,7 @@
:show-generation-time-sort="activeTab === 'output'"
/>
<div
v-if="isQueuePanelV2Enabled"
v-if="isQueuePanelV2Enabled && !isInFolderView"
class="flex items-center justify-between px-2 py-2 2xl:px-4"
>
<span class="text-sm text-muted-foreground">
@@ -98,8 +98,11 @@
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<AssetsSidebarListView
v-if="isListView"
:assets="displayAssets"
:asset-items="listViewAssetItems"
:is-selected="isSelected"
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@@ -109,6 +112,7 @@
v-else
:assets="displayAssets"
:is-selected="isSelected"
:is-in-folder-view="isInFolderView"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@@ -215,7 +219,9 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
// Lazy-loaded to avoid pulling THREE.js into the main bundle
const Load3dViewerContent = () =>
import('@/components/load3d/Load3dViewerContent.vue')
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
@@ -230,12 +236,13 @@ import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAsse
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { getJobDetail } from '@/services/jobOutputCache'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -243,12 +250,6 @@ import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
interface JobOutputItem {
filename: string
subfolder: string
type: string
}
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
@@ -323,6 +324,7 @@ const {
hasSelection,
clearSelection,
getSelectedAssets,
reconcileSelection,
getOutputCount,
getTotalOutputCount,
activate: activateSelection,
@@ -392,7 +394,21 @@ const displayAssets = computed(() => {
return filteredAssets.value
})
const selectedAssets = computed(() => getSelectedAssets(displayAssets.value))
const {
assetItems: listViewAssetItems,
selectableAssets: listViewSelectableAssets,
isStackExpanded: isListViewStackExpanded,
toggleStack: toggleListViewStack
} = useOutputStacks({
assets: computed(() => displayAssets.value)
})
const visibleAssets = computed(() => {
if (!isListView.value) return displayAssets.value
return listViewSelectableAssets.value
})
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
const isBulkMode = computed(
() => hasSelection.value && selectedAssets.value.length > 1
@@ -412,7 +428,10 @@ const showEmptyState = computed(
activeJobsCount.value === 0
)
watch(displayAssets, (newAssets) => {
watch(visibleAssets, (newAssets) => {
// Alternative: keep hidden selections and surface them in UI; for now prune
// so selection stays consistent with what this view can act on.
reconcileSelection(newAssets)
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex(
(asset) => asset.id === currentGalleryAssetId.value
@@ -430,7 +449,7 @@ watch(galleryActiveIndex, (index) => {
})
const galleryItems = computed(() => {
return displayAssets.value.map((asset) => {
return visibleAssets.value.map((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name)
const resultItem = new ResultItemImpl({
filename: asset.name,
@@ -470,9 +489,10 @@ watch(
{ immediate: true }
)
const handleAssetSelect = (asset: AssetItem) => {
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
handleAssetClick(asset, index, displayAssets.value)
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
const assetList = assets ?? visibleAssets.value
const index = assetList.findIndex((a) => a.id === asset.id)
handleAssetClick(asset, index, assetList)
}
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
@@ -557,7 +577,7 @@ const handleZoomClick = (asset: AssetItem) => {
}
currentGalleryAssetId.value = asset.id
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
const index = visibleAssets.value.findIndex((a) => a.id === asset.id)
if (index !== -1) {
galleryActiveIndex.value = index
}
@@ -570,7 +590,7 @@ const enterFolderView = async (asset: AssetItem) => {
return
}
const { promptId, allOutputs, executionTimeInSeconds, outputCount } = metadata
const { promptId, executionTimeInSeconds } = metadata
if (!promptId) {
console.warn('Missing required folder view data')
@@ -580,62 +600,21 @@ const enterFolderView = async (asset: AssetItem) => {
folderPromptId.value = promptId
folderExecutionTime.value = executionTimeInSeconds
// Determine which outputs to display
let outputsToDisplay = allOutputs ?? []
// If outputCount indicates more outputs than we have, fetch full outputs
const needsFullOutputs =
typeof outputCount === 'number' &&
outputCount > 1 &&
outputsToDisplay.length < outputCount
if (needsFullOutputs) {
try {
const jobDetail = await getJobDetail(promptId)
if (jobDetail?.outputs) {
// Convert job outputs to ResultItemImpl array
outputsToDisplay = Object.entries(jobDetail.outputs).flatMap(
([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as JobOutputItem[])
.map(
(item) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
.filter((r) => r.supportsPreview)
)
)
}
} catch (error) {
console.error('Failed to fetch job detail for folder view:', error)
outputsToDisplay = []
}
let folderItems: AssetItem[] = []
try {
folderItems = await resolveOutputAssetItems(metadata, {
createdAt: asset.created_at
})
} catch (error) {
console.error('Failed to resolve outputs for folder view:', error)
}
if (outputsToDisplay.length === 0) {
if (folderItems.length === 0) {
console.warn('No outputs available for folder view')
return
}
folderAssets.value = outputsToDisplay.map((output) => ({
id: `${output.nodeId}-${output.filename}`,
name: output.filename,
size: 0,
created_at: asset.created_at,
tags: ['output'],
preview_url: output.url,
user_metadata: {
promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
executionTimeInSeconds,
workflow: metadata.workflow
}
}))
folderAssets.value = folderItems
}
const exitFolderView = () => {

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Galleria from 'primevue/galleria'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createApp, nextTick } from 'vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vite-plus/test'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vite-plus/test'
import { nextTick } from 'vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vite-plus/test'
import { ref } from 'vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vite-plus/test'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vite-plus/test'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'

View File

@@ -0,0 +1,157 @@
import { mount } from '@vue/test-utils'
import type { ComponentProps } from 'vue-component-type-helpers'
import { nextTick, ref } from 'vue'
import { describe, expect, it, vi } from 'vite-plus/test'
import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue'
import type { LogoInfo } from '@/platform/workflow/templates/types/template'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) =>
key === 'templates.logoProviderSeparator' ? ' & ' : key,
locale: ref('en')
})
}))
type LogoOverlayProps = ComponentProps<typeof LogoOverlay>
describe('LogoOverlay', () => {
function mockGetLogoUrl(provider: string) {
return `/logos/${provider}.png`
}
function mountOverlay(
logos: LogoInfo[],
props: Partial<LogoOverlayProps> = {}
) {
return mount(LogoOverlay, {
props: {
logos,
getLogoUrl: mockGetLogoUrl,
...props
}
})
}
it('renders nothing when logos array is empty', () => {
const wrapper = mountOverlay([])
expect(wrapper.findAll('img')).toHaveLength(0)
})
it('renders a single logo with correct src and alt', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const img = wrapper.find('img')
expect(img.attributes('src')).toBe('/logos/Google.png')
expect(img.attributes('alt')).toBe('Google')
})
it('renders multiple separate logo entries', () => {
const wrapper = mountOverlay([
{ provider: 'Google' },
{ provider: 'OpenAI' },
{ provider: 'Stability' }
])
expect(wrapper.findAll('img')).toHaveLength(3)
})
it('displays provider name as label for single provider', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const span = wrapper.find('span')
expect(span.text()).toBe('Google')
})
it('images are not draggable', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const img = wrapper.find('img')
expect(img.attributes('draggable')).toBe('false')
})
it('filters out logos with empty URLs', () => {
function getLogoUrl(provider: string) {
return provider === 'Google' ? '/logos/Google.png' : ''
}
const wrapper = mount(LogoOverlay, {
props: {
logos: [{ provider: 'Google' }, { provider: 'Unknown' }],
getLogoUrl
}
})
expect(wrapper.findAll('img')).toHaveLength(1)
})
it('renders one logo per unique provider', () => {
const wrapper = mountOverlay([
{ provider: 'Google' },
{ provider: 'OpenAI' }
])
expect(wrapper.findAll('img')).toHaveLength(2)
})
describe('stacked logos', () => {
it('renders multiple providers as stacked overlapping logos', () => {
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
expect(images[0].attributes('alt')).toBe('WaveSpeed')
expect(images[1].attributes('alt')).toBe('Hunyuan')
})
it('joins provider names with locale-aware conjunction for default label', () => {
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const span = wrapper.find('span')
expect(span.text()).toBe('WaveSpeed and Hunyuan')
})
it('uses custom label when provided', () => {
const wrapper = mountOverlay([
{ provider: ['WaveSpeed', 'Hunyuan'], label: 'Custom Label' }
])
const span = wrapper.find('span')
expect(span.text()).toBe('Custom Label')
})
it('applies negative gap for overlap effect', () => {
const wrapper = mountOverlay([
{ provider: ['WaveSpeed', 'Hunyuan'], gap: -8 }
])
const images = wrapper.findAll('img')
expect(images[1].attributes('style')).toContain('margin-left: -8px')
})
it('applies default gap when not specified', () => {
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const images = wrapper.findAll('img')
expect(images[1].attributes('style')).toContain('margin-left: -6px')
})
it('filters out invalid providers from stacked logos', () => {
function getLogoUrl(provider: string) {
return provider === 'WaveSpeed' ? '/logos/WaveSpeed.png' : ''
}
const wrapper = mount(LogoOverlay, {
props: {
logos: [{ provider: ['WaveSpeed', 'Unknown'] }],
getLogoUrl
}
})
expect(wrapper.findAll('img')).toHaveLength(1)
expect(wrapper.find('span').text()).toBe('WaveSpeed')
})
})
describe('error handling', () => {
it('keeps showing remaining providers when one image fails in stacked logos', async () => {
const wrapper = mountOverlay([{ provider: ['Google', 'OpenAI'] }])
const images = wrapper.findAll('[data-testid="logo-img"]')
expect(images).toHaveLength(2)
await images[0].trigger('error')
await nextTick()
const remainingImages = wrapper.findAll('[data-testid="logo-img"]')
expect(remainingImages).toHaveLength(2)
expect(remainingImages[1].attributes('alt')).toBe('OpenAI')
})
})
})

View File

@@ -0,0 +1,117 @@
<template>
<div
v-for="logo in validLogos"
:key="logo.key"
:class="
cn('pointer-events-none absolute z-10', logo.position ?? defaultPosition)
"
>
<div
v-show="!hasAllFailed(logo.providers)"
data-testid="logo-pill"
class="flex items-center gap-1.5 rounded-full bg-black/20 py-1 pr-2"
:style="{ opacity: logo.opacity ?? 0.85 }"
>
<div class="ml-0.5 flex items-center">
<img
v-for="(provider, providerIndex) in logo.providers"
:key="provider"
data-testid="logo-img"
:src="logo.urls[providerIndex]"
:alt="provider"
class="h-6 w-6 rounded-full border-2 border-white object-cover"
:class="{ relative: providerIndex > 0 }"
:style="
providerIndex > 0 ? { marginLeft: `${logo.gap ?? -6}px` } : {}
"
draggable="false"
@error="onImageError(provider)"
/>
</div>
<span class="text-sm font-medium text-white">
{{ logo.label }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LogoInfo } from '@/platform/workflow/templates/types/template'
import { cn } from '@/utils/tailwindUtil'
const { t, locale } = useI18n()
function formatProviderList(providers: string[]): string {
const localeValue = String(locale.value)
try {
return new Intl.ListFormat(localeValue, {
style: 'long',
type: 'conjunction'
}).format(providers)
} catch {
return providers.join(t('templates.logoProviderSeparator'))
}
}
const {
logos,
getLogoUrl,
defaultPosition = 'top-2 left-2'
} = defineProps<{
logos: LogoInfo[]
getLogoUrl: (provider: string) => string
defaultPosition?: string
}>()
const failedLogos = ref(new Set<string>())
function onImageError(provider: string) {
failedLogos.value = new Set([...failedLogos.value, provider])
}
function hasAllFailed(providers: string[]): boolean {
return providers.every((p) => failedLogos.value.has(p))
}
interface ValidatedLogo {
key: string
providers: string[]
urls: string[]
label: string
position: string | undefined
opacity: number | undefined
gap: number | undefined
}
const validLogos = computed<ValidatedLogo[]>(() => {
const result: ValidatedLogo[] = []
logos.forEach((logo, index) => {
const providers = Array.isArray(logo.provider)
? logo.provider
: [logo.provider]
const urls = providers.map((p) => getLogoUrl(p))
const validProviders = providers.filter((_, i) => urls[i])
const validUrls = urls.filter((url) => url)
if (validProviders.length === 0) return
const providerKey = validProviders.join('-')
const layoutKey = `${logo.position ?? ''}-${logo.opacity ?? ''}-${logo.gap ?? ''}`
result.push({
key: providerKey ? `${providerKey}-${layoutKey}` : `logo-${index}`,
providers: validProviders,
urls: validUrls,
label: logo.label ?? formatProviderList(validProviders),
position: logo.position,
opacity: logo.opacity,
gap: logo.gap
})
})
return result
})
</script>

View File

@@ -1,7 +1,7 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import Button from '@/components/ui/button/Button.vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'

View File

@@ -1,6 +1,6 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'

View File

@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
import Popover from 'primevue/popover'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vite-plus/test'
import { createI18n } from 'vue-i18n'
import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vite-plus/test'
import { h, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -119,26 +119,28 @@ describe('TagsInput with child components', () => {
})
it('exits edit mode when clicking outside', async () => {
const outsideElement = document.createElement('div')
document.body.appendChild(outsideElement)
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
props: {
modelValue: ['tag1']
},
slots: {
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
},
attachTo: document.body
}
})
await wrapper.trigger('click')
await nextTick()
expect(wrapper.find('input').exists()).toBe(true)
document.body.click()
outsideElement.dispatchEvent(new PointerEvent('click', { bubbles: true }))
await nextTick()
expect(wrapper.find('input').exists()).toBe(false)
wrapper.unmount()
outsideElement.remove()
})
it('shows placeholder when modelValue is empty', async () => {

View File

@@ -85,7 +85,7 @@ The following diagram shows how composables fit into the application architectur
## Composable Categories
The following tables list ALL composables in the system as of 2025-01-30:
The following tables list ALL composables in the system as of 2026-01-30:
### Auth

View File

@@ -1,9 +1,6 @@
import { whenever } from '@vueuse/core'
import { computed, watch } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -13,8 +10,6 @@ export const useCurrentUser = () => {
const authStore = useFirebaseAuthStore()
const commandStore = useCommandStore()
const apiKeyStore = useApiKeyAuthStore()
const dialogService = useDialogService()
const { deleteAccount } = useFirebaseAuthActions()
const firebaseUser = computed(() => authStore.currentUser)
const isApiKeyLogin = computed(() => apiKeyStore.isAuthenticated)
@@ -116,18 +111,6 @@ export const useCurrentUser = () => {
await commandStore.execute('Comfy.User.OpenSignInDialog')
}
const handleDeleteAccount = async () => {
const confirmed = await dialogService.confirm({
title: t('auth.deleteAccount.confirmTitle'),
message: t('auth.deleteAccount.confirmMessage'),
type: 'delete'
})
if (confirmed) {
await deleteAccount()
}
}
return {
loading: authStore.loading,
isLoggedIn,
@@ -141,7 +124,6 @@ export const useCurrentUser = () => {
resolvedUserInfo,
handleSignOut,
handleSignIn,
handleDeleteAccount,
onUserResolved,
onTokenRefreshed,
onUserLogout

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