Compare commits

...

54 Commits

Author SHA1 Message Date
Johnpaul
610cd55f07 fix: remove redundant canvas save/restore and add error logging 2026-03-03 03:17:04 +01:00
Comfy Org PR Bot
4868e6003f 1.41.10 (#9343)
Patch version increment to 1.41.10

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9343-1-41-10-3186d73d3650814d9077c68cc06131ea)
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-03-02 16:25:51 -08:00
pythongosssss
9b05d7cbb7 App mode output history UX improvements (#9285)
## Summary
- replace reka ui list with normal elements due to rekas aggressive
autoscrolling and event blocking
- rework layout to fix in progress items outside scrollable area
- extract feedback component
- avoid scroll position changing when adding new items
- add left/right keyboard navigation

## Screenshots (if applicable)
Showing fixed active items at start
<img width="1292" height="101" alt="image"
src="https://github.com/user-attachments/assets/dcd3215c-ac09-4081-b483-8631d17ca6bf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9285-App-mode-output-history-UX-improvements-3146d73d3650819a9f97edb41db975cc)
by [Unito](https://www.unito.io)
2026-03-02 14:46:45 -08:00
Terry Jia
74626d65d3 fix: use widget.options.hidden to hide painter widgets in Vue renderer (#9337)
## Summary

The Vue node renderer checks widget.options.hidden, not widget.hidden.
This was previously masked by the backend sending hidden: true via
extra_dict, but is now needed as the backend switches to io.Color.Input.

BE change is in https://github.com/Comfy-Org/ComfyUI/pull/12294

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9337-fix-use-widget-options-hidden-to-hide-painter-widgets-in-Vue-renderer-3176d73d3650815cad4beb8f9f35f7e6)
by [Unito](https://www.unito.io)
2026-03-02 14:29:21 -08:00
pythongosssss
31a4dce5d4 Add enterAppBuilder method for skipping arrange mode (#9310)
## Summary

When already in app mode and entering builder with outputs defined, skip
the select step and go straight to arrange

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9310-Add-enterAppBuilder-method-for-skipping-arrange-mode-3156d73d36508101903ff434a2a1ac08)
by [Unito](https://www.unito.io)
2026-03-02 11:10:48 -08:00
pythongosssss
0d7dc15916 App mode output feed to only show current session results for outputs defined in the app (#9307)
## Summary

Updates app mode to only show images from:
- the workflow that generated the image
- in the current session
- for the outputs selected in the builder

## Changes

- **What**: 
- adds new mapping of jobid -> workflow path [cant use id here as it is
not guaranteed unique], capped at 4k entries
- fix bug where executing a workflow then quickly switching tabs
associated incorrect workflow
- add missing output history tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9307-App-mode-output-feed-to-only-show-current-session-results-for-outputs-defined-in-the-app-3156d73d36508142b4bbca3f938fc5c2)
by [Unito](https://www.unito.io)
2026-03-02 11:10:20 -08:00
AustinMroz
1dd789fa54 Support selection of app inputs and outputs from vue mode (#9259)
- The input and output indicators are now plugged directly into the
`LGraphNode.vue` template. Care was taken to make implementation to have
low cost for performance and complexity when not in app mode setup.
- Context menu event handlers are added to each widget in vue mode
instead of resolving the target widget of an event
- Swap the nodeId passed by `useGraphNodeManager` to not include the
locator id. This id was never used and was incorrect since it didn't
resolve across nested subgraphs.
- Continued bug fixes for app mode as a whole.

Known issue: There is disparity of nodeId between litegraph (which
references the widget in the root graph) and vue (which promotes the
original widget). Efforts to reconcile are ongoing.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9259-Support-selection-app-inputs-and-outputs-from-vue-mode-3136d73d365081ae8e56e35bf6322409)
by [Unito](https://www.unito.io)

---------

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-02 09:49:21 -08:00
Comfy Org PR Bot
84d7aa0fd9 1.41.9 (#9312)
Patch version increment to 1.41.9

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-01 20:19:10 -08:00
Christian Byrne
59c3215296 fix: skip CodeRabbit reviews on bot and release PRs (#9279)
## Problem

CodeRabbit is reviewing release and backport PRs created by bots (e.g.
[#9264](https://github.com/Comfy-Org/ComfyUI_frontend/pull/9264)),
leaving unnecessary review comments.

## Solution

Add ignore rules to `.coderabbit.yaml`:

- **`ignore_usernames`**: `comfy-pr-bot`, `github-actions` — skips
reviews on PRs authored by these bot accounts
- **`ignore_title_keywords`**: `[release]`, `[backport` — skips reviews
on release and backport PRs by title

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9279-fix-skip-CodeRabbit-reviews-on-bot-and-release-PRs-3146d73d3650814c9ebae0d08acbafd6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-28 23:29:21 -08:00
Hunter
589f58f916 feat: add ever-present upgrade button for free-tier users (#9315)
## Summary

Add persistent upgrade CTAs for free-tier users: a topbar button and
"Upgrade to add credits" replacing "Add Credits" in popovers and
settings panels.

## Changes

- **What**:
- New `TopbarSubscribeButton` component in both GraphCanvas and
LinearView topbars, visible only to free-tier users
- Profile popover (legacy + workspace): free-tier users see "Upgrade to
add credits" instead of "Add Credits", linking directly to the pricing
table
- Manage Plan settings (legacy + workspace): same replacement —
free-tier users see "Upgrade to add credits" instead of "Add Credits"
- Paid-tier users retain the original "Add Credits" behavior in all
locations
  - All upgrade buttons go directly to the pricing table (one-step flow)

## Review Focus

- The `isFreeTier` conditional gating on the buttons — ensure free-tier
users see upgrade CTAs and paid users see normal Add Credits
- Layout in Manage Plan panels uses `flex flex-col gap-3` to stack the
upgrade button below the usage history link instead of side-by-side

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9315-feat-add-ever-present-upgrade-button-for-free-tier-users-3166d73d365081228cdfe6a67fec6aec)
by [Unito](https://www.unito.io)
2026-02-28 20:07:12 -08:00
Hunter
7c8a548798 feat: add cloud frontend build dispatch workflow (#9308)
## Summary

Adds `.github/workflows/cloud-dispatch-build.yaml` — fires a
`repository_dispatch` event (`frontend-asset-build`) to
`Comfy-Org/cloud` on push to `cloud/*` branches and `main`.

The cloud repo handles the actual build, GCS upload, and secret
management (Sentry, Algolia, GCS creds). This is fire-and-forget.

## Changes

- New workflow: `cloud-dispatch-build.yaml`
- Trigger: `push` to `cloud/*` and `main` only
- Payload: `ref` (commit SHA) + `branch` (branch name), built with `jq`
to prevent injection
- SHA-pinned `peter-evans/repository-dispatch@v4.0.1`
- Hardened: `permissions: {}`, fork guard (`if: github.repository ==
'Comfy-Org/ComfyUI_frontend'`), concurrency to avoid dispatch storms
- `cloud-deploy-frontend.yaml` left unchanged (still needed during
migration)

## Setup Required

A repository secret `CLOUD_DISPATCH_TOKEN` must be configured — see PR
description comments.

## Part of

Frontend separate deploy prep (Task 1.3)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9308-feat-add-cloud-frontend-build-dispatch-workflow-3156d73d36508164a515eb968f6c5d79)
by [Unito](https://www.unito.io)
2026-02-28 17:59:19 -05:00
Alexander Brown
dd1a1f77d6 fix: stabilize nested subgraph promoted widget resolution (#9282)
## Summary

Fix multiple issues with promoted widget resolution in nested subgraphs,
ensuring correct value propagation, slot matching, and rendering for
deeply nested promoted widgets.

## Changes

- **What**: Stabilize nested subgraph promoted widget resolution chain
- Use deep source keys for promoted widget values in Vue rendering mode
- Resolve effective widget options from the source widget instead of the
promoted view
  - Stabilize slot resolution for nested promoted widgets
  - Preserve combo value rendering for promoted subgraph widgets
- Prevent subgraph definition deletion while other nodes still reference
the same type
  - Clean up unused exported resolution types

## Review Focus

- `resolveConcretePromotedWidget.ts` — new recursive resolution logic
for deeply nested promoted widgets
- `useGraphNodeManager.ts` — option extraction now uses
`effectiveWidget` for promoted widgets
- `SubgraphNode.ts` — unpack no longer force-deletes definitions
referenced by other nodes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9282-fix-stabilize-nested-subgraph-promoted-widget-resolution-3146d73d365081208a4fe931bb7569cf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-28 13:45:04 -08:00
pythongosssss
0ab3fdc2c9 Add indicator circle when new unseen menu items are available (#9220)
## Summary

Adds a little indicator circle when new workflow menu items are added
that the user has not seen

## Changes

- **What**: Adds a hidden setting to track menu items flagged as new
that have been seen

## Screenshots (if applicable)

<img width="164" height="120" alt="image"
src="https://github.com/user-attachments/assets/ac36673d-fbf1-42ff-9a9e-1371eb96115b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9220-Add-indicator-circle-when-new-unseen-menu-items-are-available-3126d73d3650819cb8cde854d6b6510b)
by [Unito](https://www.unito.io)
2026-02-28 12:53:26 -08:00
Terry Jia
ec1977131d feat: wrap CURVE widget value with typed format (#9294)
## Summary
Send CURVE values as { __type: 'CURVE', value: [...] } instead of {
__value__: [...] } to avoid ambiguity with link detection and enable
external tools to identify the data type.

change requested by @guill

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9294-feat-wrap-CURVE-widget-value-with-typed-format-3156d73d365081bf8e5de59527e2d3ce)
by [Unito](https://www.unito.io)
2026-02-28 15:00:39 -05:00
Christian Byrne
3f497081ee feat: Node Library sidebar and V2 Search dialog UI/UX updates (#9085)
## Summary

Implement 11 Figma design discrepancies for the Node Library sidebar and
V2 Node Search dialog, aligning the UI with the [Toolbox Figma
design](https://www.figma.com/design/xMFxCziXJe6Denz4dpDGTq/Toolbox?node-id=2074-21394&m=dev).

## Changes

- **What**: Sidebar: reorder tabs (All/Essentials/Blueprints), rename
Custom→Blueprints, uppercase section headers, chevron-left of folder
icon, bookmark-on-hover for node rows, filter dropdown with checkbox
items, sort labels (Categorized/A-Z) with label-left/check-right layout,
hide section headers in A-Z mode. Search dialog: expand filter chips
from 3→6, add Recents and source categories to sidebar, remove "Filter
by" label. Pull foundation V2 components from merged PR #8548.
- **Dependencies**: Depends on #8987 (V2 Node Search) and #8548
(NodeLibrarySidebarTabV2)

## Review Focus

- Filter dropdown (`filterOptions`) is UI-scaffolded but not yet wired
to filtering logic (pending V2 integration)
- "Recents" category currently returns frequency-based results as
placeholder until a usage-tracking store is implemented
- Pre-existing type errors from V2 PR dependencies not in the base
commit (SearchBoxV2, usePerTabState, TextTicker, getProviderIcon,
getLinkTypeColor, SidebarContainerKey) are expected and will resolve
when rebased onto main after parent PRs land

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9085-feat-Node-Library-sidebar-and-V2-Search-dialog-Figma-design-improvements-30f6d73d36508175bf72d716f5904476)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-28 22:34:27 +08:00
jaeone94
a0e518aa98 refactor(node-replacement): reorganize domain components and expand comprehensive test suite (#9301)
## Summary

Resolves six open issues by reorganizing node replacement components
into a domain-driven folder structure, refactoring event handling to
follow the emit pattern, and adding comprehensive test coverage across
all affected modules.

## Changes

- **What**:
- Moved `SwapNodeGroupRow.vue` and `SwapNodesCard.vue` from
`src/components/rightSidePanel/errors/` to
`src/platform/nodeReplacement/components/` (Issues #9255)
- Moved `useMissingNodeScan.ts` from `src/composables/` to
`src/platform/nodeReplacement/missingNodeScan.ts`, renamed to reflect it
is a plain function not a Vue composable (Issues #9254)
- Refactored `SwapNodeGroupRow.vue` to emit a `'replace'` event instead
of calling `useNodeReplacement()` and `useExecutionErrorStore()`
directly; replacement logic now handled in `TabErrors.vue` (Issue #9267)
- Added unit tests for `removeMissingNodesByType`
(`executionErrorStore.test.ts`), `scanMissingNodes`
(`missingNodeScan.test.ts`), and `swapNodeGroups` computed
(`swapNodeGroups.test.ts`, `useErrorGroups.test.ts`) (Issue #9270)
- Added placeholder detection tests covering unregistered-type detection
when `has_errors` is false, and exclusion of registered types
(`useNodeReplacement.test.ts`) (Issue #9271)
- Added component tests for `MissingNodeCard` and `MissingPackGroupRow`
covering rendering, expand/collapse, events, install states, and edge
cases (Issue #9231)
- Added component tests for `SwapNodeGroupRow` and `SwapNodesCard`
(Issues #9255, #9267)

## Additional Changes (Post-Review)

- **Edge case guard in placeholder detection**
(`useNodeReplacement.ts`): When `last_serialization.type` is absent (old
serialization format), the predicate falls back to `n.type`, which the
app may have already run through `sanitizeNodeName` — stripping HTML
special characters (`& < > " ' \` =`). In that case, a `Set.has()`
lookup against the original unsanitized type name would silently miss,
causing replacement to be skipped.

Fixed by including sanitized variants of each target type in the
`targetTypes` Set at construction time. For the overwhelmingly common
case (no special characters in type names), the Set deduplicates the
entries and runtime behavior is identical to before.

A regression test was added to cover the specific scenario:
`last_serialization.type` absent + live `n.type` already sanitized.

## Review Focus

- `TabErrors.vue`: confirm the new `@replace` event handler correctly
replaces nodes and removes them from missing nodes list (mirrors the old
inline logic in `SwapNodeGroupRow`)
- `missingNodeScan.ts`: filename/export name change from
`useMissingNodeScan` — verify all call sites updated via `app.ts`
- Test mocking strategy: module-level `vi.mock()` factories use closures
over `ref`/plain objects to allow per-test overrides without global
mutable state

- Fixes #9231
- Fixes #9254
- Fixes #9255
- Fixes #9267
- Fixes #9270
- Fixes #9271
2026-02-28 06:17:30 -08:00
jaeone94
45f112e226 fix: node replacement fails after execution and modal sync (#9269)
## Summary

Fixes two bugs in the node replacement flow: placeholder detection
failing after workflow execution or pack reinstallation, and missing UI
sync in the Errors Tab when replacements are applied from the modal
dialog.

## Changes

- **Placeholder detection**: Node placeholder detection now matches
against `targetTypes` (derived from the replaceable node list built at
workflow load time) instead of relying on `has_errors` flag or
`registered_node_types` lookup. This ensures replacement works reliably
after execution (where `has_errors` gets cleared) and after pack
reinstallation (where the type becomes registered).
- **Modal → Errors Tab sync**: Added
`executionErrorStore.removeMissingNodesByType()` call in
`MissingNodesContent.vue` after replacement, so the Errors Tab reflects
changes immediately without requiring a page reload.

## Review Focus

- `collectAllNodes` predicate change in `useNodeReplacement.ts`: now
uses `targetTypes.has(originalType)` to find nodes by their original
serialized type. This is independent of runtime state like `has_errors`
or `registered_node_types`.
- `executionErrorStore.removeMissingNodesByType` call timing in
`MissingNodesContent.vue` — runs synchronously after
`replaceNodesInPlace` resolves, before auto-close logic.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9269-fix-node-replacement-fails-after-execution-and-modal-sync-3146d73d365081218398c961639b450f)
by [Unito](https://www.unito.io)
2026-02-28 04:05:58 -08:00
Comfy Org PR Bot
b80eace639 1.41.8 (#9288)
Patch version increment to 1.41.8

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9288-1-41-8-3156d73d3650817ca737ced3e08d8c86)
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-02-28 01:07:23 -08:00
Christian Byrne
8da07f2ce2 fix: pre-rasterize SubgraphNode SVG icon to bitmap canvas (#9172)
## Summary

Pre-rasterize the SubgraphNode SVG icon to a bitmap canvas to eliminate
Firefox's per-frame SVG style processing.

## Changes

- **What**: Add `getWorkflowBitmap()` that lazily rasterizes the
`data:image/svg+xml` workflow icon to an `HTMLCanvasElement` (16×16) on
first use. `SubgraphNode.drawTitleBox()` draws the cached bitmap instead
of the raw SVG.

## Review Focus

- Firefox re-processes SVG internal stylesheets (`stroke`,
`stroke-linecap`, `stroke-width`) every time `ctx.drawImage(svgImage)`
is called. Chrome caches the rasterization. This happens on every frame
for every visible SubgraphNode.
- Reporter confirmed strong subgraph correlation: "it may be happening
in the default workflow with subgraph" / "didn't seem to happen just
using manually wired up diffusion loader, clip, sampler, etc."
- Falls back to the raw SVG Image if not yet loaded or if
`getContext('2d')` returns null.

## Stack

3 of 4 in Firefox perf fix stack. Depends on #9170.

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9172-fix-pre-rasterize-SubgraphNode-SVG-icon-to-bitmap-canvas-3116d73d365081babf17cf0848d37269)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-28 01:06:54 -08:00
AustinMroz
ea5ffcc66e Fix essentials nodes not being marked core (#9287)
In adding an essentials cateogory for nodes, #8987 introduced a
regression where core nodes which are also essential are marked as being
from a `nodes` custom node instead of being marked core. Since the
essentials designation should pre-empt core and custom nodes can choose
to mark themself as essential, the getter for `isCoreNode` is updated to
instead repeat the existing check for if a node is core.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/f1b8bf80-d072-409a-a0f9-4837e1d11767"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/14ff525b-9833-4e73-888f-791aff6cf531"/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9287-Fix-essentials-nodes-not-being-marked-core-3146d73d365081fca2a0f8bdc2baf01a)
by [Unito](https://www.unito.io)
2026-02-27 16:23:08 -08:00
pythongosssss
07dab97aed App builder exit updates (#9218)
## Summary

- remove exit builder button from right panel
- add builder exit button to bottom of canvas
- add builder menu with save & exit in top left

## Screenshots (if applicable)

<img width="1544" height="998" alt="image"
src="https://github.com/user-attachments/assets/f5deadc5-2bf5-4729-b644-2b6a815b9975"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9218-App-builder-exit-updates-3126d73d365081a0bf1adf92e1171060)
by [Unito](https://www.unito.io)
2026-02-27 13:55:05 -08:00
pythongosssss
f83daa6f3b App mode - discard slow preview messages to prevent overwriting output image (#9261)
## Summary

Prevent latent previews received after the job/node has already finished
processing overwriting the actual output display

## Changes

- **What**: 
- updates job preview store to also track which node the preview was for
- updates linear progress tracking to store executed nodes enabling
skipping previews of these

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- 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-9261-App-mode-discard-slow-preview-messages-to-prevent-overwriting-output-image-3136d73d3650817884c2ce2ff5993b9e)
by [Unito](https://www.unito.io)
2026-02-27 10:58:41 -08:00
pythongosssss
c090d189f0 Render app builder in arrange mode (#9260)
## Summary

Adds app builder in arrange/preview mode with re-orderable widgets,
maintaining size (as much as possible) between the select + preview
steps

## Changes

- **What**: 
- Extract sidebar size constants for sharing between canvas splitter +
app mode
- Add widget list using DraggableList and render inert WidgetItems

## Screenshots (if applicable)

<img width="1391" height="923" alt="image"
src="https://github.com/user-attachments/assets/3e17eafe-db1e-40a3-83b5-15a7d0672909"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9260-Render-app-builder-in-arrange-mode-3136d73d365081ef875acab683d01d9e)
by [Unito](https://www.unito.io)
2026-02-27 02:32:44 -08:00
Benjamin Lu
7901e14318 fix: make docked job history toggle persistence-safe (#9265)
## Summary
Follow-up to #9215 to keep Docked Job History toggle behavior
deterministic even when settings persistence fails.

## Changes
- Close the actions popover immediately when toggling Docked Job
History.
- Use settingStore.setMany(...) when switching from docked to floating
mode.
- Set sidebarTabStore.activeSidebarTabId = 'job-history' before
persisting when switching from floating to docked mode.
- Wrap persistence calls in try/catch without rollback so
locally-applied UI state remains deterministic.
- Expand QueueOverlayHeader tests to cover setMany, popover close
behavior, and persistence-failure paths.

## Testing
- pnpm test:unit -- src/components/queue/QueueOverlayHeader.test.ts
- pnpm typecheck
- pnpm exec eslint src/components/queue/JobHistoryActionsMenu.vue
src/components/queue/QueueOverlayHeader.test.ts
- pnpm lint (fails in this branch due pre-existing stylelint errors in
generated apps/desktop-ui/dist/**/*.css files, unrelated to this change)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9265-fix-make-docked-job-history-toggle-persistence-safe-3146d73d3650818f86c4dfdd57669abd)
by [Unito](https://www.unito.io)
2026-02-26 19:49:08 -08:00
Benjamin Lu
c8b1cd9dfb fix: remove beta labeling from comfy cloud badges (#9184)
Remove the BETA label from Comfy Cloud badges while keeping the `Comfy
Cloud` text.

This updates both paths that render Comfy Cloud badge content:
- `src/extensions/core/cloudBadges.ts` (topbar extension badge path)
- `src/components/topbar/CloudBadge.vue` (reusable cloud badge used in
subscription UI)

<img width="479" height="106" alt="image"
src="https://github.com/user-attachments/assets/a73e0607-e747-4335-b09e-cf45b5016ff5"
/>

Reasoning:

https://comfy-organization.slack.com/archives/C08V9NDB3B4/p1771981530055409?thread_ts=1771978875.127499&cid=C08V9NDB3B4

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9184-fix-remove-beta-labeling-from-comfy-cloud-badges-3126d73d365081e993aac651993010e7)
by [Unito](https://www.unito.io)
2026-02-26 19:48:55 -08:00
Comfy Org PR Bot
5e99faadfe 1.41.7 (#9264)
Patch version increment to 1.41.7

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9264-1-41-7-3146d73d36508108a0d1c3e418216cd0)
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-02-26 19:31:49 -08:00
Benjamin Lu
f495f07469 fix: move active jobs button into actionbar (#9211)
## Summary

Move the top menu `N active` queue button into `ComfyActionbar` so it
stays attached to the actionbar when docked, dragged, or floating.

## Changes

- Moved the `queue-overlay-toggle` button UI from `TopMenuSection.vue`
into `ComfyActionbar.vue`
- Moved queue button behavior and context menu handling (`toggle`,
right-click clear queue, active count badge/label) into
`ComfyActionbar.vue`
- Removed now-unused queue button state/handlers/imports from
`TopMenuSection.vue`

<img width="513" height="101" alt="image"
src="https://github.com/user-attachments/assets/6ed85237-4293-47a6-a0fb-258d0182889d"
/>

<img width="778" height="145" alt="image"
src="https://github.com/user-attachments/assets/6e5ef423-c5fc-471f-b9d0-a4bd8dc5d072"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9211-fix-move-active-jobs-button-into-actionbar-3126d73d365081ceb553c172db479e3b)
by [Unito](https://www.unito.io)
2026-02-26 19:00:10 -08:00
Christian Byrne
1054ba8949 fix: batch updateClipPath via requestAnimationFrame (#9173)
## Summary

Batch `getBoundingClientRect()` calls in `updateClipPath` via
`requestAnimationFrame` to avoid forced synchronous layout.

## Changes

- **What**: Wrap the layout-reading portion of `updateClipPath` in
`requestAnimationFrame()` with cancellation. Multiple rapid calls within
the same frame are coalesced into a single layout read. Eliminates
~1,053 forced synchronous layouts per profiling session.

## Review Focus

- `getBoundingClientRect()` forces synchronous layout. When interleaved
with style mutations (from PrimeVue `useStyle`, cursor writes, Vue VDOM
patching), this creates layout thrashing — especially in Firefox where
Stylo aggressively invalidates the entire style cache.
- The RAF wrapper coalesces all calls within a frame into one, reading
layout only once per frame. The `cancelAnimationFrame` ensures only the
latest parameters are used.
- `willChange: 'clip-path'` is included to hint the browser to optimize
clip-path animations.

## Stack

4 of 4 in Firefox perf fix stack. Depends on #9170.

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9173-fix-batch-updateClipPath-via-requestAnimationFrame-3116d73d3650810392f7fba7ea5ceb6f)
by [Unito](https://www.unito.io)
2026-02-26 18:53:14 -08:00
Christian Byrne
0698ec23c0 feat: wire essentials_category for Essentials tab display (#9091)
## Summary

Wire `essentials_category` through from backend to the Essentials tab
UI. Creates a single source of truth for node categorization and
ordering.

### Changes

**New file — `src/constants/essentialsNodes.ts`:**
- Single source of truth: `ESSENTIALS_NODES` (ordered nodes per
category), `ESSENTIALS_CATEGORIES` (folder display order),
`ESSENTIALS_CATEGORY_MAP` (flat lookup), `TOOLKIT_NOVEL_NODE_NAMES`
(telemetry), `TOOLKIT_BLUEPRINT_MODULES`

**Refactored files:**
- `src/types/nodeSource.ts`: Removed inline `ESSENTIALS_CATEGORY_MOCK`,
imports `ESSENTIALS_CATEGORY_MAP` from centralized constants
- `src/services/nodeOrganizationService.ts`: Removed inline
`NODE_ORDER_BY_FOLDER`, imports `ESSENTIALS_NODES` and
`ESSENTIALS_CATEGORIES`
- `src/constants/toolkitNodes.ts`: Re-exports from `essentialsNodes.ts`
instead of maintaining a separate list

**Subgraph passthrough:**
- `src/stores/subgraphStore.ts`: Passes `essentials_category` from
`GlobalSubgraphData` and extracts it from `definitions.subgraphs[0]` as
fallback
- `src/platform/workflow/validation/schemas/workflowSchema.ts`: Added
`essentials_category` to `SubgraphDefinitionBase` and
`zSubgraphDefinition`

**Tests:**
- `src/constants/essentialsNodes.test.ts`: 6 tests validating no
duplicates, complete coverage, basics exclusion
- `src/stores/subgraphStore.test.ts`: 2 tests for essentials_category
passthrough

All 43 relevant tests pass. Typecheck, lint, format clean.

**Depends on:** Comfy-Org/ComfyUI#12573

Fixes COM-15221

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9091-feat-wire-essentials_category-for-Essentials-tab-display-30f6d73d3650814ab3d4c06b451c273b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-26 18:40:15 -08:00
Johnpaul Chiwetelu
54b710b239 [refactor] Rename queueIndex variables to reflect job.priority usage (#9258)
## Summary
Rename `lastHistoryQueueIndex` → `lastJobHistoryPriority` and
`currentQueueIndex` → `currentJobPriority` to reflect that these
variables now read `job.priority` directly.

## Changes
- **queueStore.ts**: `lastHistoryQueueIndex` → `lastJobHistoryPriority`
- **JobDetailsPopover.vue**: `currentQueueIndex` → `currentJobPriority`
- **queueStore.test.ts**: Updated references and test descriptions

Fixes #9246

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9258-refactor-Rename-queueIndex-variables-to-reflect-job-priority-usage-3136d73d36508126989dd464f7dad9a1)
by [Unito](https://www.unito.io)
2026-02-26 18:31:27 -08:00
jaeone94
1c3984a178 feat: add node replacement UI to Errors Tab (#9253)
## Summary

Adds a node replacement UI to the Errors Tab so users can swap missing
nodes with compatible alternatives directly from the error panel,
without opening a separate dialog.

## Changes

- **What**: New `SwapNodesCard` and `SwapNodeGroupRow` components render
swap groups in the Errors Tab; each group shows the missing node type,
its instances (with locate buttons), and a Replace button. Added
`useMissingNodeScan` composable to scan the graph for missing nodes and
populate `executionErrorStore`. Added `removeMissingNodesByType()` to
`executionErrorStore` so replaced nodes are pruned from the error list
reactively.

## Bug Fixes Found During Implementation

### Bug 1: Replaced nodes render as empty shells until page refresh

`replaceWithMapping()` directly mutates `_nodes[idx]`, bypassing the Vue
rendering pipeline entirely. Because the replacement node reuses the
same ID, `vueNodeData` retains the stale entry from the old placeholder
(`hasErrors: true`, empty widgets/inputs). `graph.setDirtyCanvas()` only
repaints the LiteGraph canvas and has no effect on Vue.

**Fix**: After `replaceWithMapping()`, manually call
`nodeGraph.onNodeAdded?.(newNode)` to trigger `handleNodeAdded` in
`useGraphNodeManager`, which runs `extractVueNodeData(newNode)` and
updates `vueNodeData` correctly. Also added a guard in `handleNodeAdded`
to skip `layoutStore.createNode()` when a layout for the same ID already
exists, preventing a duplicate `spatialIndex.insert()`.

### Bug 2: Missing node error list overwritten by incomplete server
response

Two compounding issues: (A) the server's `missing_node_type` error only
reports the *first* missing node — the old handler parsed this and
called `surfaceMissingNodes([singleNode])`, overwriting the full list
collected at load time. (B) `queuePrompt()` calls `clearAllErrors()`
before the API request; if the subsequent rescan used the stale
`has_errors` flag and found nothing, the missing nodes were permanently
lost.

**Fix**: Created `useMissingNodeScan.ts` which scans
`LiteGraph.registered_node_types` directly (not `has_errors`). The
`missing_node_type` catch block in `app.ts` now calls
`rescanAndSurfaceMissingNodes(this.rootGraph)` instead of parsing the
server's partial response.

## Review Focus

- `handleReplaceNode` removes the group from the store only when
`replaceNodesInPlace` returns at least one replaced node — should we
always clear, or only on full success?
- `useMissingNodeScan` re-scans on every execution-error change; confirm
no performance concerns for large graphs with many subgraphs.


## Screenshots 


https://github.com/user-attachments/assets/78310fc4-0424-4920-b369-cef60a123d50



https://github.com/user-attachments/assets/3d2fd5e1-5e85-4c20-86aa-8bf920e86987



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9253-feat-add-node-replacement-UI-to-Errors-Tab-3136d73d365081718d4ddfd628cb4449)
by [Unito](https://www.unito.io)
2026-02-26 17:37:48 -08:00
Benjamin Lu
367d96715b fix: open target panel when toggling Docked Job History (#9215)
## Summary
Make the Docked Job History toggle deterministic so it opens the
expected UI target in both directions.

## Changes
- Update `JobHistoryActionsMenu` toggle behavior:
- When currently docked (`Comfy.Queue.QPOV2=true`), disable docked mode
and explicitly open floating QPO (`Comfy.Queue.History.Expanded=true`)
- When currently floating (`Comfy.Queue.QPOV2=false`), enable docked
mode and open the `job-history` sidebar tab
- Add/adjust unit tests in `QueueOverlayHeader.test.ts` to verify both
toggle directions and target panel behavior

## Testing
- `pnpm exec eslint src/components/queue/JobHistoryActionsMenu.vue
src/components/queue/QueueOverlayHeader.test.ts`
- `pnpm typecheck`
- `pnpm test:unit -- src/components/queue/QueueOverlayHeader.test.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9215-fix-open-target-panel-when-toggling-Docked-Job-History-3126d73d3650810eb409ff38e3a521f3)
by [Unito](https://www.unito.io)
2026-02-26 16:21:31 -08:00
Benjamin Lu
84fdf55902 fix: set queue job filter tabs to 32px (#9217)
## Summary
- Increase queue job filter tab button height from `sm` to `md` (`32px`
equivalent).
- Apply consistently to both floating Queue Progress Overlay and docked
Job History sidebar, since both use `JobFilterTabs`.

## Design
-
https://www.figma.com/board/R9eN9DHmDgX3qEJXsRKiRr/QA-Feedback--Alex-?node-id=273-111&t=OUCBdoZhwrOMsxXE-4

## Testing
- `pnpm typecheck`
- `pnpm lint` (passes with existing repo warnings only)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9217-fix-set-queue-job-filter-tabs-to-32px-3126d73d36508106a9c8e3786ab77aa5)
by [Unito](https://www.unito.io)
2026-02-26 16:09:26 -08:00
pythongosssss
9fb93a5b0a App mode - more updates & fixes (#9137)
## Summary

- fix sizing of sidebars in app mode
- update feedback button to match design
- update job queue notification
- clickable queue spinner item to allow clear queue
- refactor mode out of store to specific workflow instance
- support different saved vs active mode
- other styling/layout tweaks

## Changes

- **What**: Changes the store to a composable and moves the mode state
to the workflow.
- This enables switching between tabs and maintaining the mode they were
in

## Screenshots (if applicable)
<img width="1866" height="1455" alt="image"
src="https://github.com/user-attachments/assets/f9a8cd36-181f-4948-b48c-dd27bd9127cf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9137-App-mode-more-updates-fixes-3106d73d365081a18ccff6ffe24fdec7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-26 09:55:10 -08:00
Benjamin Lu
ac12a3d9b9 fix: preserve refill date slashes in subscription credits label (#9251)
### Motivation
- Subscription credit labels were rendering the refill date with
HTML-escaped separators (`&#x2F;`) because `vue-i18n` parameter escaping
was applied to the date interpolation.
- The goal is to render date-only parameters like `MM/DD/YY` with
literal slashes so the UI shows a human-readable date string.

### Description
- Disabled `vue-i18n` parameter escaping for the
`subscription.creditsRemainingThisMonth` and
`subscription.creditsRemainingThisYear` lookups in both subscription
panels by passing `{ escapeParameter: false }` to `t()` in
`SubscriptionPanelContentLegacy.vue` and
`SubscriptionPanelContentWorkspace.vue`.
- Adjusted the unit test i18n setup in `SubscriptionPanel.test.ts` to
include `escapeParameter: true` in the test `i18n` instance and updated
the test messages to use `Included (Refills {date})`.
- Added a regression unit test in `SubscriptionPanel.test.ts` asserting
the rendered label contains `Included (Refills 12/31/24)` and does not
contain the escaped entity `&#x2F;`.

### Testing
- Ran formatting with `pnpm format` which completed successfully.
- Ran lint via `pnpm lint` which passed with pre-existing warnings only
(no new errors).
- Ran type checking with `pnpm typecheck` (via `vue-tsc --noEmit`) which
completed successfully.
- Ran the modified unit tests with `pnpm vitest run
src/platform/cloud/subscription/components/SubscriptionPanel.test.ts`
and the test file passed (10 passed, 5 skipped).
- Attempted a Playwright-based visual capture of the running app but
Chromium crashed in this environment (SIGSEGV) before navigation, so no
screenshot was produced.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_69a0175f58788330b2256329a500e14b)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9251-fix-preserve-refill-date-slashes-in-subscription-credits-label-3136d73d36508182b770f5719a52d189)
by [Unito](https://www.unito.io)
2026-02-26 09:37:03 -08:00
Johnpaul Chiwetelu
45ca1beea2 fix: address small CodeRabbit issues (#9229)
## Summary

Address several small CodeRabbit-filed issues: clipboard simplification,
queue getter cleanup, pointer handling, and test parameterization.

## Changes

- **What**:
- Simplify `useCopyToClipboard` by using VueUse's built-in `legacy` mode
instead of a manual `document.execCommand` fallback
- Remove `queueIndex` getter alias from `TaskItemImpl`, replace all
usages with `job.priority`
- Add `pointercancel` event handling and try-catch around
`releasePointerCapture` in `useNodeResize` to prevent stuck resize state
- Parameterize repetitive `getNodeProvider` tests in
`modelToNodeStore.test.ts` using `it.each()`

- Fixes #9024
- Fixes #7955
- Fixes #7323
- Fixes #8703

## Review Focus

- `useCopyToClipboard`: VueUse's `legacy: true` enables the
`execCommand` fallback internally — verify browser compat is acceptable
- `useNodeResize`: cleanup logic extracted into shared function used by
both `pointerup` and `pointercancel`
2026-02-26 02:32:53 -08:00
Christian Byrne
aef299caf8 fix: add GLSLShader to canvas image preview node types (#9198)
## Summary

Add `GLSLShader` to `CANVAS_IMAGE_PREVIEW_NODE_TYPES` so GLSL shader
previews are promoted through subgraph nodes.

## Changes

- Add `'GLSLShader'` to the `CANVAS_IMAGE_PREVIEW_NODE_TYPES` set in
`src/composables/node/useNodeCanvasImagePreview.ts`

## Context

GLSLShader node previews were not showing on parent subgraph nodes
because `CANVAS_IMAGE_PREVIEW_NODE_TYPES` only included `PreviewImage`
and `SaveImage`. The `$$canvas-image-preview` pseudo-widget was never
created for GLSLShader nodes, so the promotion system had nothing to
promote. This degraded the UX of all 12 shipped GLSL blueprint subgraphs
— users couldn't see shader output previews without expanding the
subgraph.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9198-fix-add-GLSLShader-to-canvas-image-preview-node-types-3126d73d3650817dbe9beab4bdeaa414)
by [Unito](https://www.unito.io)
2026-02-26 01:15:24 -08:00
Johnpaul Chiwetelu
188fafa89a fix: address trivial CodeRabbit issues (#9196)
## Summary

Address several trivial CodeRabbit-filed issues: type guard extraction,
ESLint globals, curve editor optimizations, and type relocation.

## Changes

- **What**: Extract `isSingleImage()` type guard in WidgetImageCompare;
add `__DISTRIBUTION__`/`__IS_NIGHTLY__` to ESLint globals and remove
stale disable comments; remove unnecessary `toFixed(4)` from curve path
generation; optimize `histogramToPath` with array join; move
`CurvePoint` type to curve domain

- Fixes #9175
- Fixes #8281
- Fixes #9116
- Fixes #9145
- Fixes #9147

## Review Focus

All changes are mechanical/trivial. Curve path output changes from
fixed-precision to raw floats — SVG handles both fine.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9196-fix-address-trivial-CodeRabbit-issues-3126d73d365081f19a5ce20305403098)
by [Unito](https://www.unito.io)
2026-02-26 00:43:14 -08:00
Christian Byrne
3984408d05 docs: add comment explaining widget value store dom widgets getter nuance (#9202)
Adds comment explaining nuance with the differing registration semantics
between DOM widget vs base widet.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9202-fix-widget-value-store-dom-widgets-getter-3126d73d365081368b94f048efb101fa)
by [Unito](https://www.unito.io)
2026-02-25 23:44:33 -08:00
Christian Byrne
6034be9a6f fix: add GLSLShader to toolkit node telemetry tracking (#9197)
## Summary

Add `GLSLShader` to `TOOLKIT_NODE_NAMES` so Mixpanel telemetry tracks
GLSL shader node usage alongside other toolkit nodes.

## Changes

- Add `'GLSLShader'` to the `TOOLKIT_NODE_NAMES` set in
`src/constants/toolkitNodes.ts`

## Context

The Toolkit Nodes PRD defines success metrics that require tracking "%
of workflows using one of these nodes" and "how often each node is
used." GLSLShader was missing from the tracking list, so no
GLSL-specific telemetry was being collected despite 12 GLSL blueprints
shipping in prod (BlueprintsVersion 0.9.1).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9197-fix-add-GLSLShader-to-toolkit-node-telemetry-tracking-3126d73d3650814dad05fa78382d5064)
by [Unito](https://www.unito.io)
2026-02-25 22:19:50 -08:00
Christian Byrne
6a08e4ddde Revert "fix: sync DOM widget values to widgetValueStore on registration" (#9205)
Reverts Comfy-Org/ComfyUI_frontend#9166

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9205-Revert-fix-sync-DOM-widget-values-to-widgetValueStore-on-registration-3126d73d365081df8944d3c6508d2372)
by [Unito](https://www.unito.io)
2026-02-25 21:36:52 -08:00
Hunter
9ff985a792 fix: sync DOM widget default values to widgetValueStore on registration (#9164)
## Description

DOM widgets (textarea/customtext) override the `value` getter via
`Object.defineProperty` to use `getValue()/setValue()` with a fallback
to `inputEl.value`. But `BaseWidget.setNodeId()` registered
`_state.value` (undefined from constructor) instead of `this.value` (the
actual getter).

This caused Vue nodes (Nodes 2.0) to read `undefined` from the store and
display empty textareas, while execution correctly fell back to
`inputEl.value`.

**Fix:** Use `this.value` in `setNodeId()` so the store is initialized
with the actual widget value.

**Impact:** Fixes Nano Banana / Nano Banana Pro `system_prompt` showing
empty in Nodes 2.0 while still sending the correct value during
execution.

## Thread

https://ampcode.com/threads/T-019c8e99-49ce-77f5-bf2a-a32320fac477

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9164-fix-sync-DOM-widget-default-values-to-widgetValueStore-on-registration-3116d73d36508169a2fbd8308d9eec91)
by [Unito](https://www.unito.io)
2026-02-25 21:35:59 -08:00
Terry Jia
5cfd1aa77e feat: add Painter Node (#8521)
## Summary
Add PainterNode widget for freehand mask drawing directly on the canvas,
with brush/eraser tools, opacity, hardness, and background color
controls.

need BE changes https://github.com/Comfy-Org/ComfyUI/pull/12294

## Screenshots (if applicable)


https://github.com/user-attachments/assets/7222063a-0e40-40bb-b72e-b42c8984beb9



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8521-feat-add-Painter-Node-2fa6d73d36508124ab2ede449a0cc67a)
by [Unito](https://www.unito.io)
2026-02-25 21:08:49 -08:00
Christian Byrne
2cb4c5eff3 fix: textarea stays disabled after link disconnect on promoted widgets (#9199)
## Summary

Fix textarea widgets staying disabled after disconnecting a link on
promoted widgets in subgraphs.

## Changes

- **What**: `refreshNodeSlots` used `SafeWidgetData.name` for slot
metadata lookups, but for promoted widgets this is `sourceWidgetName`
(the interior widget name), which doesn't match the subgraph node's
input slot widget name. Added `slotName` field to `SafeWidgetData` to
track the original LiteGraph widget name, and updated `refreshNodeSlots`
to use `slotName ?? name` for correct matching.

## Review Focus

The key change is the `slotName` field on `SafeWidgetData` — it's only
populated when `name !== widget.name` (i.e., for promoted widgets). The
`refreshNodeSlots` function now uses `widget.slotName ?? widget.name` to
look up slot metadata, ensuring promoted widgets correctly update their
`linked` state on disconnect.

Fixes #8818

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9199-fix-textarea-stays-disabled-after-link-disconnect-on-promoted-widgets-3126d73d3650813db499c227e6587aca)
by [Unito](https://www.unito.io)
2026-02-25 20:50:11 -08:00
Benjamin Lu
b8cca4167b fix: show inline progress in QPOV2 despite stale overlay flag (#9214)
## Summary

Fix inline queue progress being hidden in QPOV2 mode when a stale
`Comfy.Queue.History.Expanded` setting remains true from legacy queue
overlay usage.

## Changes

- Update actionbar inline progress hide condition to respect
queue-overlay expansion only when QPOV2 is disabled
- Update top menu inline progress summary hide condition with the same
gate
- Keep legacy behavior unchanged for non-QPOV2 queue overlay mode

## Testing

- `pnpm exec eslint src/components/actionbar/ComfyActionbar.vue
src/components/TopMenuSection.vue` 
- `pnpm typecheck` 

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9214-fix-show-inline-progress-in-QPOV2-despite-stale-overlay-flag-3126d73d36508170ac27fbb26826dca9)
by [Unito](https://www.unito.io)
2026-02-25 20:42:17 -08:00
Benjamin Lu
d99d807c45 fix: open job history from top menu active jobs button (#9210)
## Summary

Make the top menu `N active` queue button open the Job History sidebar
tab when QPO V2 is enabled, so behavior matches the button label and
accessibility text.

## Changes

- Update `TopMenuSection.vue` so QPO V2 mode toggles `job-history`
instead of `assets`
- Update `aria-pressed` logic to track `job-history`
- Update `TopMenuSection` unit tests to assert `job-history`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9210-fix-open-job-history-from-top-menu-active-jobs-button-3126d73d365081758987fa3806b4b0e7)
by [Unito](https://www.unito.io)
2026-02-25 20:40:11 -08:00
jaeone94
80fe51bb8c feat: show missing node packs in Errors Tab with install support (#9213)
## Summary

Surfaces missing node pack information in the Errors Tab, grouped by
registry pack, with one-click install support via ComfyUI Manager.

## Changes

- **What**: Errors Tab now groups missing nodes by their registry pack
and shows a `MissingPackGroupRow` with pack name, node/pack counts, and
an Install button that triggers Manager installation. A
`MissingNodeCard` shows individual unresolvable nodes that have no
associated pack. `useErrorGroups` was extended to resolve missing node
types to their registry packs using the `/api/workflow/missing_nodes`
endpoint. `executionErrorStore` was refactored to track missing node
types separately from execution errors and expose them reactively.
- **Breaking**: None

## Review Focus

- `useErrorGroups.ts` — the new `resolveMissingNodePacks` logic fetches
pack metadata and maps node types to pack IDs; edge cases around partial
resolution (some nodes have a pack, some don't) produce both
`MissingPackGroupRow` and `MissingNodeCard` entries
- `executionErrorStore.ts` — the store now separates `missingNodeTypes`
state from `errors`; the deferred-warnings path in `app.ts` now calls
`setMissingNodeTypes` so the Errors Tab is populated even when a
workflow loads without executing

## Screenshots (if applicable)


https://github.com/user-attachments/assets/97f8d009-0cac-4739-8740-fd3333b5a85b


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9213-feat-show-missing-node-packs-in-Errors-Tab-with-install-support-3126d73d36508197bc4bf8ebfd2125c8)
by [Unito](https://www.unito.io)
2026-02-25 20:25:47 -08:00
Dante
c24c4ab607 feat: show loading spinner and uploading filename during image upload (#9189)
## Summary
- Show a canvas-based loading spinner on image upload nodes (LoadImage)
during file upload via drag-drop, paste, or file picker
- Display the uploading file's name immediately in the filename dropdown
instead of showing the previous file's name
- Show the uploading audio file's name immediately in the audio widget
during upload

## Changes
- **`useNodeImageUpload.ts`**: Add `isUploading` flag and
`onUploadStart` callback to the upload lifecycle; clear `node.imgs`
during upload to prevent stale previews
- **`useImagePreviewWidget.ts`**: Add `renderUploadSpinner` that draws
an animated arc spinner on the canvas when `node.isUploading` is true;
guard against empty `imgs` array
- **`useImageUploadWidget.ts`**: Set `fileComboWidget.value` to the new
filename on upload start; clear `node.imgs` on combo widget change
- **`uploadAudio.ts`**: Set `audioWidget.value` to the new filename on
upload start
- **`litegraph-augmentation.d.ts`**: Add `isUploading` property to
`LGraphNode`



https://github.com/user-attachments/assets/818ce529-cb83-428a-8c98-dd900a128343



## Test plan
- [x] Upload an image via file picker on LoadImage node — spinner shows
during upload, filename updates immediately
- [x] Drag-and-drop an image onto LoadImage node — same behavior
- [x] Paste an image onto LoadImage node — same behavior
- [x] Change the dropdown selection on LoadImage — old preview clears,
new image loads
- [x] Upload an audio file — filename updates immediately in the widget

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9189-feat-show-loading-spinner-and-uploading-filename-during-image-upload-3126d73d365081e4af27cd7252f34298)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:22:42 -08:00
Christian Byrne
8e215b3174 feat: add performance testing infrastructure with CDP metrics (#9170)
## Summary

Add a permanent, non-failing performance regression detection system
using Chrome DevTools Protocol metrics, with automatic PR commenting.

## Changes

- **What**: Performance testing infrastructure — `PerformanceHelper`
fixture class using CDP `Performance.getMetrics` to collect
`RecalcStyleCount`, `LayoutCount`, `LayoutDuration`, `TaskDuration`,
`JSHeapUsedSize`. Adds `@perf` Playwright project (Chromium-only,
single-threaded, 60s timeout), 4 baseline perf tests, CI workflow with
sticky PR comment reporting, and `perf-report.js` script for generating
markdown comparison tables.

## Review Focus

- `PerformanceHelper` uses `page.context().newCDPSession(page)` — CDP is
Chromium-only, so perf metrics are not collected on Firefox. This is
intentional since CDP gives us browser-level style recalc/layout counts
that `performance.mark/measure` cannot capture.
- The CI workflow uses `continue-on-error: true` so perf tests never
block merging.
- Baseline comparison uses `dawidd6/action-download-artifact` to
download metrics from the target branch, following the same pattern as
`pr-size-report.yaml`.

## Stack

This is the foundation PR for the Firefox performance fix stack:
1. **→ This PR: perf testing infrastructure**
2. `perf/fix-cursor-cache` — cursor style caching (depends on this)
3. `perf/fix-subgraph-svg` — SVG pre-rasterization (depends on this)
4. `perf/fix-clippath-raf` — RAF batching for clip-path (depends on
this)

PRs 2-4 are independent of each other.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9170-feat-add-performance-testing-infrastructure-with-CDP-metrics-3116d73d3650817cb43def6f8e9917f8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-25 20:09:57 -08:00
Benjamin Lu
c957841862 fix: open previewable assets from list preview click/double-click (#9077)
## Summary
- emit `preview-click` from `AssetsListItem` when clicking the preview
tile
- wire assets sidebar rows and queue job-history rows so preview-tile
click and row double-click open the viewer/gallery
- gate job-history preview opening by `taskRef.previewOutput` (not
`iconImageUrl`) and use preview output URL/type so video previews are
supported
- add/extend tests for preview click and double-click behavior in assets
list and job history

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9077-fix-open-previewable-assets-from-list-preview-click-double-click-30f6d73d3650810a873cfa2dc085bf97)
by [Unito](https://www.unito.io)
2026-02-25 18:03:07 -08:00
Comfy Org PR Bot
d23c8026d0 1.41.6 (#9222)
Patch version increment to 1.41.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9222-1-41-6-3136d73d36508199bccbe6e08335bb19)
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-02-25 17:44:51 -08:00
AustinMroz
a309281ac5 Prevent serialization of progress text to prompt (#9221)
#8625 fixed a bug where `ProgressTextWidget`s would be serialized to
workflow data and, under rare circumstances, clobber over other widget
values on restore.

I was mistaken that the `serialize: false` being sent to options does
serve a purpose: preventing the widget value from being serialized to
the (api) prompt which is sent to the backend. This PR reverts the
removal so now both forms of disabling serialization apply.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9221-Prevent-serialization-of-progress-text-to-prompt-3126d73d365081c5b9ecc560f0a248d5)
by [Unito](https://www.unito.io)
2026-02-25 17:25:12 -08:00
Dante
e9bf113686 feat(settings): improve search to include nav items and show all results (#9195)
## Summary
- Settings search now matches sidebar navigation items (Keybinding,
About, Extension, etc.) and navigates to the corresponding panel
- Search results show all matching settings across all categories
instead of filtering to only the first matching category
- Search result group headers display parent category prefix (e.g.
"LiteGraph › Node") for clarity

## Test plan
- [x] Search "Keybinding" → sidebar highlights and navigates to
Keybinding panel
- [x] Search "badge" → shows all 4 badge settings (3 LiteGraph + 1
Comfy)
- [x] Search "canvas" → shows results from all categories
- [x] Clear search → returns to default category
- [x] Unit tests pass (`pnpm test:unit`)
<img width="1425" height="682" alt="스크린샷 2026-02-25 오후 3 01 05"
src="https://github.com/user-attachments/assets/956c4635-b140-4dff-8145-db312d295160"
/>



🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9195-feat-settings-improve-search-to-include-nav-items-and-show-all-results-3126d73d3650814dbf3ce1d59ad962cf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-25 17:14:37 -08:00
AustinMroz
1ab48b42a7 Add App I/O selection system (#8965)
Adds a system for selecting the inputs and outputs which should be
displayed when inside linear mode. Functions only in litegraph
currently. Vue support will require a separate, larger PR.
Inputs and outputs can be re-ordered by dragging and dropping on the
side panel.

![builder_00001](https://github.com/user-attachments/assets/6345adbd-519e-455d-b71e-0020aa03c6b7)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8965-Add-App-I-O-selection-system-30b6d73d365081569b36c1682a1fdbc5)
by [Unito](https://www.unito.io)
2026-02-25 08:53:00 -08:00
274 changed files with 18988 additions and 1979 deletions

View File

@@ -5,3 +5,10 @@ reviews:
high_level_summary: false
auto_review:
drafts: true
ignore_title_keywords:
- '[release]'
- '[backport'
ignore_usernames:
- comfy-pr-bot
- github-actions
- github-actions[bot]

110
.github/workflows/ci-perf-report.yaml vendored Normal file
View File

@@ -0,0 +1,110 @@
name: 'CI: Performance Report'
on:
push:
branches: [main, core/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
concurrency:
group: perf-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
perf-tests:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
- name: Run performance tests
id: perf
continue-on-error: true
run: pnpm exec playwright test --project=performance --workers=1
- name: Upload perf metrics
if: always()
uses: actions/upload-artifact@v6
with:
name: perf-metrics
path: test-results/perf-metrics.json
retention-days: 30
if-no-files-found: warn
report:
needs: perf-tests
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
- name: Download PR perf metrics
continue-on-error: true
uses: actions/download-artifact@v7
with:
name: perf-metrics
path: test-results/
- name: Download baseline perf metrics
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ github.event.pull_request.base.ref }}
workflow: ci-perf-report.yaml
event: push
name: perf-metrics
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Generate perf report
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
- name: Read perf report
id: perf-report
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: ./perf-report.md
- name: Create or update PR comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}
body: |
${{ steps.perf-report.outputs.content }}
<!-- COMFYUI_FRONTEND_PERF -->
body-include: '<!-- COMFYUI_FRONTEND_PERF -->'

View File

@@ -0,0 +1,45 @@
---
# Dispatches a frontend-asset-build event to the cloud repo on push to
# cloud/* branches and main. The cloud repo handles the actual build,
# GCS upload, and secret management (Sentry, Algolia, GCS creds).
#
# This is fire-and-forget — it does NOT wait for the cloud workflow to
# complete. Status is visible in the cloud repo's Actions tab.
name: Cloud Frontend Build Dispatch
on:
push:
branches:
- 'cloud/*'
- 'main'
workflow_dispatch:
permissions: {}
concurrency:
group: cloud-dispatch-${{ github.ref }}
cancel-in-progress: true
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
- name: Build client payload
id: payload
run: |
payload="$(jq -nc \
--arg ref "${GITHUB_SHA}" \
--arg branch "${GITHUB_REF_NAME}" \
'{ref: $ref, branch: $branch}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
- name: Dispatch to cloud repo
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
repository: Comfy-Org/cloud
event-type: frontend-asset-build
client-payload: ${{ steps.payload.outputs.json }}

View File

@@ -0,0 +1,760 @@
{
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
"revision": 0,
"last_node_id": 11,
"last_link_id": 18,
"nodes": [
{
"id": 2,
"type": "PreviewAny",
"pos": [1031, 434],
"size": [250, 178],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 5
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": [null, null, null]
},
{
"id": 5,
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
"pos": [788, 433.5],
"size": [225, 380],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [5]
}
],
"properties": {
"proxyWidgets": [
["3", "string_a"],
["4", "value"],
["6", "value"],
["6", "value_1"]
]
},
"widgets_values": []
},
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [548, 451],
"size": [225, 142],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [4]
}
],
"title": "Outer",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Outer\n"]
}
],
"links": [
[4, 1, 0, 5, 0, "STRING"],
[5, 5, 0, 2, 0, "STRING"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 0",
"inputNode": {
"id": -10,
"bounding": [351, 432.5, 120, 120]
},
"outputNode": {
"id": -20,
"bounding": [1352, 294.5, 120, 60]
},
"inputs": [
{
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
"name": "string_a",
"type": "STRING",
"linkIds": [1],
"localized_name": "string_a",
"pos": [451, 452.5]
},
{
"id": "5fb3dcf7-9bfd-4b3c-a1b9-750b4f3edf19",
"name": "value",
"type": "STRING",
"linkIds": [13],
"pos": [451, 472.5]
},
{
"id": "55d24b8a-7c82-4b02-8e3d-ff31ffb8aa13",
"name": "value_1",
"type": "STRING",
"linkIds": [16],
"pos": [451, 492.5]
},
{
"id": "c1fe7cc3-547e-4fb0-b763-61888558d4bd",
"name": "value_1_1",
"type": "STRING",
"linkIds": [18],
"pos": [451, 512.5]
}
],
"outputs": [
{
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
"name": "STRING",
"type": "STRING",
"linkIds": [9],
"localized_name": "STRING",
"pos": [1372, 314.5]
}
],
"widgets": [],
"nodes": [
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [504, 437],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 13
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [2]
}
],
"title": "Inner 1",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 1\n"]
},
{
"id": 3,
"type": "StringConcatenate",
"pos": [743, 325],
"size": [347, 231],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 1
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
"pos": [1115, 301],
"size": [210, 196],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 7
},
{
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 16
},
{
"name": "value_1",
"type": "STRING",
"widget": {
"name": "value_1"
},
"link": 18
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [9]
}
],
"properties": {
"proxyWidgets": [
["5", "string_a"],
["11", "value"],
["9", "value"],
["10", "string_a"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "STRING"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "STRING"
},
{
"id": 6,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 1,
"target_id": 4,
"target_slot": 0,
"type": "STRING"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 2,
"target_id": 6,
"target_slot": 1,
"type": "STRING"
},
{
"id": 18,
"origin_id": -10,
"origin_slot": 3,
"target_id": 6,
"target_slot": 2,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 1",
"inputNode": {
"id": -10,
"bounding": [180, 739, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [1246, 612, 120, 60]
},
"inputs": [
{
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
"name": "string_a",
"type": "STRING",
"linkIds": [4],
"localized_name": "string_a",
"pos": [280, 759]
},
{
"id": "d50f6a62-0185-43d4-a174-a8a94bd8f6e7",
"name": "value",
"type": "STRING",
"linkIds": [14],
"pos": [280, 779]
},
{
"id": "6b78450e-5986-49cd-b743-c933e5a34a69",
"name": "value_1",
"type": "STRING",
"linkIds": [17],
"pos": [280, 799]
}
],
"outputs": [
{
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
"name": "STRING",
"type": "STRING",
"linkIds": [12],
"localized_name": "STRING",
"pos": [1266, 632]
}
],
"widgets": [],
"nodes": [
{
"id": 11,
"type": "PrimitiveStringMultiline",
"pos": [334, 742],
"size": [210, 88],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 14
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"title": "Inner 2",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 2\n"]
},
{
"id": 10,
"type": "StringConcatenate",
"pos": [581, 637],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [11]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 9,
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"pos": [1004, 613],
"size": [210, 142],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 11
},
{
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 17
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [12]
}
],
"properties": {
"proxyWidgets": [
["7", "string_a"],
["8", "value"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 11,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": 10,
"origin_slot": 0,
"target_id": 9,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 12,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 1,
"target_id": 11,
"target_slot": 0,
"type": "STRING"
},
{
"id": 17,
"origin_id": -10,
"origin_slot": 2,
"target_id": 9,
"target_slot": 1,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 2",
"inputNode": {
"id": -10,
"bounding": [262, 1222, 120, 80]
},
"outputNode": {
"id": -20,
"bounding": [1123.089999999999, 1125.1999999999998, 120, 60]
},
"inputs": [
{
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
"name": "string_a",
"type": "STRING",
"linkIds": [9],
"localized_name": "string_a",
"pos": [362, 1242]
},
{
"id": "3a545207-7202-42a9-a82f-3b62e1b0f459",
"name": "value",
"type": "STRING",
"linkIds": [15],
"pos": [362, 1262]
}
],
"outputs": [
{
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
"name": "STRING",
"type": "STRING",
"linkIds": [10],
"localized_name": "STRING",
"pos": [1143.089999999999, 1145.1999999999998]
}
],
"widgets": [],
"nodes": [
{
"id": 8,
"type": "PrimitiveStringMultiline",
"pos": [412.96000000000004, 1228.2399999999996],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [8]
}
],
"title": "Inner 3",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 3\n"]
},
{
"id": 7,
"type": "StringConcatenate",
"pos": [686.08, 1132.38],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 9
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 8
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [10]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
}
],
"groups": [],
"links": [
{
"id": 8,
"origin_id": 8,
"origin_slot": 0,
"target_id": 7,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 8,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [-412, 11]
},
"frontendVersion": "1.41.7"
},
"version": 0.4
}

View File

@@ -24,6 +24,7 @@ import {
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { CanvasHelper } from './helpers/CanvasHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { ClipboardHelper } from './helpers/ClipboardHelper'
import { CommandHelper } from './helpers/CommandHelper'
import { DragDropHelper } from './helpers/DragDropHelper'
@@ -185,6 +186,7 @@ export class ComfyPage {
public readonly dragDrop: DragDropHelper
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly perf: PerformanceHelper
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -229,6 +231,7 @@ export class ComfyPage {
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.perf = new PerformanceHelper(page)
}
get visibleToasts() {
@@ -436,7 +439,13 @@ export const comfyPageFixture = base.extend<{
}
await comfyPage.setup()
const isPerf = testInfo.tags.includes('@perf')
if (isPerf) await comfyPage.perf.init()
await use(comfyPage)
if (isPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {
const comfyMouse = new ComfyMouse(comfyPage)

View File

@@ -0,0 +1,96 @@
import type { CDPSession, Page } from '@playwright/test'
interface PerfSnapshot {
RecalcStyleCount: number
RecalcStyleDuration: number
LayoutCount: number
LayoutDuration: number
TaskDuration: number
JSHeapUsedSize: number
Timestamp: number
}
export interface PerfMeasurement {
name: string
durationMs: number
styleRecalcs: number
styleRecalcDurationMs: number
layouts: number
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
}
export class PerformanceHelper {
private cdp: CDPSession | null = null
private snapshot: PerfSnapshot | null = null
constructor(private readonly page: Page) {}
async init(): Promise<void> {
this.cdp = await this.page.context().newCDPSession(this.page)
await this.cdp.send('Performance.enable')
}
async dispose(): Promise<void> {
this.snapshot = null
if (this.cdp) {
try {
await this.cdp.send('Performance.disable')
} finally {
await this.cdp.detach()
this.cdp = null
}
}
}
private async getSnapshot(): Promise<PerfSnapshot> {
if (!this.cdp) throw new Error('PerformanceHelper not initialized')
const { metrics } = (await this.cdp.send('Performance.getMetrics')) as {
metrics: { name: string; value: number }[]
}
function get(name: string): number {
return metrics.find((m) => m.name === name)?.value ?? 0
}
return {
RecalcStyleCount: get('RecalcStyleCount'),
RecalcStyleDuration: get('RecalcStyleDuration'),
LayoutCount: get('LayoutCount'),
LayoutDuration: get('LayoutDuration'),
TaskDuration: get('TaskDuration'),
JSHeapUsedSize: get('JSHeapUsedSize'),
Timestamp: get('Timestamp')
}
}
async startMeasuring(): Promise<void> {
if (this.snapshot) {
throw new Error(
'Measurement already in progress — call stopMeasuring() first'
)
}
this.snapshot = await this.getSnapshot()
}
async stopMeasuring(name: string): Promise<PerfMeasurement> {
if (!this.snapshot) throw new Error('Call startMeasuring() first')
const after = await this.getSnapshot()
const before = this.snapshot
this.snapshot = null
function delta(key: keyof PerfSnapshot): number {
return after[key] - before[key]
}
return {
name,
durationMs: delta('Timestamp') * 1000,
styleRecalcs: delta('RecalcStyleCount'),
styleRecalcDurationMs: delta('RecalcStyleDuration') * 1000,
layouts: delta('LayoutCount'),
layoutDurationMs: delta('LayoutDuration') * 1000,
taskDurationMs: delta('TaskDuration') * 1000,
heapDeltaBytes: delta('JSHeapUsedSize')
}
}
}

View File

@@ -1,11 +1,14 @@
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { writePerfReport } from './helpers/perfReporter'
import { restorePath } from './utils/backupUtils'
dotenv.config()
export default function globalTeardown(_config: FullConfig) {
writePerfReport()
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
restorePath([process.env.TEST_COMFYUI_DIR, 'user'])
restorePath([process.env.TEST_COMFYUI_DIR, 'models'])

View File

@@ -0,0 +1,49 @@
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import type { PerfMeasurement } from '../fixtures/helpers/PerformanceHelper'
export interface PerfReport {
timestamp: string
gitSha: string
branch: string
measurements: PerfMeasurement[]
}
const TEMP_DIR = join('test-results', 'perf-temp')
export function recordMeasurement(m: PerfMeasurement) {
mkdirSync(TEMP_DIR, { recursive: true })
const filename = `${m.name}-${Date.now()}.json`
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(m))
}
export function writePerfReport(
gitSha = process.env.GITHUB_SHA ?? 'local',
branch = process.env.GITHUB_HEAD_REF ?? 'local'
) {
if (!readdirSync('test-results', { withFileTypes: true }).length) return
let tempFiles: string[]
try {
tempFiles = readdirSync(TEMP_DIR).filter((f) => f.endsWith('.json'))
} catch {
return
}
if (tempFiles.length === 0) return
const measurements: PerfMeasurement[] = tempFiles.map((f) =>
JSON.parse(readFileSync(join(TEMP_DIR, f), 'utf-8'))
)
const report: PerfReport = {
timestamp: new Date().toISOString(),
gitSha,
branch,
measurements
}
writeFileSync(
join('test-results', 'perf-metrics.json'),
JSON.stringify(report, null, 2)
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -171,6 +171,7 @@ test.describe('Node Interaction', () => {
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.dragTextEncodeNode2()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -0,0 +1,70 @@
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { recordMeasurement } from '../helpers/perfReporter'
test.describe('Performance', { tag: ['@perf'] }, () => {
test('canvas idle style recalculations', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
// Let the canvas idle for 2 seconds — no user interaction.
// Measures baseline style recalcs from reactive state + render loop.
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('canvas-idle')
recordMeasurement(m)
console.log(
`Canvas idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('canvas mouse interaction style recalculations', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
// Sweep mouse across the canvas — crosses nodes, empty space, slots
for (let i = 0; i < 100; i++) {
await comfyPage.page.mouse.move(
box.x + (box.width * i) / 100,
box.y + (box.height * (i % 3)) / 3
)
}
const m = await comfyPage.perf.stopMeasuring('canvas-mouse-sweep')
recordMeasurement(m)
console.log(
`Mouse sweep: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('DOM widget clipping during node selection', async ({ comfyPage }) => {
// Load default workflow which has DOM widgets (text inputs, combos)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
// Select and deselect nodes rapidly to trigger clipping recalculation
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
for (let i = 0; i < 20; i++) {
// Click on canvas area (nodes occupy various positions)
await comfyPage.page.mouse.click(
box.x + box.width / 3 + (i % 5) * 30,
box.y + box.height / 3 + (i % 4) * 30
)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('dom-widget-clipping')
recordMeasurement(m)
console.log(`Clipping: ${m.layouts} forced layouts`)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -555,6 +555,74 @@ test.describe(
})
})
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
// slot connected externally from the Outer node, so it should be
// disabled. The remaining promoted textarea widgets (value, value_1)
// are unlinked and should be enabled.
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('string_a')
expect(promotedNames).toContain('value')
const disabledState = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('5')
return (node?.widgets ?? []).map((w) => ({
name: w.name,
disabled: !!w.computedDisabled
}))
})
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
expect(linkedWidget?.disabled).toBe(true)
const unlinkedWidgets = disabledState.filter(
(w) => w.name !== 'string_a'
)
for (const w of unlinkedWidgets) {
expect(w.disabled).toBe(false)
}
})
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// The promoted textareas that are NOT externally linked should be
// fully opaque and interactive.
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
const wrapper = textarea.locator('..')
const opacity = await wrapper.evaluate(
(el) => getComputedStyle(el).opacity
)
if (opacity === '1' && (await textarea.isEditable())) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
}
}
})
})
test.describe('Promotion Cleanup', () => {
test('Removing subgraph node clears promotion store entries', async ({
comfyPage

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -22,7 +22,9 @@ const extraFileExtensions = ['.vue']
const commonGlobals = {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
} as const
const settings = {

View File

@@ -41,7 +41,9 @@ const config: KnipConfig = {
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.github/workflows/ci-oss-assets-validation.yaml'
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.5",
"version": "1.41.10",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -16,7 +16,7 @@
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,clip-text-encode,get-video-components,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
@custom-variant touch (@media (hover: none));
@@ -634,6 +634,18 @@
}
}
@utility bg-subscription-gradient {
background: var(--color-subscription-button-gradient);
}
@utility highlight {
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {

View File

@@ -0,0 +1,3 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 9C12.7761 9 13 9.22386 13 9.5V20C13 20.2761 13.2239 20.5 13.5 20.5H28C28.2761 20.5 28.5 20.7239 28.5 21C28.5 21.2761 28.2761 21.5 28 21.5H13.5C12.6716 21.5 12 20.8284 12 20V9.5C12 9.22386 12.2239 9 12.5 9ZM14.5 7C14.7761 7 15 7.22386 15 7.5V18C15 18.2761 15.2239 18.5 15.5 18.5H30C30.2761 18.5 30.5 18.7239 30.5 19C30.5 19.2761 30.2761 19.5 30 19.5H15.5C14.6716 19.5 14 18.8284 14 18V7.5C14 7.22386 14.2239 7 14.5 7ZM16.5 5C16.7761 5 17 5.22386 17 5.5V16C17 16.2761 17.2239 16.5 17.5 16.5H32C32.2761 16.5 32.5 16.7239 32.5 17C32.5 17.2761 32.2761 17.5 32 17.5H17.5C16.6716 17.5 16 16.8284 16 16V5.5C16 5.22386 16.2239 5 16.5 5ZM33.7061 2.5C34.4126 2.5 34.9999 3.08968 35 3.7998V14.2002C34.9999 14.9103 34.4126 15.5 33.7061 15.5H19.2939C18.5874 15.5 18.0001 14.9103 18 14.2002V3.7998C18.0001 3.08968 18.5874 2.5 19.2939 2.5H33.7061ZM19.1084 12.2676V14.2002C19.1085 14.3124 19.1814 14.3856 19.293 14.3857H33.7061C33.8179 14.3857 33.8915 14.3125 33.8916 14.2002V12.6094L30.7207 10.0615L28.1055 11.873C27.9107 12.005 27.6299 11.9923 27.4473 11.8438L23.8896 8.95312L19.1084 12.2676ZM19.2939 3.61426C19.1821 3.61426 19.1085 3.68744 19.1084 3.7998V10.9092L23.5957 7.79883C23.6707 7.74519 23.7587 7.71107 23.8496 7.7002C23.9954 7.68428 24.1465 7.72944 24.2598 7.82227L27.8164 10.7178L30.4385 8.90723C30.6334 8.7753 30.9141 8.78784 31.0967 8.93652L33.8916 11.1826V3.7998C33.8915 3.68747 33.8179 3.61426 33.7061 3.61426H19.2939ZM27.7939 5.09961C28.7054 5.09987 29.4561 5.8554 29.4561 6.77148C29.456 7.68754 28.7054 8.44213 27.7939 8.44238C26.8823 8.44238 26.1309 7.6877 26.1309 6.77148C26.1309 5.85524 26.8823 5.09961 27.7939 5.09961ZM27.7939 6.21387C27.4814 6.21387 27.2393 6.45737 27.2393 6.77148C27.2393 7.08557 27.4814 7.32812 27.7939 7.32812C28.1062 7.32788 28.3476 7.08542 28.3477 6.77148C28.3477 6.45752 28.1063 6.21411 27.7939 6.21387Z" fill="#8A8A8A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,5 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.12 4.65979C26.0073 3.75304 27.4985 3.73975 28.3787 4.65979L31.3397 7.62073H31.3387C32.2523 8.49397 32.253 10.0028 31.3182 10.8785L31.3192 10.8795L23.62 18.5797L22.5096 19.6891V7.27112L22.7 7.08069L25.12 4.65979Z" stroke="#8A8A8A" stroke-width="1.3"/>
<path d="M32.3396 13.8499C33.6177 13.8499 34.65 14.8804 34.6501 16.1594V20.3401C34.6501 21.6199 33.618 22.6506 32.3396 22.6506H20.3503L21.4597 21.5403L29.1501 13.8499H32.3396Z" stroke="#8A8A8A" stroke-width="1.3"/>
<path d="M17.7604 17.2496C17.1991 17.2496 16.7498 17.6986 16.7497 18.2594C16.7497 18.8208 17.1995 19.2701 17.7604 19.2701C18.3065 19.2699 18.7702 18.8157 18.7702 18.2594C18.7701 17.6982 18.3211 17.2499 17.7604 17.2496ZM22.1706 18.2399C22.1706 20.6987 20.2192 22.6498 17.7604 22.65C15.2992 22.65 13.3493 20.677 13.3493 18.2399V3.65979C13.3494 2.38005 14.3815 1.34933 15.6598 1.34924H19.8405C21.1222 1.34934 22.1421 2.38132 22.1706 3.64514V18.2399Z" stroke="#8A8A8A" stroke-width="1.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 652 B

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -0,0 +1,3 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.8593 13C35.4827 13 36.0007 13.4988 36.0009 14.0996V22.9004C36.0007 23.5012 35.4827 24 34.8593 24H22.1425C21.5191 24 21.0011 23.5012 21.0009 22.9004V14.0996C21.0011 13.4988 21.5191 13 22.1425 13H34.8593ZM21.9794 21.2646V22.9004C21.9796 22.9953 22.0439 23.0566 22.1425 23.0566H34.8593C34.9579 23.0566 35.0222 22.9953 35.0224 22.9004V21.5547L32.2255 19.3984L29.9179 20.9307C29.746 21.0424 29.498 21.032 29.3369 20.9062L26.1982 18.4609L21.9794 21.2646ZM16.5009 10.5C16.777 10.5001 17.0009 10.7239 17.0009 11V17.5C17.001 18.3283 17.6727 18.9998 18.5009 19H18.7089L18.0615 18.3535C17.8665 18.1583 17.8665 17.8417 18.0615 17.6465C18.2567 17.4512 18.5742 17.4512 18.7695 17.6465L20.1835 19.0605C20.3785 19.2557 20.3784 19.5723 20.1835 19.7676L18.7695 21.1816C18.5742 21.3769 18.2567 21.3768 18.0615 21.1816C17.8666 20.9864 17.8664 20.6697 18.0615 20.4746L18.5361 20H18.5009C17.1204 19.9998 16.001 18.8806 16.0009 17.5V11C16.001 10.724 16.2249 10.5002 16.5009 10.5ZM22.1425 13.9424C22.0439 13.9424 21.9796 14.0047 21.9794 14.0996V20.1152L25.9384 17.4834C26.0045 17.4381 26.082 17.4096 26.162 17.4004C26.2907 17.3869 26.4244 17.4244 26.5244 17.5029L29.663 19.9531L31.9755 18.4219C32.1475 18.3102 32.3954 18.3204 32.5566 18.4463L35.0224 20.3467V14.0996C35.0222 14.0047 34.9579 13.9424 34.8593 13.9424H22.1425ZM29.6425 15.2002C30.4468 15.2003 31.1093 15.839 31.1093 16.6143C31.1093 17.3895 30.4469 18.0283 29.6425 18.0283C28.8381 18.0283 28.1747 17.3895 28.1747 16.6143C28.1748 15.839 28.8381 15.2002 29.6425 15.2002ZM29.6425 16.1426C29.3668 16.1426 29.1533 16.3485 29.1533 16.6143C29.1533 16.8801 29.3667 17.0859 29.6425 17.0859C29.9182 17.0859 30.1318 16.88 30.1318 16.6143C30.1318 16.3485 29.9182 16.1426 29.6425 16.1426ZM22.0917 0C23.6924 0.000102997 25.0009 1.29808 25.0009 2.91016V7.08984C25.0009 8.70192 23.6924 9.9999 22.0917 10H14.9111C13.3103 10 12.0009 8.70198 12.0009 7.08984V2.91016C12.0009 1.29802 13.3103 0 14.9111 0H22.0917ZM14.9111 1.04199C13.8598 1.04199 13.0331 1.87561 13.0331 2.91016V7.08984C13.0331 8.12439 13.8598 8.95801 14.9111 8.95801H22.0917C23.1429 8.95791 23.9697 8.12432 23.9697 7.08984V2.91016C23.9697 1.87568 23.1429 1.04209 22.0917 1.04199H14.9111ZM17.0146 2.36523C17.1026 2.36806 17.189 2.39596 17.2636 2.44531L20.5556 4.53613C20.7284 4.64278 20.7919 4.83988 20.7919 5C20.7919 5.16007 20.7283 5.35719 20.5556 5.46387L17.2646 7.55469C17.1075 7.65858 16.9024 7.66034 16.7441 7.56055L16.7431 7.55957C16.5867 7.45933 16.4941 7.27149 16.499 7.08398V2.91016C16.4953 2.64423 16.6989 2.38047 16.9755 2.36621L17.0146 2.36523ZM17.5068 6.1416L19.3095 5L17.5068 3.85449V6.1416ZM20.4999 5.22559L20.5234 5.19434C20.5303 5.1833 20.5364 5.17121 20.5419 5.15918C20.5308 5.1833 20.5167 5.20593 20.4999 5.22559Z" fill="#8A8A8A"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -36,7 +36,18 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
timeout: 15000,
grepInvert: /@mobile/ // Run all tests except those tagged with @mobile
grepInvert: /@mobile|@perf/ // Run all tests except those tagged with @mobile or @perf
},
{
name: 'performance',
use: {
...devices['Desktop Chrome'],
trace: 'retain-on-failure'
},
timeout: 60_000,
grep: /@perf/,
fullyParallel: false
},
{

125
scripts/perf-report.ts Normal file
View File

@@ -0,0 +1,125 @@
import { existsSync, readFileSync } from 'node:fs'
interface PerfMeasurement {
name: string
durationMs: number
styleRecalcs: number
styleRecalcDurationMs: number
layouts: number
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
}
interface PerfReport {
timestamp: string
gitSha: string
branch: string
measurements: PerfMeasurement[]
}
const CURRENT_PATH = 'test-results/perf-metrics.json'
const BASELINE_PATH = 'temp/perf-baseline/perf-metrics.json'
function formatDelta(pct: number): string {
if (pct >= 20) return `+${pct.toFixed(0)}% 🔴`
if (pct >= 10) return `+${pct.toFixed(0)}% 🟠`
if (pct > -10) return `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}% ⚪`
return `${pct.toFixed(0)}% 🟢`
}
function formatBytes(bytes: number): string {
if (Math.abs(bytes) < 1024) return `${bytes} B`
if (Math.abs(bytes) < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function calcDelta(
baseline: number,
current: number
): { pct: number; isNew: boolean } {
if (baseline > 0) {
return { pct: ((current - baseline) / baseline) * 100, isNew: false }
}
return current > 0 ? { pct: Infinity, isNew: true } : { pct: 0, isNew: false }
}
function formatDeltaCell(delta: { pct: number; isNew: boolean }): string {
return delta.isNew ? 'new 🔴' : formatDelta(delta.pct)
}
function main() {
if (!existsSync(CURRENT_PATH)) {
process.stdout.write(
'## ⚡ Performance Report\n\nNo perf metrics found. Perf tests may not have run.\n'
)
process.exit(0)
}
const current: PerfReport = JSON.parse(readFileSync(CURRENT_PATH, 'utf-8'))
const baseline: PerfReport | null = existsSync(BASELINE_PATH)
? JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'))
: null
const lines: string[] = []
lines.push('## ⚡ Performance Report\n')
if (baseline) {
lines.push(
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
)
for (const m of current.measurements) {
const base = baseline.measurements.find((b) => b.name === m.name)
if (!base) {
lines.push(`| ${m.name}: style recalcs | — | ${m.styleRecalcs} | new |`)
lines.push(`| ${m.name}: layouts | — | ${m.layouts} | new |`)
lines.push(
`| ${m.name}: task duration | — | ${m.taskDurationMs.toFixed(0)}ms | new |`
)
continue
}
const recalcDelta = calcDelta(base.styleRecalcs, m.styleRecalcs)
lines.push(
`| ${m.name}: style recalcs | ${base.styleRecalcs} | ${m.styleRecalcs} | ${formatDeltaCell(recalcDelta)} |`
)
const layoutDelta = calcDelta(base.layouts, m.layouts)
lines.push(
`| ${m.name}: layouts | ${base.layouts} | ${m.layouts} | ${formatDeltaCell(layoutDelta)} |`
)
const taskDelta = calcDelta(base.taskDurationMs, m.taskDurationMs)
lines.push(
`| ${m.name}: task duration | ${base.taskDurationMs.toFixed(0)}ms | ${m.taskDurationMs.toFixed(0)}ms | ${formatDeltaCell(taskDelta)} |`
)
}
} else {
lines.push(
'No baseline found — showing absolute values.\n',
'| Metric | Value |',
'|--------|-------|'
)
for (const m of current.measurements) {
lines.push(`| ${m.name}: style recalcs | ${m.styleRecalcs} |`)
lines.push(`| ${m.name}: layouts | ${m.layouts} |`)
lines.push(
`| ${m.name}: task duration | ${m.taskDurationMs.toFixed(0)}ms |`
)
lines.push(`| ${m.name}: heap delta | ${formatBytes(m.heapDeltaBytes)} |`)
}
}
lines.push('\n<details><summary>Raw data</summary>\n')
lines.push('```json')
lines.push(JSON.stringify(current, null, 2))
lines.push('```')
lines.push('\n</details>')
process.stdout.write(lines.join('\n') + '\n')
}
main()

View File

@@ -51,7 +51,6 @@ onMounted(() => {
// See: https://vite.dev/guide/build#load-error-handling
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ === 'cloud') {
captureException(event.payload, {
tags: { error_type: 'vite_preload_error' }

View File

@@ -18,14 +18,14 @@
<Splitter
:key="splitterRefreshKey"
class="bg-transparent pointer-events-none border-none flex-1 overflow-hidden"
:state-key="sidebarStateKey"
:state-key="isSelectMode ? 'builder-splitter' : sidebarStateKey"
state-storage="local"
@resizestart="onResizestart"
>
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
!focusMode && (sidebarLocation === 'left' || showOffsideSplitter)
"
:class="
sidebarLocation === 'left'
@@ -35,8 +35,10 @@
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="sidebarLocation === 'left' ? 10 : 15"
:size="20"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
@@ -54,7 +56,7 @@
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="80" class="flex flex-col">
<SplitterPanel :size="CENTER_PANEL_SIZE" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
@@ -85,7 +87,7 @@
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
!focusMode && (sidebarLocation === 'right' || showOffsideSplitter)
"
:class="
sidebarLocation === 'right'
@@ -95,8 +97,10 @@
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="sidebarLocation === 'right' ? 10 : 15"
:size="20"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
@@ -123,6 +127,13 @@ import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
SIDEBAR_MIN_SIZE,
SIDE_PANEL_SIZE
} from '@/constants/splitterConstants'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -144,11 +155,17 @@ const unifiedWidth = computed(() =>
const { focusMode } = storeToRefs(workspaceStore)
const { isSelectMode, isBuilderMode } = useAppMode()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || isSelectMode.value
)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
const sidebarPanelVisible = computed(
() => activeSidebarTab.value !== null && !isBuilderMode.value
)
const sidebarStateKey = computed(() => {
return unifiedWidth.value
@@ -169,7 +186,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
* to recalculate the width and panel order
*/
const splitterRefreshKey = computed(() => {
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}${isSelectMode.value ? '-builder' : ''}-${sidebarLocation.value}`
})
const firstPanelStyle = computed(() => {

View File

@@ -262,7 +262,7 @@ describe('TopMenuSection', () => {
)
})
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
it('opens the job history sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
@@ -273,10 +273,10 @@ describe('TopMenuSection', () => {
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
})
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
it('toggles the job history sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
@@ -287,7 +287,7 @@ describe('TopMenuSection', () => {
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe(null)

View File

@@ -56,43 +56,6 @@
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu
ref="queueContextMenu"
:model="queueContextMenuItems"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
@@ -127,13 +90,15 @@
<div
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
>
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
<QueueInlineProgressSummary
:hidden="shouldHideInlineProgressSummary"
/>
</div>
</Teleport>
<QueueInlineProgressSummary
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
class="pr-1"
:hidden="isQueueOverlayExpanded"
:hidden="shouldHideInlineProgressSummary"
/>
<QueueNotificationBannerHost
v-if="shouldShowQueueNotificationBanners"
@@ -146,14 +111,11 @@
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
@@ -167,12 +129,9 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isDesktop } from '@/platform/distribution/types'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
@@ -185,17 +144,11 @@ const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser()
const { t, n } = useI18n()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const queueUIStore = useQueueUIStore()
const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
@@ -208,14 +161,6 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
@@ -241,24 +186,12 @@ const inlineProgressSummaryTarget = computed(() => {
}
return progressTarget.value
})
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
const shouldHideInlineProgressSummary = computed(
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.manageExtensions'))
)
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
@@ -281,27 +214,6 @@ onMounted(() => {
}
})
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('assets')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
}
const openCustomNodeManager = async () => {
try {
await managerState.openManager({

View File

@@ -42,12 +42,44 @@
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
variant="secondary"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'job-history'
: queueOverlayExpanded
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
</div>
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="queueOverlayExpanded"
:hidden="shouldHideInlineProgress"
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
data-testid="queue-inline-progress"
/>
@@ -65,11 +97,14 @@ import {
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
@@ -77,6 +112,8 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
@@ -92,8 +129,13 @@ const emit = defineEmits<{
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const { t } = useI18n()
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const sidebarTabStore = useSidebarTabStore()
const { t, n } = useI18n()
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const { activeJobsCount } = storeToRefs(queueStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
@@ -287,6 +329,9 @@ const inlineProgressTarget = computed(() => {
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
const shouldHideInlineProgress = computed(
() => !isQueuePanelV2Enabled.value && queueOverlayExpanded
)
watch(
panelElement,
(target) => {
@@ -315,11 +360,52 @@ watch(isDragging, (dragging) => {
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('job-history')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
}
const actionbarClass = computed(() =>
cn(

View File

@@ -8,12 +8,14 @@ import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemp
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const appModeStore = useAppModeStore()
const { enableAppBuilder } = useAppMode()
const { enterBuilder } = useAppModeStore()
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
const isAssetsActive = computed(
@@ -23,10 +25,6 @@ const isWorkflowsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'workflows'
)
function enterBuilderMode() {
appModeStore.setMode('builder:select')
}
function openAssets() {
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
}
@@ -43,7 +41,7 @@ function openTemplates() {
<template>
<div class="flex flex-col gap-2 pointer-events-auto">
<WorkflowActionsDropdown source="app_mode_toolbar">
<template #button>
<template #button="{ hasUnseenItems }">
<Button
v-tooltip.right="{
value: t('sideToolbar.labels.menu'),
@@ -52,16 +50,21 @@ function openTemplates() {
variant="secondary"
size="unset"
:aria-label="t('sideToolbar.labels.menu')"
class="h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
class="relative h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i class="icon-[lucide--panels-top-left] size-4" />
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</template>
</WorkflowActionsDropdown>
<Button
v-if="appModeStore.enableAppBuilder"
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
@@ -70,7 +73,7 @@ function openTemplates() {
size="unset"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilderMode"
@click="enterBuilder"
>
<i class="icon-[lucide--hammer] size-4" />
</Button>

View File

@@ -1,7 +1,7 @@
<template>
<div
data-testid="subgraph-breadcrumb"
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center"
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center -mt-4 pt-4"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs

View File

@@ -0,0 +1,393 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed, provide, ref, toValue, watchEffect } from 'vue'
import type { MaybeRef } from 'vue'
import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
type BoundStyle = { top: string; left: string; width: string; height: string }
const appModeStore = useAppModeStore()
const canvasInteractions = useCanvasInteractions()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const { isSelectMode, isArrangeMode } = useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
workflowStore.activeWorkflow?.changeTracker?.reset()
function resolveNode(nodeId: NodeId) {
return (
app.rootGraph.getNodeById(nodeId) ??
[...app.rootGraph.subgraphs.values()]
.flatMap((sg) => sg.nodes)
.find((n) => n.id == nodeId)
)
}
// Prune stale entries whose node/widget no longer exists, so the
// DraggableList model always matches the rendered items.
watchEffect(() => {
const valid = appModeStore.selectedInputs.filter(([nodeId, widgetName]) =>
resolveNode(nodeId)?.widgets?.some((w) => w.name === widgetName)
)
if (valid.length < appModeStore.selectedInputs.length) {
appModeStore.selectedInputs = valid
}
})
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const node = resolveNode(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) return null
return { nodeId, widgetName, node, widget }
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = resolveNode(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) return { nodeId, widgetName }
const input = node.inputs.find((i) => i.widget?.name === widget.name)
const rename = input && (() => renameWidget(widget, input))
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
rename
}
})
)
const outputsWithState = computed<[NodeId, string][]>(() =>
appModeStore.selectedOutputs.map((nodeId) => [
nodeId,
app.rootGraph.getNodeById(nodeId)?.title ?? String(nodeId)
])
)
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
const newLabel = await useDialogService().prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
widget.label = newLabel || undefined
input.label = newLabel || undefined
widget.callback?.(widget.value)
useCanvasStore().canvas?.setDirty(true)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
const { graph } = canvas
if (!canvas || !graph) return
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
if (!e) return
canvas.adjustMouseEvent(e)
const node = graph.getNodeOnPos(e.canvasX, e.canvasY)
if (!node) return
const widget = node.getWidgetOnPos(e.canvasX, e.canvasY, false)
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
}
function getBounding(nodeId: NodeId, widgetName?: string) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const node = app.rootGraph.getNodeById(nodeId)
if (!node) return
const titleOffset =
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
if (!widgetName)
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) return
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
const marginX = margin ?? BaseWidget.margin
const height =
(widget.computedHeight !== undefined
? widget.computedHeight - 4
: LiteGraph.NODE_WIDGET_HEIGHT) - (margin ? 2 * margin - 4 : 0)
return {
width: `${node.size[0] - marginX * 2}px`,
height: `${height}px`,
left: `${node.pos[0] + marginX}px`,
top: `${node.pos[1] + widget.y + (margin ?? 0)}px`
}
}
function handleDown(e: MouseEvent) {
const [node] = getHovered(e) ?? []
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
}
function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? []
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!node.constructor.nodeData?.output_node)
return canvasInteractions.forwardEventToCanvas(e)
const index = appModeStore.selectedOutputs.findIndex((id) => id == node.id)
if (index === -1) appModeStore.selectedOutputs.push(node.id)
else appModeStore.selectedOutputs.splice(index, 1)
return
}
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
else appModeStore.selectedInputs.splice(index, 1)
}
function nodeToDisplayTuple(
n: LGraphNode
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
return [
n.id,
getBounding(n.id),
appModeStore.selectedOutputs.some((id) => n.id === id)
]
}
const renderedOutputs = computed(() => {
void appModeStore.selectedOutputs.length
return canvas
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
.map(nodeToDisplayTuple)
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
`${nodeId}: ${widgetName}`,
getBounding(nodeId, widgetName)
])
)
</script>
<template>
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
</div>
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'p-2 my-2 pointer-events-auto')"
:aria-label="`${widget.label ?? widgetName} ${node.title}`"
>
<div class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-else
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-border-subtle border-b"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'bg-primary-background/30 p-2 my-2 rounded-lg')"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="!isArrangeMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedOutputs"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'bg-warning-background/40 p-2 my-2 rounded-lg',
index === 0 && 'ring-warning-background ring-2'
)
"
:title
:sub-title="String(key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
/>
</DraggableList>
</PropertiesAccordionItem>
<Teleport
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
to="body"
>
<div
:class="
cn(
'absolute w-full h-full pointer-events-auto',
hoveringSelectable ? 'cursor-pointer' : 'cursor-grab'
)
"
@pointerdown="handleDown"
@pointermove="hoveringSelectable = !!getHovered($event)"
@click="handleClick"
@wheel="canvasInteractions.forwardEventToCanvas"
>
<TransformPane :canvas="canvasStore.getCanvas()">
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k == key)
"
@pointerdown.stop
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
/>
</div>
</div>
</TransformPane>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,43 @@
<template>
<div
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<Button size="lg" @click="onExitBuilder">
{{ t('builderMenu.exitAppBuilder') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
const { isBuilderMode } = useAppMode()
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
if (
e.key === 'Escape' &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
dialogStore.dialogStack.length === 0 &&
isBuilderMode.value
) {
e.preventDefault()
e.stopPropagation()
onExitBuilder()
}
})
function onExitBuilder() {
void appModeStore.exitBuilder()
}
</script>

View File

@@ -0,0 +1,73 @@
<template>
<Popover :show-arrow="false" class="min-w-56 p-3">
<template #button>
<button
:class="
cn(
'absolute left-4 top-[calc(var(--workflow-tabs-height)+16px)] z-1000 inline-flex h-10 cursor-pointer items-center gap-2.5 rounded-lg py-2 pr-2 pl-3 shadow-interface transition-colors border-none',
'bg-secondary-background hover:bg-secondary-background-hover',
'data-[state=open]:bg-secondary-background-hover'
)
"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
>
<i class="icon-[lucide--hammer] size-4" />
<span class="text-sm font-medium">
{{ t('linearMode.appModeToolbar.appBuilder') }}
</span>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
</button>
</template>
<template #default="{ close }">
<button
:class="
cn(
'flex w-full items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none',
hasOutputs
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'opacity-50 pointer-events-none'
)
"
:disabled="!hasOutputs"
@click="onSave(close)"
>
<i class="icon-[lucide--save] size-4" />
{{ t('builderMenu.saveApp') }}
</button>
<div class="my-1 border-t border-border-default" />
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none hover:bg-secondary-background-hover"
@click="onExitBuilder(close)"
>
<i class="icon-[lucide--square-pen] size-4" />
{{ t('builderMenu.exitAppBuilder') }}
</button>
</template>
</Popover>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import { useBuilderSave } from './useBuilderSave'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { setSaving } = useBuilderSave()
function onSave(close: () => void) {
setSaving(true)
close()
}
function onExitBuilder(close: () => void) {
void appModeStore.exitBuilder()
close()
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<nav
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-[1000] -translate-x-1/2"
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-1000 -translate-x-1/2"
:aria-label="t('builderToolbar.label')"
>
<div
@@ -20,7 +20,7 @@
)
"
:aria-current="activeStep === step.id ? 'step' : undefined"
@click="appModeStore.setMode(step.id)"
@click="setMode(step.id)"
>
<StepBadge :step :index :model-value="activeStep" />
<StepLabel :step />
@@ -31,9 +31,9 @@
<!-- Save -->
<ConnectOutputPopover
v-if="!appModeStore.hasOutputs"
v-if="!hasOutputs"
:is-select-active="activeStep === 'builder:select'"
@switch="appModeStore.setMode('builder:select')"
@switch="setMode('builder:select')"
>
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
@@ -50,7 +50,7 @@
: 'hover:bg-secondary-background bg-transparent'
)
"
@click="appModeStore.setBuilderSaving(true)"
@click="setSaving(true)"
>
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
@@ -63,21 +63,24 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import type { AppMode } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import { useBuilderSave } from './useBuilderSave'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import StepBadge from './StepBadge.vue'
import StepLabel from './StepLabel.vue'
import type { BuilderToolbarStep } from './types'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { mode, setMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
const { saving, setSaving } = useBuilderSave()
const activeStep = computed(() =>
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
)
const activeStep = computed(() => (saving.value ? 'save' : mode.value))
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
const { rename, remove } = defineProps<{
title: string
subTitle?: string
rename?: () => void
remove?: () => void
}>()
const entries = computed(() => {
const items = []
if (rename)
items.push({
label: t('g.rename'),
command: rename,
icon: 'icon-[lucide--pencil]'
})
if (remove)
items.push({
label: t('g.delete'),
command: remove,
icon: 'icon-[lucide--trash-2]'
})
return items
})
</script>
<template>
<div class="p-2 my-2 rounded-lg flex items-center-safe">
<span class="mr-auto truncate shrink-1" v-text="title" />
<span
class="text-muted-foreground mr-2 text-end truncate shrink-3"
v-text="subTitle"
/>
<Popover :entries>
<template #button>
<Button variant="muted-textonly">
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>
</Popover>
</div>
</template>

View File

@@ -1,30 +1,36 @@
import { watch } from 'vue'
import { ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
import { whenever } from '@vueuse/core'
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
export function useBuilderSave() {
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const { toastErrorHandler } = useErrorHandling()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const dialogService = useDialogService()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
watch(
() => appModeStore.isBuilderSaving,
(saving) => {
if (saving) void onBuilderSave()
}
)
const saving = ref(false)
whenever(saving, onBuilderSave)
function setSaving(value: boolean) {
saving.value = value
}
async function onBuilderSave() {
const workflow = workflowStore.activeWorkflow
@@ -33,15 +39,14 @@ export function useBuilderSave() {
return
}
// TODO: Update this to show the save dialog if it is temp OR if the user has not saved app mode before.
// If they have saved app mode before, just save the workflow, but use the initial app mode state not current.
if (!workflow.isTemporary) {
if (!workflow.isTemporary && workflow.initialMode != null) {
// Re-save with the previously chosen mode — no dialog needed.
try {
workflow.changeTracker?.checkState()
appModeStore.flushSelections()
await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
} catch {
showSuccessDialog(workflow.filename, workflow.initialMode === 'app')
} catch (e) {
toastErrorHandler(e)
resetSaving()
}
return
@@ -75,16 +80,19 @@ export function useBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
appModeStore.flushSelections()
const mode = openAsApp ? 'app' : 'graph'
const saved = await workflowService.saveWorkflowAs(workflow, {
filename,
openAsApp
initialMode: mode
})
if (!saved) return
closeSaveDialog()
showSuccessDialog(filename, openAsApp)
} catch {
} catch (e) {
toastErrorHandler(e)
closeSaveDialog()
resetSaving()
}
@@ -98,7 +106,7 @@ export function useBuilderSave() {
workflowName,
savedAsApp,
onViewApp: () => {
appModeStore.setMode('app')
setMode('app')
closeSuccessDialog()
},
onClose: closeSuccessDialog
@@ -119,6 +127,8 @@ export function useBuilderSave() {
}
function resetSaving() {
appModeStore.setBuilderSaving(false)
saving.value = false
}
return { saving, setSaving }
}

View File

@@ -1,7 +1,11 @@
<template>
<span
class="flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs"
:class="textColorClass"
:class="
cn(
'flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs',
textColorClass
)
"
:style="customStyle"
>
<i v-if="icon" :class="cn(icon, 'size-2.5', iconClass)" />

View File

@@ -0,0 +1,59 @@
<script setup lang="ts" generic="T">
import { onBeforeUnmount, ref, useTemplateRef, watchPostEffect } from 'vue'
import { DraggableList } from '@/scripts/ui/draggableList'
const modelValue = defineModel<T[]>({ required: true })
const draggableList = ref<DraggableList>()
const draggableItems = useTemplateRef('draggableItems')
watchPostEffect(() => {
void modelValue.value.length
draggableList.value?.dispose()
if (!draggableItems.value?.children?.length) return
draggableList.value = new DraggableList(
draggableItems.value,
'.draggable-item'
)
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem
}
}
const newPosition = reorderedItems.indexOf(this.draggableItem)
const itemList = modelValue.value
const [item] = itemList.splice(oldPosition, 1)
itemList.splice(newPosition, 0, item)
modelValue.value = [...itemList]
}
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
</script>
<template>
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
<slot
drag-class="draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing"
/>
</div>
</template>

View File

@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
import type { FlattenedItem } from 'reka-ui'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -9,12 +10,25 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue('left')
})
}))
vi.mock('@/stores/nodeBookmarkStore', () => ({
useNodeBookmarkStore: () => ({
isBookmarked: vi.fn().mockReturnValue(false),
toggleBookmark: vi.fn()
})
}))
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
default: { template: '<div />' }
}))
@@ -78,6 +92,7 @@ describe('TreeExplorerV2Node', () => {
return {
wrapper: mount(TreeExplorerV2Node, {
global: {
plugins: [i18n],
stubs: {
TreeItem: treeItemStub.stub,
Teleport: { template: '<div />' }

View File

@@ -24,6 +24,25 @@
{{ item.value.label }}
</slot>
</span>
<button
:class="
cn(
'flex size-6 shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted-foreground hover:text-foreground',
'opacity-0 group-hover/tree-node:opacity-100'
)
"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
</button>
</div>
<!-- Folder -->
@@ -33,6 +52,15 @@
:style="rowStyle"
@click.stop="handleClick($event, handleToggle, handleSelect)"
>
<i
v-if="item.hasChildren"
:class="
cn(
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90'
)
"
/>
<i
:class="cn(item.value.icon, 'size-4 shrink-0 text-muted-foreground')"
/>
@@ -41,15 +69,6 @@
{{ item.value.label }}
</slot>
</span>
<i
v-if="item.hasChildren"
:class="
cn(
'icon-[lucide--chevron-down] mr-4 size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90'
)
"
/>
</div>
</TreeItem>
@@ -73,6 +92,7 @@ import { computed, inject } from 'vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -93,9 +113,21 @@ const emit = defineEmits<{
}>()
const contextMenuNode = inject(InjectKeyContextMenuNode)
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeDef = computed(() => item.value.data)
const isBookmarked = computed(() => {
if (!nodeDef.value) return false
return nodeBookmarkStore.isBookmarked(nodeDef.value)
})
function toggleBookmark() {
if (nodeDef.value) {
nodeBookmarkStore.toggleBookmark(nodeDef.value)
}
}
const {
previewRef,
showPreview,

View File

@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -27,8 +28,13 @@ const { menuItems } = useWorkflowActionsMenu(
{ isRoot: true }
)
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
() => menuItems.value
)
function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source
})
@@ -39,7 +45,7 @@ function handleOpen(open: boolean) {
<template>
<DropdownMenuRoot @update:open="handleOpen">
<DropdownMenuTrigger as-child>
<slot name="button">
<slot name="button" :has-unseen-items="hasUnseenItems">
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
@@ -49,7 +55,7 @@ function handleOpen(open: boolean) {
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
class="relative h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i
class="size-4"
@@ -60,6 +66,11 @@ function handleOpen(open: boolean) {
"
/>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</slot>
</DropdownMenuTrigger>

View File

@@ -17,7 +17,7 @@ function createWrapper(items: WorkflowMenuItem[]) {
describe('WorkflowActionsList', () => {
it('renders action items with label and icon', () => {
const items: WorkflowMenuItem[] = [
{ label: 'Save', icon: 'pi pi-save', command: vi.fn() }
{ id: 'save', label: 'Save', icon: 'pi pi-save', command: vi.fn() }
]
const wrapper = createWrapper(items)
@@ -28,9 +28,9 @@ describe('WorkflowActionsList', () => {
it('renders separator items', () => {
const items: WorkflowMenuItem[] = [
{ label: 'Before', icon: 'pi pi-a', command: vi.fn() },
{ id: 'before', label: 'Before', icon: 'pi pi-a', command: vi.fn() },
{ separator: true },
{ label: 'After', icon: 'pi pi-b', command: vi.fn() }
{ id: 'after', label: 'After', icon: 'pi pi-b', command: vi.fn() }
]
const wrapper = createWrapper(items)
@@ -44,7 +44,7 @@ describe('WorkflowActionsList', () => {
it('dispatches command on select', async () => {
const command = vi.fn()
const items: WorkflowMenuItem[] = [
{ label: 'Action', icon: 'pi pi-play', command }
{ id: 'action', label: 'Action', icon: 'pi pi-play', command }
]
const wrapper = createWrapper(items)
@@ -57,6 +57,7 @@ describe('WorkflowActionsList', () => {
it('renders badge when present', () => {
const items: WorkflowMenuItem[] = [
{
id: 'new-feature',
label: 'New Feature',
icon: 'pi pi-star',
command: vi.fn(),
@@ -71,7 +72,7 @@ describe('WorkflowActionsList', () => {
it('does not render badge when absent', () => {
const items: WorkflowMenuAction[] = [
{ label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
]
const wrapper = createWrapper(items)

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import CurveEditor from './CurveEditor.vue'

View File

@@ -77,7 +77,8 @@
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import { histogramToPath } from './curveUtils'

View File

@@ -3,7 +3,7 @@
</template>
<script setup lang="ts">
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import CurveEditor from './CurveEditor.vue'

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
import {
createMonotoneInterpolator,

View File

@@ -1,4 +1,4 @@
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import type { CurvePoint } from './types'
/**
* Monotone cubic Hermite interpolation.
@@ -95,15 +95,15 @@ export function histogramToPath(histogram: Uint32Array): string {
const max = sorted[Math.floor(255 * 0.995)]
if (max === 0) return ''
const step = 1 / 255
let d = 'M0,1'
const invMax = 1 / max
const parts: string[] = ['M0,1']
for (let i = 0; i < 256; i++) {
const x = i * step
const y = 1 - Math.min(1, histogram[i] / max)
d += ` L${x.toFixed(4)},${y.toFixed(4)}`
const x = i / 255
const y = 1 - Math.min(1, histogram[i] * invMax)
parts.push(`L${x},${y}`)
}
d += ' L1,1 Z'
return d
parts.push('L1,1 Z')
return parts.join(' ')
}
export function curvesToLUT(points: CurvePoint[]): Uint8Array {

View File

@@ -0,0 +1 @@
export type CurvePoint = [x: number, y: number]

View File

@@ -438,7 +438,6 @@ onMounted(() => {
const systemStatsStore = useSystemStatsStore()
const distributions = computed(() => {
// eslint-disable-next-line no-undef
switch (__DISTRIBUTION__) {
case 'cloud':
return [TemplateIncludeOnDistributionEnum.Cloud]

View File

@@ -234,6 +234,7 @@ import { isCloud } from '@/platform/distribution/types'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import { cn } from '@/utils/tailwindUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -245,6 +246,7 @@ const { missingNodeTypes } = defineProps<{
const { missingCoreNodes } = useMissingNodes()
const { replaceNodesInPlace } = useNodeReplacement()
const dialogStore = useDialogStore()
const executionErrorStore = useExecutionErrorStore()
interface ProcessedNode {
label: string
@@ -339,6 +341,14 @@ function handleReplaceSelected() {
replacedTypes.value = nextReplaced
selectedTypes.value = nextSelected
// replaceNodesInPlace() handles canvas rendering via onNodeAdded(),
// but the modal only updates its own local UI state above.
// Without this call the Errors Tab would still list the replaced nodes
// as missing because executionErrorStore is not aware of the replacement.
if (result.length > 0) {
executionErrorStore.removeMissingNodesByType(result)
}
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
const allReplaced = replaceableNodes.value.every((n) =>
nextReplaced.has(n.label)

View File

@@ -18,10 +18,11 @@
>
<WorkflowTabs />
<TopbarBadges />
<TopbarSubscribeButton />
</div>
</div>
</template>
<template v-if="showUI && !appModeStore.isBuilderMode" #side-toolbar>
<template v-if="showUI && !isBuilderMode" #side-toolbar>
<SideToolbar />
</template>
<template v-if="showUI" #side-bar-panel>
@@ -31,26 +32,24 @@
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
</div>
</template>
<template v-if="showUI && !appModeStore.isBuilderMode" #topmenu>
<template v-if="showUI && !isBuilderMode" #topmenu>
<TopMenuSection />
</template>
<template v-if="showUI" #bottom-panel>
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<NodePropertiesPanel v-if="!appModeStore.isBuilderMode" />
<AppBuilder v-if="mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!isBuilderMode" />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
v-if="canvasMenuEnabled && !appModeStore.isBuilderMode"
v-if="canvasMenuEnabled && !isBuilderMode"
class="pointer-events-auto"
/>
<MiniMap
v-if="
comfyAppReady &&
minimapEnabled &&
betaMenuEnabled &&
!appModeStore.isBuilderMode
comfyAppReady && minimapEnabled && betaMenuEnabled && !isBuilderMode
"
class="pointer-events-auto"
/>
@@ -129,6 +128,7 @@ import { isMiddlePointerInput } from '@/base/pointerUtils'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
@@ -141,6 +141,7 @@ import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
@@ -182,7 +183,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -203,7 +204,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const appModeStore = useAppModeStore()
const { mode, isBuilderMode } = useAppMode()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()

View File

@@ -0,0 +1,116 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import type { DomWidgetState } from '@/stores/domWidgetStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import DomWidget from './DomWidget.vue'
const mockUpdatePosition = vi.fn()
const mockUpdateClipPath = vi.fn()
const mockCanvasElement = document.createElement('canvas')
const mockCanvasStore = {
canvas: {
graph: {
getNodeById: vi.fn(() => true)
},
ds: {
offset: [0, 0],
scale: 1
},
canvas: mockCanvasElement,
selected_nodes: {}
},
getCanvas: () => ({ canvas: mockCanvasElement }),
linearMode: false
}
vi.mock('@/composables/element/useAbsolutePosition', () => ({
useAbsolutePosition: () => ({
style: reactive<Record<string, string>>({}),
updatePosition: mockUpdatePosition
})
}))
vi.mock('@/composables/element/useDomClipping', () => ({
useDomClipping: () => ({
style: reactive<Record<string, string>>({}),
updateClipPath: mockUpdateClipPath
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => false)
})
}))
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
const domWidgetStore = useDomWidgetStore()
const node = createMockLGraphNode({
id: 1,
constructor: {
nodeData: {}
}
})
const widget = {
id: 'dom-widget-id',
name: 'test_widget',
type: 'custom',
value: '',
options: {},
node,
computedDisabled: false
} as unknown as BaseDOMWidget<object | string>
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: createMockLGraphNode({ id: 2 }),
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
})
const state = domWidgetStore.widgetStates.get(widget.id)
if (!state) throw new Error('Expected registered DomWidgetState')
state.zIndex = 2
state.size = [100, 40]
return reactive(state)
}
describe('DomWidget disabled style', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
useDomWidgetStore().clear()
vi.clearAllMocks()
})
it('uses disabled style when promoted override widget is computedDisabled', async () => {
const widgetState = createWidgetState(true)
const wrapper = mount(DomWidget, {
props: {
widgetState
}
})
widgetState.zIndex = 3
await wrapper.vm.$nextTick()
const root = wrapper.get('.dom-widget').element as HTMLElement
expect(root.style.pointerEvents).toBe('none')
expect(root.style.opacity).toBe('0.5')
})
})

View File

@@ -110,13 +110,17 @@ watch(
updateDomClipping()
}
const override = widgetState.positionOverride
const isDisabled = override
? (override.widget.computedDisabled ?? widget.computedDisabled)
: widget.computedDisabled
style.value = {
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents:
widgetState.readonly || widget.computedDisabled ? 'none' : 'auto',
opacity: widget.computedDisabled ? 0.5 : 1
pointerEvents: widgetState.readonly || isDisabled ? 'none' : 'auto',
opacity: isDisabled ? 0.5 : 1
}
},
{ deep: true }

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex w-50 flex-col overflow-hidden rounded-2xl bg-(--base-background) border border-(--border-default)"
class="flex w-50 flex-col overflow-hidden rounded-2xl bg-base-background border border-border-default"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div ref="previewWrapperRef" class="origin-top-left scale-50">
@@ -100,7 +100,7 @@ import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphN
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const SCALE_FACTOR = 0.5
const PREVIEW_CONTAINER_PADDING_PX = 24 // p-3 top + bottom (12px × 2)
const PREVIEW_CONTAINER_PADDING_PX = 24
const {
nodeDef,

View File

@@ -0,0 +1,359 @@
<template>
<div
class="widget-expands flex h-full w-full flex-col gap-1"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<div
class="flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-lg bg-node-component-surface"
>
<div class="relative max-h-full w-full" :style="canvasContainerStyle">
<img
v-if="inputImageUrl"
:src="inputImageUrl"
class="absolute inset-0 size-full"
draggable="false"
@load="handleInputImageLoad"
@dragstart.prevent
/>
<canvas
ref="canvasEl"
class="absolute inset-0 size-full cursor-none touch-none"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerenter="handlePointerEnter"
@pointerleave="handlePointerLeave"
/>
<div
v-show="cursorVisible"
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)]"
:style="cursorStyle"
/>
</div>
</div>
<div
v-if="isImageInputConnected"
class="text-center text-xs text-muted-foreground"
>
{{ canvasWidth }} x {{ canvasHeight }}
</div>
<div
ref="controlsEl"
:class="
cn(
'grid shrink-0 gap-x-1 gap-y-1',
compact ? 'grid-cols-1' : 'grid-cols-[auto_1fr]'
)
"
>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.tool') }}
</div>
<div
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
tool === PAINTER_TOOLS.BRUSH
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="tool = PAINTER_TOOLS.BRUSH"
>
{{ $t('painter.brush') }}
</Button>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
tool === PAINTER_TOOLS.ERASER
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="tool = PAINTER_TOOLS.ERASER"
>
{{ $t('painter.eraser') }}
</Button>
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.size') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[brushSize]"
:min="1"
:max="200"
:step="1"
class="flex-1"
@update:model-value="(v) => v?.length && (brushSize = v[0])"
/>
<span class="w-8 text-center text-xs text-node-text-muted">{{
brushSize
}}</span>
</div>
<template v-if="tool === PAINTER_TOOLS.BRUSH">
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.color') }}
</div>
<div
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
>
<input
type="color"
:value="brushColorDisplay"
class="h-4 w-8 cursor-pointer appearance-none overflow-hidden rounded-full border-none bg-transparent [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-webkit-color-swatch]:rounded-full [&::-moz-color-swatch]:border-none [&::-moz-color-swatch]:rounded-full"
@input="
(e) => (brushColorDisplay = (e.target as HTMLInputElement).value)
"
/>
<span class="min-w-[4ch] truncate text-xs">{{
brushColorDisplay
}}</span>
<span class="ml-auto flex items-center text-xs text-node-text-muted">
<input
type="number"
:value="brushOpacityPercent"
min="0"
max="100"
step="1"
class="w-7 appearance-none border-0 bg-transparent text-right text-xs text-node-text-muted outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
@click.prevent
@change="
(e) => {
const val = Math.min(
100,
Math.max(0, Number((e.target as HTMLInputElement).value))
)
brushOpacityPercent = val
;(e.target as HTMLInputElement).value = String(val)
}
"
/>%</span
>
</div>
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.hardness') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[brushHardnessPercent]"
:min="0"
:max="100"
:step="1"
class="flex-1"
@update:model-value="
(v) => v?.length && (brushHardnessPercent = v[0])
"
/>
<span class="w-8 text-center text-xs text-node-text-muted"
>{{ brushHardnessPercent }}%</span
>
</div>
</template>
<template v-if="!isImageInputConnected">
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.width') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[canvasWidth]"
:min="64"
:max="4096"
:step="64"
class="flex-1"
@update:model-value="(v) => v?.length && (canvasWidth = v[0])"
/>
<span class="w-10 text-center text-xs text-node-text-muted">{{
canvasWidth
}}</span>
</div>
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.height') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[canvasHeight]"
:min="64"
:max="4096"
:step="64"
class="flex-1"
@update:model-value="(v) => v?.length && (canvasHeight = v[0])"
/>
<span class="w-10 text-center text-xs text-node-text-muted">{{
canvasHeight
}}</span>
</div>
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.background') }}
</div>
<div
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
>
<input
type="color"
:value="backgroundColorDisplay"
class="h-4 w-8 cursor-pointer appearance-none overflow-hidden rounded-full border-none bg-transparent [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-webkit-color-swatch]:rounded-full [&::-moz-color-swatch]:border-none [&::-moz-color-swatch]:rounded-full"
@input="
(e) =>
(backgroundColorDisplay = (e.target as HTMLInputElement).value)
"
/>
<span class="min-w-[4ch] truncate text-xs">{{
backgroundColorDisplay
}}</span>
</div>
</template>
<Button
variant="secondary"
size="md"
:class="
cn(
'gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground',
!compact && 'col-span-2'
)
"
@click="handleClear"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('painter.clear') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { computed, useTemplateRef } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { PAINTER_TOOLS, usePainter } from '@/composables/painter/usePainter'
import { toHexFromFormat } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
const { nodeId } = defineProps<{
nodeId: string
}>()
const modelValue = defineModel<string>({ default: '' })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const controlsEl = useTemplateRef<HTMLDivElement>('controlsEl')
const { width: controlsWidth } = useElementSize(controlsEl)
const compact = computed(
() => controlsWidth.value > 0 && controlsWidth.value < 350
)
const {
tool,
brushSize,
brushColor,
brushOpacity,
brushHardness,
backgroundColor,
canvasWidth,
canvasHeight,
cursorX,
cursorY,
cursorVisible,
displayBrushSize,
inputImageUrl,
isImageInputConnected,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerEnter,
handlePointerLeave,
handleInputImageLoad,
handleClear
} = usePainter(nodeId, { canvasEl, modelValue })
const canvasContainerStyle = computed(() => ({
aspectRatio: `${canvasWidth.value} / ${canvasHeight.value}`,
backgroundColor: isImageInputConnected.value
? undefined
: backgroundColor.value
}))
const cursorStyle = computed(() => {
const size = displayBrushSize.value
const x = cursorX.value - size / 2
const y = cursorY.value - size / 2
return {
width: `${size}px`,
height: `${size}px`,
transform: `translate(${x}px, ${y}px)`
}
})
const brushOpacityPercent = computed({
get: () => Math.round(brushOpacity.value * 100),
set: (val: number) => {
brushOpacity.value = val / 100
}
})
const brushHardnessPercent = computed({
get: () => Math.round(brushHardness.value * 100),
set: (val: number) => {
brushHardness.value = val / 100
}
})
const brushColorDisplay = computed({
get: () => toHexFromFormat(brushColor.value, 'hex'),
set: (val: unknown) => {
brushColor.value = toHexFromFormat(val, 'hex')
}
})
const backgroundColorDisplay = computed({
get: () => toHexFromFormat(backgroundColor.value, 'hex'),
set: (val: unknown) => {
backgroundColor.value = toHexFromFormat(val, 'hex')
}
})
</script>

View File

@@ -20,7 +20,7 @@
class="w-full justify-between text-sm font-light"
variant="textonly"
size="sm"
@click="onToggleDockedJobHistory"
@click="onToggleDockedJobHistory(close)"
>
<span class="flex items-center gap-2">
<i
@@ -79,6 +79,7 @@ import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const emit = defineEmits<{
(e: 'clearHistory'): void
@@ -86,6 +87,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const isQueuePanelV2Enabled = computed(() =>
@@ -98,7 +100,22 @@ const onClearHistoryFromMenu = (close: () => void) => {
emit('clearHistory')
}
const onToggleDockedJobHistory = async () => {
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled.value)
const onToggleDockedJobHistory = async (close: () => void) => {
close()
try {
if (isQueuePanelV2Enabled.value) {
await settingStore.setMany({
'Comfy.Queue.QPOV2': false,
'Comfy.Queue.History.Expanded': true
})
return
}
sidebarTabStore.activeSidebarTabId = 'job-history'
await settingStore.set('Comfy.Queue.QPOV2', true)
} catch (error) {
console.warn('[JobHistory] Failed to toggle docked job history:', error)
}
}
</script>

View File

@@ -23,18 +23,27 @@ vi.mock('@/components/ui/Popover.vue', () => {
return { default: PopoverStub }
})
const mockGetSetting = vi.fn((key: string) =>
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const mockSetSetting = vi.fn()
const mockSetMany = vi.fn()
const mockSidebarTabStore = {
activeSidebarTabId: null as string | null
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting,
set: mockSetSetting
set: mockSetSetting,
setMany: mockSetMany
})
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
@@ -81,6 +90,11 @@ describe('QueueOverlayHeader', () => {
beforeEach(() => {
popoverCloseSpy.mockClear()
mockSetSetting.mockClear()
mockSetMany.mockClear()
mockSidebarTabStore.activeSidebarTabId = null
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
})
it('renders header title', () => {
@@ -125,7 +139,7 @@ describe('QueueOverlayHeader', () => {
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
it('toggles docked job history setting from the menu', async () => {
it('opens floating queue progress overlay when disabling from the menu', async () => {
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
@@ -133,7 +147,64 @@ describe('QueueOverlayHeader', () => {
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetMany).toHaveBeenCalledTimes(1)
expect(mockSetMany).toHaveBeenCalledWith({
'Comfy.Queue.QPOV2': false,
'Comfy.Queue.History.Expanded': true
})
expect(mockSetSetting).not.toHaveBeenCalled()
expect(mockSidebarTabStore.activeSidebarTabId).toBe(null)
})
it('opens docked job history sidebar when enabling from the menu', async () => {
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', false)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
expect(mockSetMany).not.toHaveBeenCalled()
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
})
it('keeps docked target open even when enabling persistence fails', async () => {
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
mockSetSetting.mockRejectedValueOnce(new Error('persistence failed'))
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
})
it('closes the menu when disabling persistence fails', async () => {
mockSetMany.mockRejectedValueOnce(new Error('persistence failed'))
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetMany).toHaveBeenCalledWith({
'Comfy.Queue.QPOV2': false,
'Comfy.Queue.History.Expanded': true
})
})
})

View File

@@ -0,0 +1,146 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import JobAssetsList from './JobAssetsList.vue'
vi.mock('vue-i18n', () => {
return {
createI18n: () => ({
global: {
t: (key: string) => key,
te: () => true,
d: (value: string) => value
}
}),
useI18n: () => ({
t: (key: string) => key
})
}
})
const createResultItem = (
filename: string,
mediaType: string = 'images'
): ResultItemImpl => {
const item = new ResultItemImpl({
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType
})
Object.defineProperty(item, 'url', {
get: () => `/api/view/${filename}`
})
return item
}
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
const job: ApiJobListItem = {
id: `task-${Math.random().toString(36).slice(2)}`,
status: 'completed',
create_time: Date.now(),
preview_output: null,
outputs_count: preview ? 1 : 0,
priority: 0
}
const flatOutputs = preview ? [preview] : []
return new TaskItemImpl(job, {}, flatOutputs)
}
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: createTaskRef(createResultItem('job-1.png')),
...overrides
})
const mountJobAssetsList = (jobs: JobListItem[]) => {
const displayedJobGroups: JobGroup[] = [
{
key: 'group-1',
label: 'Group 1',
items: jobs
}
]
return mount(JobAssetsList, {
props: { displayedJobGroups }
})
}
describe('JobAssetsList', () => {
it('emits viewItem on preview-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
listItem.vm.$emit('preview-click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on double-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
})
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
expect(listItem.props('previewUrl')).toBe('/api/view/job-1.webm')
expect(listItem.props('isVideoPreview')).toBe(true)
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
})
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.find('i').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
taskRef: createTaskRef(createResultItem('job-1.png'))
})
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toBeUndefined()
})
})

View File

@@ -12,7 +12,8 @@
v-for="job in group.items"
:key="job.id"
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
:preview-url="job.iconImageUrl"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
@@ -23,6 +24,8 @@
@mouseenter="hoveredJobId = job.id"
@mouseleave="onJobLeave(job.id)"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
@@ -78,7 +81,7 @@ import { isActiveJobState } from '@/utils/queueUtil'
defineProps<{ displayedJobGroups: JobGroup[] }>()
defineEmits<{
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
@@ -100,6 +103,28 @@ const isCancelable = (job: JobListItem) =>
const isFailedDeletable = (job: JobListItem) =>
job.showClear !== false && job.state === 'failed'
const getPreviewOutput = (job: JobListItem) => job.taskRef?.previewOutput
const getJobPreviewUrl = (job: JobListItem) => {
const preview = getPreviewOutput(job)
if (preview?.isImage || preview?.isVideo) {
return preview.url
}
return job.iconImageUrl
}
const isVideoPreviewJob = (job: JobListItem) =>
job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
const isPreviewableCompletedJob = (job: JobListItem) =>
job.state === 'completed' && !!getPreviewOutput(job)
const emitViewItem = (job: JobListItem) => {
if (isPreviewableCompletedJob(job)) {
emit('viewItem', job)
}
}
const getJobIconClass = (job: JobListItem): string | undefined => {
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {

View File

@@ -131,11 +131,11 @@ export const Queued: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-1'
const queueIndex = 104
const priority = 104
// Current job in pending
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
makePendingTask(jobId, priority, Date.now() - 90_000)
]
// Add some other pending jobs to give context
queue.pendingTasks.push(
@@ -179,13 +179,13 @@ export const QueuedParallel: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-parallel'
const queueIndex = 210
const priority = 210
// Current job in pending with some ahead
queue.pendingTasks = [
makePendingTask('job-ahead-1', 200, Date.now() - 180_000),
makePendingTask('job-ahead-2', 205, Date.now() - 150_000),
makePendingTask(jobId, queueIndex, Date.now() - 120_000)
makePendingTask(jobId, priority, Date.now() - 120_000)
]
// Seen 2 minutes ago - set via prompt metadata above
@@ -238,9 +238,9 @@ export const Running: Story = {
const exec = useExecutionStore()
const jobId = 'job-running-1'
const queueIndex = 300
const priority = 300
queue.runningTasks = [
makeRunningTask(jobId, queueIndex, Date.now() - 65_000)
makeRunningTask(jobId, priority, Date.now() - 65_000)
]
queue.historyTasks = [
makeHistoryTask('hist-r1', 250, 30, true),
@@ -279,10 +279,10 @@ export const QueuedZeroAheadSingleRunning: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-single'
const queueIndex = 510
const priority = 510
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 45_000)
makePendingTask(jobId, priority, Date.now() - 45_000)
]
queue.historyTasks = [
@@ -324,10 +324,10 @@ export const QueuedZeroAheadMultiRunning: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-multi'
const queueIndex = 520
const priority = 520
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 20_000)
makePendingTask(jobId, priority, Date.now() - 20_000)
]
queue.historyTasks = [
@@ -380,8 +380,8 @@ export const Completed: Story = {
const queue = useQueueStore()
const jobId = 'job-completed-1'
const queueIndex = 400
queue.historyTasks = [makeHistoryTask(jobId, queueIndex, 37, true)]
const priority = 400
queue.historyTasks = [makeHistoryTask(jobId, priority, 37, true)]
return { args: { ...args, jobId } }
},
@@ -401,11 +401,11 @@ export const Failed: Story = {
const queue = useQueueStore()
const jobId = 'job-failed-1'
const queueIndex = 410
const priority = 410
queue.historyTasks = [
makeHistoryTask(
jobId,
queueIndex,
priority,
12,
false,
'Example error: invalid inputs for node X'

View File

@@ -166,16 +166,16 @@ const queuedAtValue = computed(() =>
: ''
)
const currentQueueIndex = computed<number | null>(() => {
const currentJobPriority = computed<number | null>(() => {
const task = taskForJob.value
return task ? Number(task.queueIndex) : null
return task ? Number(task.job.priority) : null
})
const jobsAhead = computed<number | null>(() => {
const idx = currentQueueIndex.value
const idx = currentJobPriority.value
if (idx == null) return null
const ahead = queueStore.pendingTasks.filter(
(t: TaskItemImpl) => Number(t.queueIndex) < idx
(t: TaskItemImpl) => Number(t.job.priority) < idx
)
return ahead.length
})

View File

@@ -5,7 +5,7 @@
v-for="tab in visibleJobTabs"
:key="tab"
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
size="sm"
size="md"
@click="$emit('update:selectedJobTab', tab)"
>
{{ tabLabel(tab) }}

View File

@@ -40,7 +40,8 @@ const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
const { findParentGroup } = useGraphHierarchy()
@@ -109,9 +110,21 @@ const hasContainerInternalError = computed(() => {
})
})
const hasMissingNodeSelected = computed(
() =>
hasSelection.value &&
selectedNodes.value.some((node) =>
activeMissingNodeGraphIds.value.has(String(node.id))
)
)
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
return hasDirectNodeError.value || hasContainerInternalError.value
return (
hasDirectNodeError.value ||
hasContainerInternalError.value ||
hasMissingNodeSelected.value
)
})
const tabs = computed<RightSidePanelTabList>(() => {

View File

@@ -17,24 +17,26 @@
>
{{ card.nodeTitle }}
</span>
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="rounded-lg text-sm shrink-0"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
>
<i class="icon-[lucide--locate] size-3.5" />
</Button>
<div class="flex items-center shrink-0">
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="rounded-lg text-sm shrink-0 h-8"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
</div>
<!-- Multiple Errors within one Card -->

View File

@@ -0,0 +1,214 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const mockIsCloud = { value: false }
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
const mockApplyChanges = vi.fn()
const mockIsRestarting = ref(false)
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
useApplyChanges: () => ({
isRestarting: mockIsRestarting,
applyChanges: mockApplyChanges
})
}))
const mockIsPackInstalled = vi.fn(() => false)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
const mockShouldShowManagerButtons = { value: false }
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons
})
}))
vi.mock('./MissingPackGroupRow.vue', () => ({
default: {
name: 'MissingPackGroupRow',
template: '<div class="pack-row" />',
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
emits: ['locate-node', 'open-manager-info']
}
}))
import MissingNodeCard from './MissingNodeCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
missingNodePacks: {
ossMessage: 'Missing node packs detected. Install them.',
cloudMessage: 'Unsupported node packs detected.',
applyChanges: 'Apply Changes'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makePackGroups(count = 2): MissingPackGroup[] {
return Array.from({ length: count }, (_, i) => ({
packId: `pack-${i}`,
nodeTypes: [
{ type: `MissingNode${i}`, nodeId: String(i), isReplaceable: false }
],
isResolving: false
}))
}
function mountCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
return mount(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
}
}
})
}
describe('MissingNodeCard', () => {
beforeEach(() => {
mockApplyChanges.mockClear()
mockIsPackInstalled.mockReset()
mockIsPackInstalled.mockReturnValue(false)
mockIsCloud.value = false
mockShouldShowManagerButtons.value = false
mockIsRestarting.value = false
})
describe('Rendering & Props', () => {
it('renders cloud message when isCloud is true', () => {
mockIsCloud.value = true
const wrapper = mountCard()
expect(wrapper.text()).toContain('Unsupported node packs detected')
})
it('renders OSS message when isCloud is false', () => {
const wrapper = mountCard()
expect(wrapper.text()).toContain('Missing node packs detected')
})
it('renders correct number of MissingPackGroupRow components', () => {
const wrapper = mountCard({ missingPackGroups: makePackGroups(3) })
expect(
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
).toHaveLength(3)
})
it('renders zero rows when missingPackGroups is empty', () => {
const wrapper = mountCard({ missingPackGroups: [] })
expect(
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
).toHaveLength(0)
})
it('passes props correctly to MissingPackGroupRow children', () => {
const wrapper = mountCard({
showInfoButton: true,
showNodeIdBadge: true
})
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
expect(row.props('showInfoButton')).toBe(true)
expect(row.props('showNodeIdBadge')).toBe(true)
})
})
describe('Apply Changes Section', () => {
it('hides Apply Changes when manager is not enabled', () => {
mockShouldShowManagerButtons.value = false
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('hides Apply Changes when manager enabled but no packs pending', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('shows Apply Changes when at least one pack is pending restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountCard()
expect(wrapper.text()).toContain('Apply Changes')
})
it('displays spinner during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
const wrapper = mountCard()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('disables button during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
const wrapper = mountCard()
const btn = wrapper.find('button')
expect(btn.attributes('disabled')).toBeDefined()
})
it('calls applyChanges when Apply Changes button is clicked', async () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountCard()
const btn = wrapper.find('button')
await btn.trigger('click')
expect(mockApplyChanges).toHaveBeenCalledOnce()
})
})
describe('Event Handling', () => {
it('emits locateNode when child emits locate-node', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
await row.vm.$emit('locate-node', '42')
expect(wrapper.emitted('locateNode')).toBeTruthy()
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['42'])
})
it('emits openManagerInfo when child emits open-manager-info', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
await row.vm.$emit('open-manager-info', 'pack-0')
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['pack-0'])
})
})
})

View File

@@ -0,0 +1,79 @@
<template>
<div class="px-4 pb-2">
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
<p class="m-0 pb-5 text-sm text-muted-foreground leading-relaxed">
{{
isCloud
? t('rightSidePanel.missingNodePacks.cloudMessage')
: t('rightSidePanel.missingNodePacks.ossMessage')
}}
</p>
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
</div>
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
<div v-if="shouldShowManagerButtons" class="px-4">
<Button
v-if="hasInstalledPacksPendingRestart"
variant="primary"
:disabled="isRestarting"
class="w-full h-9 justify-center gap-2 text-sm font-semibold mt-2"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
<i v-else class="icon-[lucide--refresh-cw] size-4 shrink-0" />
<span class="truncate min-w-0">{{
t('rightSidePanel.missingNodePacks.applyChanges')
}}</span>
</Button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useApplyChanges } from '@/workbench/extensions/manager/composables/useApplyChanges'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { isCloud } from '@/platform/distribution/types'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
const props = defineProps<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
openManagerInfo: [packId: string]
}>()
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { isRestarting, applyChanges } = useApplyChanges()
const { shouldShowManagerButtons } = useManagerState()
/**
* Show Apply Changes when any pack from the error group is already installed
* on disk but ComfyUI hasn't restarted yet to load it.
* This is server-state based → persists across browser refreshes.
*/
const hasInstalledPacksPendingRestart = computed(() =>
props.missingPackGroups.some(
(g) => g.packId !== null && comfyManagerStore.isPackInstalled(g.packId)
)
)
</script>

View File

@@ -0,0 +1,368 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const mockInstallAllPacks = vi.fn()
const mockIsInstalling = ref(false)
const mockIsPackInstalled = vi.fn(() => false)
const mockShouldShowManagerButtons = { value: false }
const mockOpenManager = vi.fn()
const mockMissingNodePacks = ref<Array<{ id: string; name: string }>>([])
const mockIsLoading = ref(false)
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useMissingNodes',
() => ({
useMissingNodes: () => ({
missingNodePacks: mockMissingNodePacks,
isLoading: mockIsLoading
})
})
)
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/usePackInstall',
() => ({
usePackInstall: () => ({
isInstalling: mockIsInstalling,
installAllPacks: mockInstallAllPacks
})
})
)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons,
openManager: mockOpenManager
})
}))
vi.mock('@/workbench/extensions/manager/types/comfyManagerTypes', () => ({
ManagerTab: { Missing: 'missing', All: 'all' }
}))
import MissingPackGroupRow from './MissingPackGroupRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
loading: 'Loading'
},
rightSidePanel: {
locateNode: 'Locate node on canvas',
missingNodePacks: {
unknownPack: 'Unknown pack',
installNodePack: 'Install node pack',
installing: 'Installing...',
installed: 'Installed',
searchInManager: 'Search in Node Manager',
viewInManager: 'View in Manager',
collapse: 'Collapse',
expand: 'Expand'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makeGroup(
overrides: Partial<MissingPackGroup> = {}
): MissingPackGroup {
return {
packId: 'my-pack',
nodeTypes: [
{ type: 'MissingA', nodeId: '10', isReplaceable: false },
{ type: 'MissingB', nodeId: '11', isReplaceable: false }
],
isResolving: false,
...overrides
}
}
function mountRow(
props: Partial<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}> = {}
) {
return mount(MissingPackGroupRow, {
props: {
group: makeGroup(),
showInfoButton: false,
showNodeIdBadge: false,
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
}
}
})
}
describe('MissingPackGroupRow', () => {
beforeEach(() => {
mockInstallAllPacks.mockClear()
mockOpenManager.mockClear()
mockIsPackInstalled.mockReset()
mockIsPackInstalled.mockReturnValue(false)
mockShouldShowManagerButtons.value = false
mockIsInstalling.value = false
mockMissingNodePacks.value = []
mockIsLoading.value = false
})
describe('Basic Rendering', () => {
it('renders pack name from packId', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('my-pack')
})
it('renders "Unknown pack" when packId is null', () => {
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
expect(wrapper.text()).toContain('Unknown pack')
})
it('renders loading text when isResolving is true', () => {
const wrapper = mountRow({ group: makeGroup({ isResolving: true }) })
expect(wrapper.text()).toContain('Loading')
})
it('renders node count', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('(2)')
})
it('renders count of 5 for 5 nodeTypes', () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: Array.from({ length: 5 }, (_, i) => ({
type: `Node${i}`,
nodeId: String(i),
isReplaceable: false
}))
})
})
expect(wrapper.text()).toContain('(5)')
})
})
describe('Expand / Collapse', () => {
it('starts collapsed', () => {
const wrapper = mountRow()
expect(wrapper.text()).not.toContain('MissingA')
})
it('expands when chevron is clicked', async () => {
const wrapper = mountRow()
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('MissingA')
expect(wrapper.text()).toContain('MissingB')
})
it('collapses when chevron is clicked again', async () => {
const wrapper = mountRow()
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('MissingA')
await wrapper.get('button[aria-label="Collapse"]').trigger('click')
expect(wrapper.text()).not.toContain('MissingA')
})
})
describe('Node Type List', () => {
async function expand(wrapper: ReturnType<typeof mountRow>) {
await wrapper.get('button[aria-label="Expand"]').trigger('click')
}
it('renders all nodeTypes when expanded', async () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: [
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
]
})
})
await expand(wrapper)
expect(wrapper.text()).toContain('NodeA')
expect(wrapper.text()).toContain('NodeB')
expect(wrapper.text()).toContain('NodeC')
})
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
expect(wrapper.text()).toContain('#10')
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const wrapper = mountRow({ showNodeIdBadge: false })
await expand(wrapper)
expect(wrapper.text()).not.toContain('#10')
})
it('emits locateNode when Locate button is clicked', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
await wrapper
.get('button[aria-label="Locate node on canvas"]')
.trigger('click')
expect(wrapper.emitted('locateNode')).toBeTruthy()
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['10'])
})
it('does not show Locate for nodeType without nodeId', async () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
})
})
await expand(wrapper)
expect(
wrapper.find('button[aria-label="Locate node on canvas"]').exists()
).toBe(false)
})
it('handles mixed nodeTypes with and without nodeId', async () => {
const wrapper = mountRow({
showNodeIdBadge: true,
group: makeGroup({
nodeTypes: [
{ type: 'WithId', nodeId: '100', isReplaceable: false },
{ type: 'WithoutId', isReplaceable: false } as never
]
})
})
await expand(wrapper)
expect(wrapper.text()).toContain('WithId')
expect(wrapper.text()).toContain('WithoutId')
expect(
wrapper.findAll('button[aria-label="Locate node on canvas"]')
).toHaveLength(1)
})
})
describe('Manager Integration', () => {
it('hides install UI when shouldShowManagerButtons is false', () => {
mockShouldShowManagerButtons.value = false
const wrapper = mountRow()
expect(wrapper.text()).not.toContain('Install node pack')
})
it('hides install UI when packId is null', () => {
mockShouldShowManagerButtons.value = true
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
expect(wrapper.text()).not.toContain('Install node pack')
})
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = []
const wrapper = mountRow()
expect(wrapper.text()).toContain('Search in Node Manager')
})
it('shows "Installed" state when pack is installed', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.text()).toContain('Installed')
})
it('shows spinner when installing', () => {
mockShouldShowManagerButtons.value = true
mockIsInstalling.value = true
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('shows install button when not installed and pack found', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.text()).toContain('Install node pack')
})
it('calls installAllPacks when Install button is clicked', async () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
await wrapper.get('button:not([aria-label])').trigger('click')
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
})
it('shows loading spinner when registry is loading', () => {
mockShouldShowManagerButtons.value = true
mockIsLoading.value = true
const wrapper = mountRow()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
})
describe('Info Button', () => {
it('shows Info button when showInfoButton true and packId not null', () => {
const wrapper = mountRow({ showInfoButton: true })
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(true)
})
it('hides Info button when showInfoButton is false', () => {
const wrapper = mountRow({ showInfoButton: false })
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(false)
})
it('hides Info button when packId is null', () => {
const wrapper = mountRow({
showInfoButton: true,
group: makeGroup({ packId: null })
})
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(false)
})
it('emits openManagerInfo when Info button is clicked', async () => {
const wrapper = mountRow({ showInfoButton: true })
await wrapper.get('button[aria-label="View in Manager"]').trigger('click')
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['my-pack'])
})
})
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
const wrapper = mountRow({ group: makeGroup({ nodeTypes: [] }) })
expect(wrapper.text()).toContain('(0)')
})
})
})

View File

@@ -0,0 +1,250 @@
<template>
<div class="flex flex-col w-full mb-2">
<!-- Pack header row: pack name + info + chevron -->
<div class="flex h-8 items-center w-full">
<!-- Warning icon for unknown packs -->
<i
v-if="group.packId === null && !group.isResolving"
class="icon-[lucide--triangle-alert] size-4 text-warning-background shrink-0 mr-1.5"
/>
<p
class="flex-1 min-w-0 text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap"
:class="
group.packId === null && !group.isResolving
? 'text-warning-background'
: 'text-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
}}
</span>
</p>
<Button
v-if="showInfoButton && group.packId !== null"
variant="textonly"
size="icon-sm"
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--info] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
)
"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse')
: t('rightSidePanel.missingNodePacks.expand')
"
@click="toggleExpand"
>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="flex flex-col gap-0.5 pl-2 mb-1 overflow-hidden"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold mr-1"
>
#{{ nodeType.nodeId }}
</span>
<p class="flex-1 min-w-0 text-xs text-muted-foreground truncate">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
variant="textonly"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-base-foreground shrink-0 mr-1"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
<div
v-if="
shouldShowManagerButtons &&
group.packId !== null &&
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
"
class="flex items-start w-full pt-1 pb-1"
>
<Button
variant="secondary"
size="md"
class="flex flex-1 w-full"
:disabled="
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<i
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
class="icon-[lucide--check] size-4 text-foreground shrink-0 mr-1"
/>
<i
v-else
class="icon-[lucide--download] size-4 text-foreground shrink-0 mr-1"
/>
<span class="text-sm text-foreground truncate min-w-0">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: comfyManagerStore.isPackInstalled(group.packId)
? t('rightSidePanel.missingNodePacks.installed')
: t('rightSidePanel.missingNodePacks.installNodePack')
}}
</span>
</Button>
</div>
<!-- Registry still loading: packId known but result not yet available -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
class="flex items-start w-full pt-1 pb-1"
>
<div
class="flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 bg-secondary-background opacity-60 cursor-not-allowed select-none"
>
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
<span class="text-sm text-foreground truncate min-w-0">
{{ t('g.loading') }}
</span>
</div>
</div>
<!-- Search in Manager: fetch done but pack not found in registry -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons"
class="flex items-start w-full pt-1 pb-1"
>
<Button
variant="secondary"
size="md"
class="flex flex-1 w-full"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
>
<i class="icon-[lucide--search] size-4 text-foreground shrink-0 mr-1" />
<span class="text-sm text-foreground truncate min-w-0">
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
</span>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import type { MissingNodeType } from '@/types/comfy'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const props = defineProps<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
openManagerInfo: [packId: string]
}>()
const { t } = useI18n()
const { missingNodePacks, isLoading } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const { shouldShowManagerButtons, openManager } = useManagerState()
const nodePack = computed(() => {
if (!props.group.packId) return null
return missingNodePacks.value.find((p) => p.id === props.group.packId) ?? null
})
const { isInstalling, installAllPacks } = usePackInstall(() =>
nodePack.value ? [nodePack.value] : []
)
function handlePackInstallClick() {
if (!props.group.packId) return
if (!comfyManagerStore.isPackInstalled(props.group.packId)) {
void installAllPacks()
}
}
const expanded = ref(false)
function toggleExpand() {
expanded.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
if (typeof nodeType === 'string') return nodeType
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
}
function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function handleLocateNode(nodeType: MissingNodeType) {
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locateNode', String(nodeType.nodeId))
}
}
</script>

View File

@@ -18,7 +18,8 @@ vi.mock('@/scripts/app', () => ({
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn()
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/composables/useCopyToClipboard', () => ({

View File

@@ -27,6 +27,11 @@
:key="group.title"
:collapse="collapseState[group.title] ?? false"
class="border-b border-interface-stroke"
:size="
group.type === 'missing_node' || group.type === 'swap_nodes'
? 'lg'
: 'default'
"
@update:collapse="collapseState[group.title] = $event"
>
<template #label>
@@ -36,20 +41,79 @@
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
/>
<span class="text-destructive-background-hover truncate">
{{ group.title }}
{{
group.type === 'missing_node'
? `${group.title} (${missingPackGroups.length})`
: group.type === 'swap_nodes'
? `${group.title} (${swapNodeGroups.length})`
: group.title
}}
</span>
<span
v-if="group.cards.length > 1"
v-if="group.type === 'execution' && group.cards.length > 1"
class="text-destructive-background-hover"
>
({{ group.cards.length }})
</span>
</span>
<Button
v-if="
group.type === 'missing_node' &&
missingNodePacks.length > 0 &&
shouldShowInstallButton
"
variant="secondary"
size="sm"
class="shrink-0 mr-2 h-8 rounded-lg text-sm"
:disabled="isInstallingAll"
@click.stop="installAll"
>
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
{{
isInstallingAll
? t('rightSidePanel.missingNodePacks.installing')
: t('rightSidePanel.missingNodePacks.installAll')
}}
</Button>
<Button
v-else-if="group.type === 'swap_nodes'"
v-tooltip.top="
t(
'nodeReplacement.replaceAllWarning',
'Replaces all available nodes in this group.'
)
"
variant="secondary"
size="sm"
class="shrink-0 mr-2 h-8 rounded-lg text-sm"
@click.stop="handleReplaceAll()"
>
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
</Button>
</div>
</template>
<!-- Cards in Group (default slot) -->
<div class="px-4 space-y-3">
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:show-node-id-badge="showNodeIdBadge"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Swap Nodes -->
<SwapNodesCard
v-else-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Execution Errors -->
<div v-else-if="group.type === 'execution'" class="px-4 space-y-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
@@ -108,13 +172,22 @@ import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
@@ -122,6 +195,12 @@ const { focusNode, enterSubgraph } = useFocusNode()
const { staticUrls } = useExternalLink()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
useManagerState()
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
@@ -136,7 +215,10 @@ const {
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache
errorNodeCache,
missingNodeCache,
missingPackGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
/**
@@ -151,11 +233,13 @@ watch(
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
const hasMatch =
group.type === 'execution' &&
group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
collapseState[group.title] = !hasMatch
}
rightSidePanelStore.focusedErrorNodeId = null
@@ -167,6 +251,27 @@ function handleLocateNode(nodeId: string) {
focusNode(nodeId, errorNodeCache.value)
}
function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
} else {
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
}
}
function handleReplaceGroup(group: SwapNodeGroup) {
replaceGroup(group)
}
function handleReplaceAll() {
replaceAllGroups(swapNodeGroups.value)
}
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}

View File

@@ -0,0 +1,187 @@
import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getExecutionIdByNode: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: () => ({
inferPackFromNodeName: vi.fn()
})
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: vi.fn(() => '')
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => false)
}))
vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
type: string,
opts: {
nodeId?: string
isReplaceable?: boolean
replacement?: { new_node_id: string }
} = {}
): MissingNodeType {
return {
type,
nodeId: opts.nodeId ?? '1',
isReplaceable: opts.isReplaceable ?? false,
replacement: opts.replacement
? {
old_node_id: type,
new_node_id: opts.replacement.new_node_id,
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
: undefined
}
}
describe('swapNodeGroups computed', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
const store = useExecutionErrorStore()
store.surfaceMissingNodes(nodeTypes)
const searchQuery = ref('')
const t = (key: string) => key
const { swapNodeGroups } = useErrorGroups(searchQuery, t)
return swapNodeGroups
}
it('returns empty array when no missing nodes', () => {
const swap = getSwapNodeGroups([])
expect(swap.value).toEqual([])
})
it('returns empty array when no nodes are replaceable', () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('NodeA', { isReplaceable: false }),
makeMissingNodeType('NodeB', { isReplaceable: false })
])
expect(swap.value).toEqual([])
})
it('groups replaceable nodes by type', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('OldNode', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('OldNode', {
nodeId: '2',
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
})
])
await nextTick()
expect(swap.value).toHaveLength(1)
expect(swap.value[0].type).toBe('OldNode')
expect(swap.value[0].newNodeId).toBe('NewNode')
expect(swap.value[0].nodeTypes).toHaveLength(2)
})
it('creates separate groups for different types', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('TypeA', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewA' }
}),
makeMissingNodeType('TypeB', {
nodeId: '2',
isReplaceable: true,
replacement: { new_node_id: 'NewB' }
})
])
await nextTick()
expect(swap.value).toHaveLength(2)
expect(swap.value.map((g) => g.type)).toEqual(['TypeA', 'TypeB'])
})
it('sorts groups alphabetically by type', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('Zebra', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewZ' }
}),
makeMissingNodeType('Alpha', {
nodeId: '2',
isReplaceable: true,
replacement: { new_node_id: 'NewA' }
})
])
await nextTick()
expect(swap.value[0].type).toBe('Alpha')
expect(swap.value[1].type).toBe('Zebra')
})
it('excludes string nodeType entries', async () => {
const swap = getSwapNodeGroups([
'StringGroupNode' as unknown as MissingNodeType,
makeMissingNodeType('OldNode', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
})
])
await nextTick()
expect(swap.value).toHaveLength(1)
expect(swap.value[0].type).toBe('OldNode')
})
it('sets newNodeId to undefined when replacement is missing', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('OldNode', {
nodeId: '1',
isReplaceable: true
// no replacement
})
])
await nextTick()
expect(swap.value).toHaveLength(1)
expect(swap.value[0].newNodeId).toBeUndefined()
})
})

View File

@@ -14,8 +14,12 @@ export interface ErrorCardData {
errors: ErrorItem[]
}
export interface ErrorGroup {
title: string
cards: ErrorCardData[]
priority: number
}
export type ErrorGroup =
| {
type: 'execution'
title: string
cards: ErrorCardData[]
priority: number
}
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }

View File

@@ -0,0 +1,439 @@
import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getExecutionIdByNode: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: () => ({
inferPackFromNodeName: vi.fn()
})
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: vi.fn(() => '')
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => false)
}))
vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
type: string,
opts: {
nodeId?: string
isReplaceable?: boolean
cnrId?: string
replacement?: { new_node_id: string }
} = {}
): MissingNodeType {
return {
type,
nodeId: opts.nodeId ?? '1',
isReplaceable: opts.isReplaceable ?? false,
cnrId: opts.cnrId,
replacement: opts.replacement
? {
old_node_id: type,
new_node_id: opts.replacement.new_node_id,
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
: undefined
}
}
function createErrorGroups() {
const store = useExecutionErrorStore()
const searchQuery = ref('')
const t = (key: string) => key
const groups = useErrorGroups(searchQuery, t)
return { store, searchQuery, groups }
}
describe('useErrorGroups', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('missingPackGroups', () => {
it('returns empty array when no missing nodes', () => {
const { groups } = createErrorGroups()
expect(groups.missingPackGroups.value).toEqual([])
})
it('groups non-replaceable nodes by cnrId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(2)
const pack1 = groups.missingPackGroups.value.find(
(g) => g.packId === 'pack-1'
)
expect(pack1?.nodeTypes).toHaveLength(2)
const pack2 = groups.missingPackGroups.value.find(
(g) => g.packId === 'pack-2'
)
expect(pack2?.nodeTypes).toHaveLength(1)
})
it('excludes replaceable nodes from missingPackGroups', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBe('some-pack')
})
it('groups nodes without cnrId under null packId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBeNull()
expect(groups.missingPackGroups.value[0].nodeTypes).toHaveLength(2)
})
it('sorts groups alphabetically with null packId last', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
makeMissingNodeType('NodeB', { nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
])
await nextTick()
const packIds = groups.missingPackGroups.value.map((g) => g.packId)
expect(packIds).toEqual(['alpha-pack', 'zebra-pack', null])
})
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
])
await nextTick()
const group = groups.missingPackGroups.value[0]
const types = group.nodeTypes.map((n) =>
typeof n === 'string' ? n : `${n.type}:${n.nodeId}`
)
expect(types).toEqual(['NodeA:1', 'NodeA:3', 'NodeB:2'])
})
it('handles string nodeType entries', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
'StringGroupNode' as unknown as MissingNodeType
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBeNull()
})
})
describe('allErrorGroups', () => {
it('returns empty array when no errors', () => {
const { groups } = createErrorGroups()
expect(groups.allErrorGroups.value).toEqual([])
})
it('includes missing_node group when missing nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
})
it('includes swap_nodes group when replaceable nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
})
])
await nextTick()
const swapGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'swap_nodes'
)
expect(swapGroup).toBeDefined()
})
it('includes both swap_nodes and missing_node when both exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
const types = groups.allErrorGroups.value.map((g) => g.type)
expect(types).toContain('swap_nodes')
expect(types).toContain('missing_node')
})
it('swap_nodes has lower priority than missing_node', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
const swapIdx = groups.allErrorGroups.value.findIndex(
(g) => g.type === 'swap_nodes'
)
const missingIdx = groups.allErrorGroups.value.findIndex(
(g) => g.type === 'missing_node'
)
expect(swapIdx).toBeLessThan(missingIdx)
})
it('includes execution error groups from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'value_not_valid',
message: 'Value not valid',
details: 'some detail'
}
]
}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
})
it('includes execution error from runtime errors', async () => {
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
node_type: 'KSampler',
executed: [],
exception_type: 'RuntimeError',
exception_message: 'CUDA out of memory',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
})
it('includes prompt error when present', async () => {
const { store, groups } = createErrorGroups()
store.lastPromptError = {
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
}
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution' && g.title === 'No outputs'
)
expect(promptGroup).toBeDefined()
})
})
describe('filteredGroups', () => {
it('returns all groups when search query is empty', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
await nextTick()
expect(groups.filteredGroups.value.length).toBeGreaterThan(0)
})
it('filters groups based on search query', async () => {
const { store, groups, searchQuery } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'value_error',
message: 'Value error in sampler',
details: ''
}
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{
type: 'file_not_found',
message: 'File not found',
details: ''
}
]
}
}
await nextTick()
searchQuery.value = 'sampler'
await nextTick()
const executionGroups = groups.filteredGroups.value.filter(
(g) => g.type === 'execution'
)
for (const group of executionGroups) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(c) =>
c.title.toLowerCase().includes('sampler') ||
c.errors.some((e) => e.message.toLowerCase().includes('sampler'))
)
expect(hasMatch).toBe(true)
}
})
})
describe('groupedErrorMessages', () => {
it('returns empty array when no errors', () => {
const { groups } = createErrorGroups()
expect(groups.groupedErrorMessages.value).toEqual([])
})
it('collects unique error messages from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{ type: 'err_a', message: 'Error A', details: '' },
{ type: 'err_b', message: 'Error B', details: '' }
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [{ type: 'err_a', message: 'Error A', details: '' }]
}
}
await nextTick()
const messages = groups.groupedErrorMessages.value
expect(messages).toContain('Error A')
expect(messages).toContain('Error B')
// Deduplication: Error A appears twice but should only be listed once
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
})
it('includes missing node group title as message', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
})
})
describe('collapseState', () => {
it('returns a reactive object', () => {
const { groups } = createErrorGroups()
expect(groups.collapseState).toBeDefined()
expect(typeof groups.collapseState).toBe('object')
})
})
})

View File

@@ -1,11 +1,11 @@
import { computed, reactive } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { isCloud } from '@/platform/distribution/types'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
@@ -20,6 +20,7 @@ import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { isNodeExecutionId } from '@/types/nodeIdentification'
@@ -32,7 +33,23 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
'server_error'
])
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
export interface MissingPackGroup {
packId: string | null
nodeTypes: MissingNodeType[]
isResolving: boolean
}
export interface SwapNodeGroup {
type: string
newNodeId: string | undefined
nodeTypes: MissingNodeType[]
}
interface GroupEntry {
type: 'execution'
priority: number
cards: Map<string, ErrorCardData>
}
@@ -76,7 +93,7 @@ function getOrCreateGroup(
): Map<string, ErrorCardData> {
let entry = groupsMap.get(title)
if (!entry) {
entry = { priority, cards: new Map() }
entry = { type: 'execution', priority, cards: new Map() }
groupsMap.set(title, entry)
}
return entry.cards
@@ -137,6 +154,7 @@ function addCardErrorToGroup(
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
type: 'execution' as const,
title,
cards: Array.from(groupData.cards.values()),
priority: groupData.priority
@@ -153,6 +171,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
const searchableList: ErrorSearchItem[] = []
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi]
if (group.type !== 'execution') continue
for (let ci = 0; ci < group.cards.length; ci++) {
const card = group.cards[ci]
searchableList.push({
@@ -160,8 +179,12 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
searchableMessage: card.errors
.map((e: ErrorItem) => e.message)
.join(' '),
searchableDetails: card.errors
.map((e: ErrorItem) => e.details ?? '')
.join(' ')
})
}
}
@@ -184,11 +207,16 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
)
return groups
.map((group, gi) => ({
...group,
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
}))
.filter((group) => group.cards.length > 0)
.map((group, gi) => {
if (group.type !== 'execution') return group
return {
...group,
cards: group.cards.filter((_: ErrorCardData, ci: number) =>
matchedCardKeys.has(`${gi}:${ci}`)
)
}
})
.filter((group) => group.type !== 'execution' || group.cards.length > 0)
}
export function useErrorGroups(
@@ -197,6 +225,7 @@ export function useErrorGroups(
) {
const executionErrorStore = useExecutionErrorStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const collapseState = reactive<Record<string, boolean>>({})
const selectedNodeInfo = computed(() => {
@@ -237,6 +266,19 @@ export function useErrorGroups(
return map
})
const missingNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
for (const nodeType of nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
const nodeId = String(nodeType.nodeId)
const node = getNodeByExecutionId(app.rootGraph, nodeId)
if (node) map.set(nodeId, node)
}
return map
})
function isErrorInSelection(executionNodeId: string): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -343,6 +385,173 @@ export function useErrorGroups(
)
}
// Async pack-ID resolution for missing node types that lack a cnrId
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
const pendingTypes = computed(() =>
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
(n): n is Exclude<MissingNodeType, string> =>
typeof n !== 'string' && !n.cnrId
)
)
watch(
pendingTypes,
async (pending, _, onCleanup) => {
const toResolve = pending.filter(
(n) => asyncResolvedIds.value.get(n.type) === undefined
)
if (!toResolve.length) return
const resolvingTypes = toResolve.map((n) => n.type)
let cancelled = false
onCleanup(() => {
cancelled = true
const next = new Map(asyncResolvedIds.value)
for (const type of resolvingTypes) {
if (next.get(type) === RESOLVING) next.delete(type)
}
asyncResolvedIds.value = next
})
const updated = new Map(asyncResolvedIds.value)
for (const type of resolvingTypes) updated.set(type, RESOLVING)
asyncResolvedIds.value = updated
const results = await Promise.allSettled(
toResolve.map(async (n) => ({
type: n.type,
packId: (await inferPackFromNodeName.call(n.type))?.id ?? null
}))
)
if (cancelled) return
const final = new Map(asyncResolvedIds.value)
for (const r of results) {
if (r.status === 'fulfilled') {
final.set(r.value.type, r.value.packId)
}
}
// Clear any remaining RESOLVING markers for failed lookups
for (const type of resolvingTypes) {
if (final.get(type) === RESOLVING) final.set(type, null)
}
asyncResolvedIds.value = final
},
{ immediate: true }
)
const missingPackGroups = computed<MissingPackGroup[]>(() => {
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<
string | null,
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
>()
const resolvingKeys = new Set<string | null>()
for (const nodeType of nodeTypes) {
if (typeof nodeType !== 'string' && nodeType.isReplaceable) continue
let packId: string | null
if (typeof nodeType === 'string') {
packId = null
} else if (nodeType.cnrId) {
packId = nodeType.cnrId
} else {
const resolved = asyncResolvedIds.value.get(nodeType.type)
if (resolved === undefined || resolved === RESOLVING) {
packId = null
resolvingKeys.add(null)
} else {
packId = resolved
}
}
const existing = map.get(packId)
if (existing) {
existing.nodeTypes.push(nodeType)
} else {
map.set(packId, { nodeTypes: [nodeType], isResolving: false })
}
}
for (const key of resolvingKeys) {
const group = map.get(key)
if (group) group.isResolving = true
}
return Array.from(map.entries())
.sort(([packIdA], [packIdB]) => {
// null (Unknown Pack) always goes last
if (packIdA === null) return 1
if (packIdB === null) return -1
return packIdA.localeCompare(packIdB)
})
.map(([packId, { nodeTypes, isResolving }]) => ({
packId,
nodeTypes: [...nodeTypes].sort((a, b) => {
const typeA = typeof a === 'string' ? a : a.type
const typeB = typeof b === 'string' ? b : b.type
const typeCmp = typeA.localeCompare(typeB)
if (typeCmp !== 0) return typeCmp
const idA = typeof a === 'string' ? '' : String(a.nodeId ?? '')
const idB = typeof b === 'string' ? '' : String(b.nodeId ?? '')
return idA.localeCompare(idB, undefined, { numeric: true })
}),
isResolving
}))
})
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<string, SwapNodeGroup>()
for (const nodeType of nodeTypes) {
if (typeof nodeType === 'string' || !nodeType.isReplaceable) continue
const typeName = nodeType.type
const existing = map.get(typeName)
if (existing) {
existing.nodeTypes.push(nodeType)
} else {
map.set(typeName, {
type: typeName,
newNodeId: nodeType.replacement?.new_node_id,
nodeTypes: [nodeType]
})
}
}
return Array.from(map.values()).sort((a, b) => a.type.localeCompare(b.type))
})
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
const error = executionErrorStore.missingNodesError
if (!error) return []
const groups: ErrorGroup[] = []
if (swapNodeGroups.value.length > 0) {
groups.push({
type: 'swap_nodes' as const,
title: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
priority: 0
})
}
if (missingPackGroups.value.length > 0) {
groups.push({
type: 'missing_node' as const,
title: error.message,
priority: 1
})
}
return groups.sort((a, b) => a.priority - b.priority)
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
@@ -350,7 +559,7 @@ export function useErrorGroups(
processNodeErrors(groupsMap)
processExecutionError(groupsMap)
return toSortedGroups(groupsMap)
return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
})
const tabErrorGroups = computed<ErrorGroup[]>(() => {
@@ -360,9 +569,11 @@ export function useErrorGroups(
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)
return isSingleNodeSelected.value
const executionGroups = isSingleNodeSelected.value
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
return [...buildMissingNodeGroups(), ...executionGroups]
})
const filteredGroups = computed<ErrorGroup[]>(() => {
@@ -373,10 +584,15 @@ export function useErrorGroups(
const groupedErrorMessages = computed<string[]>(() => {
const messages = new Set<string>()
for (const group of allErrorGroups.value) {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
if (group.type === 'execution') {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
}
} else {
// Groups without cards (e.g. missing_node) surface their title as the message.
messages.add(group.title)
}
}
return Array.from(messages)
@@ -389,6 +605,9 @@ export function useErrorGroups(
collapseState,
isSingleNodeSelected,
errorNodeCache,
groupedErrorMessages
missingNodeCache,
groupedErrorMessages,
missingPackGroups,
swapNodeGroups
}
}

View File

@@ -10,12 +10,16 @@ const {
label,
enableEmptyState,
tooltip,
size = 'default',
tooltipDelay = 1000,
class: className
} = defineProps<{
disabled?: boolean
label?: string
enableEmptyState?: boolean
tooltip?: string
size?: 'default' | 'lg'
tooltipDelay?: number
class?: string
}>()
@@ -25,7 +29,7 @@ const isExpanded = computed(() => !isCollapse.value && !disabled)
const tooltipConfig = computed(() => {
if (!tooltip) return undefined
return { value: tooltip, showDelay: 1000 }
return { value: tooltip, showDelay: tooltipDelay }
})
</script>
@@ -39,7 +43,8 @@ const tooltipConfig = computed(() => {
type="button"
:class="
cn(
'group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
'group bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
size === 'lg' ? 'min-h-16' : 'min-h-12',
!disabled && 'cursor-pointer'
)
"

View File

@@ -131,6 +131,12 @@ const nodeHasError = computed(() => {
return hasDirectError.value || hasContainerInternalError.value
})
const showSeeError = computed(
() =>
nodeHasError.value &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
)
const parentGroup = computed<LGraphGroup | null>(() => {
if (!targetNode.value || !getNodeParentGroup) return null
return getNodeParentGroup(targetNode.value)
@@ -194,6 +200,7 @@ defineExpose({
:enable-empty-state
:disabled="isEmpty"
:tooltip
:size="showSeeError ? 'lg' : 'default'"
>
<template #label>
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
@@ -223,13 +230,10 @@ defineExpose({
</span>
</span>
<Button
v-if="
nodeHasError &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
"
v-if="showSeeError"
variant="secondary"
size="sm"
class="shrink-0 rounded-lg text-sm"
class="shrink-0 rounded-lg text-sm h-8"
@click.stop="navigateToErrorTab"
>
{{ t('rightSidePanel.seeError') }}

View File

@@ -34,6 +34,7 @@ const {
node,
isDraggable = false,
hiddenFavoriteIndicator = false,
hiddenWidgetActions = false,
showNodeName = false,
parents = [],
isShownOnParents = false
@@ -42,6 +43,7 @@ const {
node: LGraphNode
isDraggable?: boolean
hiddenFavoriteIndicator?: boolean
hiddenWidgetActions?: boolean
showNodeName?: boolean
parents?: SubgraphNode[]
isShownOnParents?: boolean
@@ -170,7 +172,10 @@ const displayLabel = customRef((track, trigger) => {
>
{{ sourceNodeName }}
</span>
<div class="flex items-center gap-1 shrink-0 pointer-events-auto">
<div
v-if="!hiddenWidgetActions"
class="flex items-center gap-1 shrink-0 pointer-events-auto"
>
<WidgetActions
v-model:label="displayLabel"
:widget="widget"

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import {
demoteWidget,
@@ -17,10 +18,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { cn } from '@/utils/tailwindUtil'
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
@@ -30,9 +31,6 @@ const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const draggableList = ref<DraggableList | undefined>(undefined)
const draggableItems = ref()
const promotionEntries = computed(() => {
const node = activeNode.value
if (!node) return []
@@ -195,54 +193,9 @@ function showRecommended() {
}
}
function setDraggableState() {
draggableList.value?.dispose()
if (searchQuery.value || !draggableItems.value?.children?.length) return
draggableList.value = new DraggableList(
draggableItems.value,
'.draggable-item'
)
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem
}
}
const newPosition = reorderedItems.indexOf(this.draggableItem)
const aw = activeWidgets.value
const [w] = aw.splice(oldPosition, 1)
aw.splice(newPosition, 0, w)
activeWidgets.value = aw
}
}
watch(filteredActive, () => {
setDraggableState()
})
onMounted(() => {
setDraggableState()
if (activeNode.value) pruneDisconnected(activeNode.value)
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
</script>
<template>
@@ -280,19 +233,18 @@ onBeforeUnmount(() => {
{{ $t('subgraphStore.hideAll') }}</a
>
</div>
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
<SubgraphNodeWidget
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
class="bg-comfy-menu-bg"
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
:node-title="node.title"
:widget-name="widget.name"
:is-shown="true"
:is-draggable="!searchQuery"
:is-physical="node.id === -1"
:is-draggable="!searchQuery"
@toggle-visibility="demote([node, widget])"
/>
</div>
</DraggableList>
</div>
<div

View File

@@ -29,8 +29,7 @@ function getIcon() {
cn(
'flex py-1 px-2 break-all rounded items-center gap-1',
'bg-node-component-surface',
props.isDraggable &&
'draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing hover:ring-1 ring-accent-background',
props.isDraggable && 'hover:ring-1 ring-accent-background',
props.class
)
"

View File

@@ -50,7 +50,8 @@ describe('NodeSearchCategorySidebar', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
essentials_category: 'basic'
essentials_category: 'basic',
python_module: 'comfy_essentials'
})
])
await nextTick()
@@ -58,9 +59,13 @@ describe('NodeSearchCategorySidebar', () => {
const wrapper = await createWrapper()
expect(wrapper.text()).toContain('Most relevant')
expect(wrapper.text()).toContain('Recents')
expect(wrapper.text()).toContain('Favorites')
expect(wrapper.text()).toContain('Essentials')
expect(wrapper.text()).toContain('Custom')
expect(wrapper.text()).toContain('Blueprints')
expect(wrapper.text()).toContain('Partner')
expect(wrapper.text()).toContain('Comfy')
expect(wrapper.text()).toContain('Extensions')
})
it('should mark the selected preset category as selected', async () => {

View File

@@ -53,7 +53,6 @@ import NodeSearchCategoryTreeNode, {
CATEGORY_UNSELECTED_CLASS
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
@@ -65,11 +64,11 @@ const selectedCategory = defineModel<string>('selectedCategory', {
})
const { t } = useI18n()
const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const topCategories = computed(() => [
{ id: 'most-relevant', label: t('g.mostRelevant') },
{ id: 'recents', label: t('g.recents') },
{ id: 'favorites', label: t('g.favorites') }
])
@@ -81,10 +80,18 @@ const hasEssentialNodes = computed(() =>
const sourceCategories = computed(() => {
const categories = []
if (flags.nodeLibraryEssentialsEnabled && hasEssentialNodes.value) {
if (hasEssentialNodes.value) {
categories.push({ id: 'essentials', label: t('g.essentials') })
}
categories.push({ id: 'custom', label: t('g.custom') })
categories.push(
{
id: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
},
{ id: 'partner', label: t('g.partner') },
{ id: 'comfy', label: t('g.comfy') },
{ id: 'extensions', label: t('g.extensions') }
)
return categories
})

View File

@@ -132,7 +132,7 @@ describe('NodeSearchContent', () => {
expect(wrapper.text()).toContain('No results')
})
it('should show only non-Core nodes when Custom is selected', async () => {
it('should show only CustomNodes when Extensions is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CoreNode',
@@ -155,7 +155,7 @@ describe('NodeSearchContent', () => {
).toBe(NodeSourceType.CustomNodes)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-custom"]').trigger('click')
await wrapper.find('[data-testid="category-extensions"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)

View File

@@ -19,9 +19,6 @@
<!-- Filter header row -->
<div class="flex items-center">
<div class="shrink-0 px-3 py-2 text-sm text-muted-foreground">
{{ $t('g.filterBy') }}
</div>
<NodeSearchFilterBar
class="flex-1"
:active-chip-key="activeFilter?.key"
@@ -128,7 +125,6 @@ const searchQuery = ref('')
const selectedCategory = ref('most-relevant')
const selectedIndex = ref(0)
// Filter selection mode
const activeFilter = ref<FilterChip | null>(null)
const filterQuery = ref('')
@@ -171,7 +167,6 @@ function cancelFilter() {
nextTick(() => searchInputRef.value?.focus())
}
// Node search
const searchResults = computed(() => {
if (!searchQuery.value && filters.length === 0) {
return nodeFrequencyStore.topNodeDefs
@@ -212,11 +207,24 @@ const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
(n) => n.nodeSource.type === NodeSourceType.Essentials
)
break
case 'custom':
case 'recents':
return searchResults.value
case 'blueprints':
results = allNodes.filter(
(n) =>
n.nodeSource.type !== NodeSourceType.Core &&
n.nodeSource.type !== NodeSourceType.Essentials
(n) => n.nodeSource.type === NodeSourceType.Blueprint
)
break
case 'partner':
results = allNodes.filter((n) => n.api_node)
break
case 'comfy':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Core
)
break
case 'extensions':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.CustomNodes
)
break
default:
@@ -247,7 +255,6 @@ watch([selectedCategory, searchQuery, () => filters], () => {
selectedIndex.value = 0
})
// Keyboard navigation
function onKeyDown() {
if (activeFilter.value) {
filterPanelRef.value?.navigate(1)

View File

@@ -39,14 +39,17 @@ describe(NodeSearchFilterBar, () => {
return wrapper
}
it('should render Input, Output, and Source filter chips', async () => {
it('should render all filter chips', async () => {
const wrapper = await createWrapper()
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(3)
expect(buttons[0].text()).toBe('Input')
expect(buttons[1].text()).toBe('Output')
expect(buttons[2].text()).toBe('Source')
expect(buttons).toHaveLength(6)
expect(buttons[0].text()).toBe('Blueprints')
expect(buttons[1].text()).toBe('Partner Nodes')
expect(buttons[2].text()).toBe('Essentials')
expect(buttons[3].text()).toBe('Extensions')
expect(buttons[4].text()).toBe('Input')
expect(buttons[5].text()).toBe('Output')
})
it('should mark active chip as pressed when activeChipKey matches', async () => {

View File

@@ -52,6 +52,26 @@ const nodeDefStore = useNodeDefStore()
const chips = computed<FilterChip[]>(() => {
const searchService = nodeDefStore.nodeSearchService
return [
{
key: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints'),
filter: searchService.nodeSourceFilter
},
{
key: 'partnerNodes',
label: t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes'),
filter: searchService.nodeSourceFilter
},
{
key: 'essentials',
label: t('g.essentials'),
filter: searchService.nodeSourceFilter
},
{
key: 'extensions',
label: t('g.extensions'),
filter: searchService.nodeSourceFilter
},
{
key: 'input',
label: t('g.input'),
@@ -61,11 +81,6 @@ const chips = computed<FilterChip[]>(() => {
key: 'output',
label: t('g.output'),
filter: searchService.outputTypeFilter
},
{
key: 'source',
label: t('g.source'),
filter: searchService.nodeSourceFilter
}
]
})

View File

@@ -123,13 +123,3 @@ const nodeFrequency = computed(() =>
const nodeBookmarkStore = useNodeBookmarkStore()
const isBookmarked = computed(() => nodeBookmarkStore.isBookmarked(nodeDef))
</script>
<style scoped>
:deep(.highlight) {
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
</style>

View File

@@ -37,15 +37,27 @@ export const testI18n = createI18n({
addNode: 'Add a node...',
filterBy: 'Filter by:',
mostRelevant: 'Most relevant',
recents: 'Recents',
favorites: 'Favorites',
essentials: 'Essentials',
custom: 'Custom',
comfy: 'Comfy',
partner: 'Partner',
extensions: 'Extensions',
noResults: 'No results',
filterByType: 'Filter by {type}...',
input: 'Input',
output: 'Output',
source: 'Source',
search: 'Search'
},
sideToolbar: {
nodeLibraryTab: {
filterOptions: {
blueprints: 'Blueprints',
partnerNodes: 'Partner Nodes'
}
}
}
}
}

View File

@@ -106,4 +106,42 @@ describe('AssetsSidebarListView', () => {
expect(assetListItem?.props('previewUrl')).toBe('')
expect(assetListItem?.props('isVideoPreview')).toBe(false)
})
it('emits preview-asset when item preview is clicked', async () => {
const imageAsset = {
...buildAsset('image-asset', 'image.png'),
preview_url: '/api/view/image.png',
user_metadata: {}
} satisfies AssetItem
const wrapper = mountListView([buildOutputItem(imageAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
expect(assetListItem).toBeDefined()
assetListItem!.vm.$emit('preview-click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
it('emits preview-asset when item is double-clicked', async () => {
const imageAsset = {
...buildAsset('image-asset-dbl', 'image.png'),
preview_url: '/api/view/image.png',
user_metadata: {}
} satisfies AssetItem
const wrapper = mountListView([buildOutputItem(imageAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
expect(assetListItem).toBeDefined()
await assetListItem!.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
})

View File

@@ -56,6 +56,8 @@
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('select-asset', item.asset, selectableAssets)"
@dblclick.stop="emit('preview-asset', item.asset)"
@preview-click="emit('preview-asset', item.asset)"
@stack-toggle="void toggleStack(item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
@@ -116,6 +118,7 @@ const assetsStore = useAssetsStore()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
(e: 'preview-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
}>()

View File

@@ -95,6 +95,7 @@
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
@@ -216,10 +217,6 @@ import {
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
// Lazy-loaded to avoid pulling THREE.js into the main bundle
const Load3dViewerContent = defineAsyncComponent(
() => 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'
@@ -251,6 +248,10 @@ import {
} from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
const { t } = useI18n()
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()

View File

@@ -67,7 +67,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, defineAsyncComponent, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
@@ -86,11 +86,17 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
const { t, n } = useI18n()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
@@ -151,6 +157,24 @@ const {
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const previewOutput = item.taskRef?.previewOutput
if (previewOutput?.is3D) {
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: item.title,
component: Load3dViewerContent,
props: {
modelUrl: previewOutput.url || ''
},
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true
}
})
return
}
await openResultGallery(item)
})

View File

@@ -46,10 +46,10 @@ vi.mock('./nodeLibrary/AllNodesPanel.vue', () => ({
}
}))
vi.mock('./nodeLibrary/CustomNodesPanel.vue', () => ({
vi.mock('./nodeLibrary/BlueprintsPanel.vue', () => ({
default: {
name: 'CustomNodesPanel',
template: '<div data-testid="custom-panel"><slot /></div>',
name: 'BlueprintsPanel',
template: '<div data-testid="blueprints-panel"><slot /></div>',
props: ['sections', 'expandedKeys']
}
}))
@@ -58,7 +58,7 @@ vi.mock('./nodeLibrary/EssentialNodesPanel.vue', () => ({
default: {
name: 'EssentialNodesPanel',
template: '<div data-testid="essential-panel"><slot /></div>',
props: ['root', 'expandedKeys']
props: ['root', 'expandedKeys', 'flatNodes']
}
}))
@@ -127,6 +127,8 @@ describe('NodeLibrarySidebarTabV2', () => {
expect(wrapper.find('[data-testid="essential-panel"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="all-panel"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="custom-panel"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="blueprints-panel"]').exists()).toBe(
false
)
})
})

View File

@@ -29,17 +29,79 @@
v-for="option in sortingOptions"
:key="option.id"
:value="option.id"
class="flex cursor-pointer items-center justify-end gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{ $t(option.label) }}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
<span>{{ $t(option.label) }}</span>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<DropdownMenuRoot v-if="selectedTab === 'all'">
<DropdownMenuTrigger as-child>
<button
:aria-label="$t('sideToolbar.nodeLibraryTab.filter')"
class="flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-comfy-input hover:bg-comfy-input-hover border-none"
>
<i class="icon-[lucide--list-filter] size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
class="z-[9999] min-w-32 rounded-lg border border-border-default bg-comfy-menu-bg p-1 shadow-lg"
align="end"
:side-offset="4"
>
<DropdownMenuCheckboxItem
v-model="filterOptions.blueprints"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.partnerNodes"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.comfyNodes"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.comfyNodes')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.extensions"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.extensions')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<Separator decorative class="border border-dashed border-comfy-input" />
<!-- Tab list in header (fixed) -->
@@ -52,7 +114,7 @@
:value="tab.value"
:class="
cn(
'flex-1 text-center select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'text-sm text-foreground transition-colors',
selectedTab === tab.value
? 'bg-comfy-input font-bold'
@@ -75,6 +137,7 @@
"
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
<AllNodesPanel
@@ -82,12 +145,13 @@
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
<CustomNodesPanel
v-if="selectedTab === 'custom'"
<BlueprintsPanel
v-if="selectedTab === 'blueprints'"
v-model:expanded-keys="expandedKeys"
:sections="renderedCustomSections"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabsRoot>
@@ -99,6 +163,7 @@
import { cn } from '@/utils/tailwindUtil'
import { useLocalStorage } from '@vueuse/core'
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItemIndicator,
DropdownMenuPortal,
@@ -125,17 +190,23 @@ import {
nodeOrganizationService
} from '@/services/nodeOrganizationService'
import { getProviderIcon } from '@/utils/categoryUtil'
import { sortedTree } from '@/utils/treeUtil'
import { flattenTree, sortedTree, unwrapTreeRoot } from '@/utils/treeUtil'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { SortingStrategyId, TabId } from '@/types/nodeOrganizationTypes'
import { buildNodeDefTree, useNodeDefStore } from '@/stores/nodeDefStore'
import type {
NodeCategoryId,
NodeSection,
SortingStrategyId,
TabId
} from '@/types/nodeOrganizationTypes'
import type {
NodeLibrarySection,
RenderedTreeExplorerNode,
TreeNode
} from '@/types/treeExplorerTypes'
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
import CustomNodesPanel from './nodeLibrary/CustomNodesPanel.vue'
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
@@ -161,7 +232,7 @@ const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
{
essentials: DEFAULT_SORTING_ID,
all: DEFAULT_SORTING_ID,
custom: 'alphabetical'
blueprints: 'alphabetical'
}
)
const sortOrder = usePerTabState(selectedTab, sortOrderByTab)
@@ -173,14 +244,21 @@ const sortingOptions = computed(() =>
}))
)
const filterOptions = ref<Record<NodeCategoryId, boolean>>({
blueprints: true,
partnerNodes: true,
comfyNodes: true,
extensions: true
})
const { t } = useI18n()
const searchBoxRef = ref()
const searchBoxRef = ref<InstanceType<typeof SearchBox> | null>(null)
const searchQuery = ref('')
const expandedKeysByTab = ref<Record<TabId, string[]>>({
essentials: [],
all: [],
custom: []
blueprints: []
})
const expandedKeys = usePerTabState(selectedTab, expandedKeysByTab)
@@ -213,8 +291,8 @@ const sections = computed(() => {
function getFolderIcon(node: TreeNode): string {
const firstLeaf = findFirstLeaf(node)
if (
firstLeaf?.key?.startsWith('root/api node') &&
firstLeaf.key.replace(`${node.key}/`, '') === firstLeaf.label
firstLeaf?.data?.api_node &&
firstLeaf.key?.replace(`${node.key}/`, '') === firstLeaf.label
) {
return getProviderIcon(node.label ?? '')
}
@@ -264,12 +342,33 @@ function applySorting(tree: TreeNode): TreeNode {
return tree
}
const renderedSections = computed(() => {
return sections.value.map((section) => ({
function renderSections(
nodeSections: NodeSection[],
filter?: (section: NodeSection) => boolean
): NodeLibrarySection<ComfyNodeDefImpl>[] {
const filtered = filter ? nodeSections.filter(filter) : nodeSections
if (sortOrder.value === 'alphabetical') {
const allNodes = filtered.flatMap((section) =>
flattenTree<ComfyNodeDefImpl>(section.tree)
)
const mergedTree = unwrapTreeRoot(buildNodeDefTree(allNodes))
return [{ root: fillNodeInfo(applySorting(mergedTree)) }]
}
return filtered.map((section) => ({
category: section.category,
title: section.title,
root: fillNodeInfo(applySorting(section.tree))
}))
})
}
const renderedSections = computed(() =>
renderSections(
sections.value,
(section) => !section.category || filterOptions.value[section.category]
)
)
const essentialSections = computed(() => {
if (selectedTab.value !== 'essentials') return []
@@ -286,18 +385,32 @@ const renderedEssentialRoot = computed(() => {
: fillNodeInfo({ key: 'root', label: '', children: [] })
})
const customSections = computed(() => {
if (selectedTab.value !== 'custom') return []
return nodeOrganizationService.organizeNodesByTab(activeNodes.value, 'custom')
function flattenRenderedLeaves(
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
): RenderedTreeExplorerNode<ComfyNodeDefImpl>[] {
if (node.type === 'node') return [node]
return node.children?.flatMap(flattenRenderedLeaves) ?? []
}
const essentialFlatNodes = computed(() => {
if (sortOrder.value !== 'alphabetical') return []
return flattenRenderedLeaves(renderedEssentialRoot.value).sort((a, b) =>
(a.label ?? '').localeCompare(b.label ?? '')
)
})
const renderedCustomSections = computed(() => {
return customSections.value.map((section) => ({
title: section.title,
root: fillNodeInfo(applySorting(section.tree))
}))
const blueprintsSections = computed(() => {
if (selectedTab.value !== 'blueprints') return []
return nodeOrganizationService.organizeNodesByTab(
activeNodes.value,
'blueprints'
)
})
const renderedBlueprintsSections = computed(() =>
renderSections(blueprintsSections.value)
)
function collectFolderKeys(node: TreeNode): string[] {
if (node.leaf) return []
const keys = [node.key]
@@ -334,8 +447,8 @@ async function handleSearch() {
for (const section of essentialSections.value) {
allKeys.push(...collectFolderKeys(section.tree))
}
} else if (selectedTab.value === 'custom') {
for (const section of customSections.value) {
} else if (selectedTab.value === 'blueprints') {
for (const section of blueprintsSections.value) {
allKeys.push(...collectFolderKeys(section.tree))
}
} else {
@@ -347,19 +460,18 @@ async function handleSearch() {
}
const tabs = computed(() => {
const baseTabs: Array<{ value: TabId; label: string }> = [
const allTabs: Array<{ value: TabId; label: string }> = [
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
{
value: 'essentials' as TabId,
label: t('sideToolbar.nodeLibraryTab.essentials')
},
{
value: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.blueprints')
}
]
return flags.nodeLibraryEssentialsEnabled
? [
{
value: 'essentials' as TabId,
label: t('sideToolbar.nodeLibraryTab.essentials')
},
...baseTabs
]
: baseTabs
return flags.nodeLibraryEssentialsEnabled ? allTabs : [allTabs[0], allTabs[2]]
})
onMounted(() => {

View File

@@ -1,9 +1,8 @@
<template>
<div
ref="containerRef"
:class="
cn(
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col w-full',
props.class
)
"
@@ -37,17 +36,9 @@
</div>
</template>
<script lang="ts">
import type { InjectionKey, Ref } from 'vue'
export const SidebarContainerKey: InjectionKey<Ref<HTMLElement | null>> =
Symbol('SidebarContainer')
</script>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import Toolbar from 'primevue/toolbar'
import { provide, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
@@ -58,7 +49,4 @@ const props = defineProps<{
const sidebarPt = {
start: 'min-w-0 flex-1 overflow-hidden'
}
const containerRef = ref<HTMLElement | null>(null)
provide(SidebarContainerKey, containerRef)
</script>

View File

@@ -1,28 +1,30 @@
<template>
<TabsContent value="all" class="flex-1 overflow-y-auto h-full">
<!-- Favorites section -->
<template v-if="hasFavorites">
<h3
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ $t('sideToolbar.nodeLibraryTab.sections.favorites') }}
</h3>
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
:root="favoritesRoot"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
</template>
<h3
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ $t('sideToolbar.nodeLibraryTab.sections.bookmarked') }}
</h3>
<TreeExplorerV2
v-if="hasFavorites"
v-model:expanded-keys="expandedKeys"
:root="favoritesRoot"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
<div v-else class="px-6 py-2 text-xs text-muted-background">
{{ $t('sideToolbar.nodeLibraryTab.noBookmarkedNodes') }}
</div>
<!-- Node sections -->
<div v-for="(section, index) in sections" :key="section.title ?? index">
<div v-for="(section, index) in sections" :key="section.category ?? index">
<h3
v-if="section.title"
class="px-4 py-2 text-xs font-medium tracking-wide text-muted-foreground mb-0"
v-if="section.category && sortOrder !== 'alphabetical'"
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ section.title }}
{{ $t(NODE_CATEGORY_LABELS[section.category]) }}
</h3>
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
@@ -42,15 +44,17 @@ import { computed } from 'vue'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NODE_CATEGORY_LABELS } from '@/types/nodeOrganizationTypes'
import type {
NodeLibrarySection,
RenderedTreeExplorerNode,
TreeNode
} from '@/types/treeExplorerTypes'
const { fillNodeInfo } = defineProps<{
sections: NodeLibrarySection[]
const { fillNodeInfo, sortOrder = 'original' } = defineProps<{
sections: NodeLibrarySection<ComfyNodeDefImpl>[]
fillNodeInfo: (node: TreeNode) => RenderedTreeExplorerNode<ComfyNodeDefImpl>
sortOrder?: string
}>()
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })

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