Compare commits

...

146 Commits

Author SHA1 Message Date
bymyself
75022f302b fix: use node.pos/size instead of getBounding for positioning
getBounding returns stale data for freshly created nodes since
updateArea only runs at the start of each render frame. Use
node.pos and node.size directly which are set immediately by
createNode.
2026-03-15 10:37:43 +00:00
bymyself
959f1365dd fix: restore position-before-connect order to match main
Connections can modify node bounding boxes (adding input slots), so
positioning must happen before connect() to get correct getBounding()
values. This matches the operation order on main.
2026-03-15 10:03:04 +00:00
bymyself
216c707c87 fix: make PasteNodesResult non-exported to pass knip 2026-03-15 07:27:20 +00:00
bymyself
47a2f312c8 fix: await upload completion before closing undo batch
Make pasteFiles async so upload completion is awaitable. Keep the
emitBeforeChange/emitAfterChange transaction open until all uploads
finish, ensuring widget value changes from onUploadComplete are
captured in the same undo entry as the node creation.
2026-03-15 07:21:49 +00:00
bymyself
80a4b31da4 fix: update test to match restored selectItems call in multi-image branch 2026-03-15 05:01:05 +00:00
bymyself
93bb2d0d6f fix: restore positionBatchNodes/selectItems for multi-image and deduplicate getFileType
- Add back positionBatchNodes and selectItems calls in multi-image branch
- Extract duplicate getFileType into a module-level function
2026-03-12 08:06:30 -07:00
bymyself
f04809d124 test: fix undo test to call changeTracker.undo() directly
Amp-Thread-ID: https://ampcode.com/threads/T-019cbb7e-7e09-7668-bac9-ff06ee44a8e4
2026-03-04 21:55:12 -08:00
bymyself
6171bd9ac4 test: fix batch image import E2E tests for CI reliability
Amp-Thread-ID: https://ampcode.com/threads/T-019cbb7e-7e09-7668-bac9-ff06ee44a8e4
2026-03-04 21:45:36 -08:00
GitHub Action
218cf60f5f [automated] Apply ESLint and Oxfmt fixes 2026-03-05 05:26:04 +00:00
bymyself
fc56f7ee85 test: add E2E tests for batch image import and undo
Amp-Thread-ID: https://ampcode.com/threads/T-019cbb7e-7e09-7668-bac9-ff06ee44a8e4
2026-03-04 21:23:34 -08:00
bymyself
c664b5bc38 fix: update test mock canvas with emitBeforeChange/emitAfterChange and correct selectItems assertion
Amp-Thread-ID: https://ampcode.com/threads/T-019cbb71-dfcd-772a-8995-5fc69ae59f4b
2026-03-04 16:45:13 -08:00
GitHub Action
724d60822a [automated] Apply ESLint and Oxfmt fixes 2026-03-04 21:22:34 +00:00
bymyself
a15d3ce49b fix: batch image import into single undo entry
Wrap handleFileList in emitBeforeChange()/emitAfterChange() so
dropping multiple images creates one undo entry instead of one per
node. Add JSDoc to the undocumented batching APIs and unit tests
for ChangeTracker batching.
2026-03-04 13:20:17 -08:00
Kelly Yang
1e86e8c4d5 [Bug] Node preview images are lost when switching between multiple workflow tabs (#9380)
## Summary

When working with multiple workflow tabs, the internal preview (image
thumbnail) of nodes like Load Image disappears after navigating away
from and back to a tab. This affects all active tabs once the switch
occurs.

## Screenshot
before


https://github.com/user-attachments/assets/99466123-37db-406f-9e17-0a9ff22311c3



after




https://github.com/user-attachments/assets/bdad0ef1-72b7-46c7-aa61-0a557688e55e

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-04 20:58:57 +00:00
pythongosssss
31276ff2a6 feat: Show empty workflow dialog when entering app builder with no nodes (#9379)
## Summary

Prompts users to load a template or return to graph when entering
builder mode on an empty workflow

## Screenshots (if applicable)

<img width="627" height="275" alt="image"
src="https://github.com/user-attachments/assets/c1a35dc3-4e8f-4abd-95b9-2f92524e8ebf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9379-feat-Show-empty-workflow-dialog-when-entering-app-builder-with-no-nodes-3196d73d36508123b643ec893cd86cac)
by [Unito](https://www.unito.io)
2026-03-04 12:15:56 -08:00
AustinMroz
f084a60708 Misc app mode fixes (#9368)
A working branch of smaller app mode fixes. Can be merged at any time
and I'll make a new branch.
- Selected inputs and outputs can now be re-ordered when clicking on
label text
- 3d outputs once again display correctly
- Some padding has been added to the side so that control buttons don't
overlap with the floating app sidebar controls
- A "Share" button placeholder has been added to the menu, but is
disabled
- Adds a workaround for canvas read_only state being disabled when
'space' is pressed.
  - This one is particularly hacky, and can be pulled out if problematic
- Fix download all only downloading the first output

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9368-Misc-app-mode-fixes-3196d73d365081eab02ad1e693784707)
by [Unito](https://www.unito.io)
2026-03-04 10:14:05 -08:00
pythongosssss
c759fe517f feat: Replace BuilderExitButton with new BuilderFooterToolbar (#9378)
## Summary

Makes it easier and more obvious for users to navigate between steps

## Changes

- **What**: 
- add back/next navigation to builder footer alongside exit button
- extract shared step logic into useBuilderSteps composable

## Screenshots (if applicable)

<img width="428" height="102" alt="image"
src="https://github.com/user-attachments/assets/91b33e8f-53ae-4895-a2eb-fb1316b2b367"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9378-feat-Replace-BuilderExitButton-with-new-BuilderFooterToolbar-3196d73d3650819392efc171cf277326)
by [Unito](https://www.unito.io)
2026-03-04 09:58:59 -08:00
pythongosssss
f4ed79b133 feat: Add apps sidebar tab (#9342)
## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## 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)

<img width="383" height="359" alt="image"
src="https://github.com/user-attachments/assets/47905196-9db6-4a57-8cf7-384d4d37d000"
/>

<img width="335" height="281" alt="image"
src="https://github.com/user-attachments/assets/843068f3-e895-4781-bf5f-e0eb86d3387c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9342-feat-Add-apps-sidebar-tab-3176d73d3650812b822fc9cc3f17322e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-04 09:54:26 -08:00
pythongosssss
194218a9d6 fix: Prune invalid builder mappings on load (#9376)
## Summary

- extract resolveNode to reusable util
- remove mid builder pruning
- handle missing widgets with label

## Review Focus

`resolveNode` was simplified for subgraphs by calling getNodeById on
each of the subgraphs instead of searching their inner nodes manually.

## Screenshots (if applicable)

"Widget not visible"
<img width="657" height="822" alt="image"
src="https://github.com/user-attachments/assets/ab7d1e87-3210-4e54-876a-07881974b5c7"
/>
<img width="674" height="375" alt="image"
src="https://github.com/user-attachments/assets/c50ec871-d423-43d6-8e1e-7b1a362f621c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9376-fix-Prune-invalid-builder-mappings-on-load-3196d73d3650811280c2d459ed0271af)
by [Unito](https://www.unito.io)
2026-03-04 09:52:14 -08:00
pythongosssss
3e59f8e932 feat: App builder confirmation dialog after setting default view mode (#9374)
## Summary

Adds an additional dialog after setting the default view of the workflow
to let users pick their next step

## Screenshots (if applicable)

<img width="479" height="332" alt="image"
src="https://github.com/user-attachments/assets/1ea40b10-d7d3-49ff-9ea2-27b9e907c923"
/>

<img width="478" height="343" alt="image"
src="https://github.com/user-attachments/assets/21674998-5ce2-496d-97e6-ef8f2f2d7dd7"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9374-feat-App-builder-confirmation-dialog-after-setting-default-view-mode-3196d73d36508192a45ee8ba0a7f74a6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-04 09:51:36 -08:00
Johnpaul Chiwetelu
9933d9bd11 fix: make queue button tooltip reflect current mode (#9350)
## Summary

Make queue button tooltip mode-aware so it shows the correct action text
based on whether QPOV2 is enabled.

## Changes

- **What**: Update `queueHistoryTooltipConfig` in `ComfyActionbar.vue`
to conditionally show "View Job History" (QPOV2 enabled) or
"Expand/Collapse Queue" (QPOV2 disabled) instead of always showing "View
Job History"

## Review Focus

Straightforward conditional using existing `isQueuePanelV2Enabled`
computed and existing i18n keys.

Fixes #9278

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9350-fix-make-queue-button-tooltip-reflect-current-mode-3186d73d36508122b198e5fbb0226221)
by [Unito](https://www.unito.io)
2026-03-03 22:27:36 -08:00
AustinMroz
fe8ab1d896 App mode mobile redesign (#9047)
Reworks the app mode display for mobile devices. Adds multiple bottom
tabs that can be swiped between.


![AnimateDiff_00005](https://github.com/user-attachments/assets/e1c928ff-dd52-4f4c-83a6-c351c4711e62)

To be handled in followup PRs
- Nicer error display
- Support for even smaller screens
- UX improvements for the 'Outputs' pane
  - Was postponed to minimize conflicts with non-mobile development.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9047-App-mode-mobile-redesign-30e6d73d365081388e4adea4df886522)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-03 14:18:19 -08:00
pythongosssss
68b16e3a3f feat: App mode saving rework (#9338)
## Summary

Change app mode changes to be written directly to the workflow on change
instead of requiring explicit save via builder.
Temporary: Adds `.app.json` file extension to app files for
identification since we don't currently have a way to identify them with
metadata
Removes app builder save dialog and replaces it with default mode
selection

## Changes

- **What**: 
- ensure all save locations handle app mode
- remove dirtyLinearData and flushing

- **Breaking**: 
- if people are relying on workflow names and are converting to/from app
mode in the same workflow, they will gain/lose the `.app` part of the
extension

## Screenshots (if applicable)

<img width="689" height="84" alt="image"
src="https://github.com/user-attachments/assets/335596ee-dce9-4e3a-a7b5-f0715c294e41"
/>

<img width="421" height="324" alt="image"
src="https://github.com/user-attachments/assets/ad3cd33c-e9f0-4c30-8874-d4507892fc6b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9338-feat-App-mode-saving-rework-3176d73d3650813f9ae1f6c5a234da8c)
by [Unito](https://www.unito.io)
2026-03-03 11:35:36 -08:00
Alexander Brown
ab2aaa3852 refactor: use the gradient directly instead of with a custom utility. (#9327)
## Summary

Little simpler.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9327-refactor-use-the-gradient-directly-instead-of-with-a-custom-utility-3166d73d36508179876af0ef8cea35b7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-03 09:25:04 -08:00
pythongosssss
be04046ec8 App builder item - cap max width and truncate (#9335)
## Summary

Currently they overflow, this adds truncation

## Screenshots (if applicable)

<img width="325" height="459" alt="image"
src="https://github.com/user-attachments/assets/92600c14-da31-4a96-af3c-aeac928243c4"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9335-App-builder-item-cap-max-width-and-truncate-3176d73d365081d1b461cf2758b04ec2)
by [Unito](https://www.unito.io)
2026-03-03 08:55:13 -08:00
pythongosssss
b18a0713db feat: App mode enter builder menu item (#9341)
## Summary

Adds enter builder menu item for easier access to app builder. 
Fixes issues with seen item tracking

## Changes

- **What**: 
- add enter builder menu item
- change non visible items to still be returned as part of the array, so
they are not incorrectly removed from the seen-items tracking
- split toggle-app-mode into two stable items

## Screenshots (if applicable)

<img width="309" height="526" alt="image"
src="https://github.com/user-attachments/assets/69affc2c-34ab-45eb-b47b-efacb8a20b99"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9341-feat-App-mode-enter-builder-menu-item-3176d73d365081a9a7e7cf1a1986354f)
by [Unito](https://www.unito.io)
2026-03-03 08:35:47 -08:00
pythongosssss
d360b2218f fix: App builder menu change "Save app" to just "Save" (#9356)
## Summary

Changes "Save app" to just "Save" as you are saving the whole workflow
as normal

## Screenshots (if applicable)

<img width="293" height="247" alt="image"
src="https://github.com/user-attachments/assets/c5aea772-12cf-4b0e-8336-8d72ef55be98"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9356-fix-App-builder-menu-change-Save-app-to-just-Save-3186d73d36508119a7c0e8e00155e0b0)
by [Unito](https://www.unito.io)
2026-03-03 08:25:48 -08:00
Terry Jia
613058e831 fix: propagate widget disabled state to Vue node components (#9321)
## Summary

Widgets with `widget.disabled = true` (e.g. display-only counters in
custom nodes) were editable in Vue node mode despite being correctly
greyed out in litegraph mode. The disabled state from the widget store
was not being merged into the options passed to Vue widget components.

## Screenshots (if applicable)
Before


https://github.com/user-attachments/assets/6957dd86-6eb9-4edb-93ee-50fc5aa5350f


After


https://github.com/user-attachments/assets/d954006f-d7e6-4e7c-9b3c-bcabed0e6260

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9321-fix-propagate-widget-disabled-state-to-Vue-node-components-3166d73d365081a7936aeabe81eb6e15)
by [Unito](https://www.unito.io)
2026-03-03 10:27:26 -05:00
Johnpaul Chiwetelu
16119dfcd2 fix: allow cursor positioning in painter opacity input (#9348) 2026-03-03 10:14:34 +01:00
Terry Jia
a6f1b1cf90 fix: sync subgraph name on double-click title rename (#9353)
## Summary
The Vue renderer's title editing path (NodeHeader →
useNodeEventHandlers) only updated node.title but not subgraph.name, so
the breadcrumb didn't reflect the new name when entering the subgraph.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9353-fix-sync-subgraph-name-on-double-click-title-rename-3186d73d365081e2bc54f19ecd421ac0)
by [Unito](https://www.unito.io)
2026-03-02 20:15:21 -08:00
Alexander Brown
c95d32249b fix: Custom Combo options display in Nodes 2.0 (#9324)
## Summary

Keep the value in the store instead of in the closure.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9324-fix-Custom-Combo-options-display-in-Nodes-2-0-3166d73d3650814db361c41ebdb1d222)
by [Unito](https://www.unito.io)
2026-03-02 19:23:01 -08:00
Dante
740df0470e feat: use cloud backend thumbnail resize for image previews (#9298)
## Summary

- In cloud mode, large generated images (4K, 8K+) cause browser freezing
when loaded at full resolution for preview display
- The cloud backend (ingest service) now supports a `res` query
parameter on `/api/view` that returns server-side resized JPEG (quality
80, max 512px) instead of redirecting to the full-size GCS original
- This PR adds `&res=512` to all image preview URLs in cloud mode,
reducing browser decode overhead from tens of MB to tens of KB
- Downloads still use the original resolution (no `res` param)
- No impact on localhost/desktop builds (`isCloud` compile-time
constant)

### without `?res`

302 -> png downloads
<img width="808" height="564" alt="스크린샷 2026-02-28 오후 6 53 03"
src="https://github.com/user-attachments/assets/7c1c62dd-0bc4-468d-9c74-7b98e892e126"
/>
<img width="323" height="137" alt="스크린샷 2026-02-28 오후 6 52 52"
src="https://github.com/user-attachments/assets/926aa0c4-856c-4057-96a0-d8fbd846762b"
/>

200 -> jpeg

### with `?res`
<img width="811" height="407" alt="스크린샷 2026-02-28 오후 6 51 55"
src="https://github.com/user-attachments/assets/d58d46ae-6749-4888-8bad-75344c4d868b"
/>


### Changes

- **New utility**: `getCloudResParam(filename?)` returns `&res=512` in
cloud mode for image files, empty string otherwise
- **Core stores**: `imagePreviewStore` appends `res` to node output
URLs; `queueStore.ResultItemImpl` gets a `previewUrl` getter (separates
preview from download URLs)
- **Applied to**: asset browser thumbnails, widget dropdown previews,
linear mode indicators, image compare node, background image upload

### Intentionally excluded

- Downloads (`getAssetUrl`) — need original resolution
- Mask editor — needs pixel-accurate data
- Audio/video/3D files — `res` only applies to raster images
- Execution-in-progress previews — use WebSocket blob URLs, not
`/api/view`

## Test plan

- [x] Unit tests for `getCloudResParam()` (5 tests: cloud/non-cloud,
image/non-image, undefined filename)
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] All 5332 unit tests pass
- [x] Manual verification on cloud.comfy.org: `res=512` returns 200 with
resized JPEG; without `res` returns 302 redirect to GCS PNG original

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:56:06 +00:00
Terry Jia
dccf68ddb7 fix: improve painter cursor performance by bypassing Vue reactivity (#9339)
## Summary
Previously painter has node performance issue. 
Use direct DOM manipulation for cursor position updates instead of
reactive refs, and add will-change-transform for GPU layer promotion.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9339-fix-improve-painter-cursor-performance-by-bypassing-Vue-reactivity-3176d73d365081d88b23d26e774cebf5)
by [Unito](https://www.unito.io)
2026-03-02 21:18:48 -05:00
Terry Jia
117448fba4 fix: stop pointer events on audio widgets to prevent node drag (#9329)
## Summary

Audio player and record widgets were missing @pointerdown.stop, causing
node drag when interacting with the timeline or controls.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/061a9ad2-0cc2-45f8-aea0-d45e3a2912b9


after


https://github.com/user-attachments/assets/a510c50a-65b8-4944-9480-b53cbe61c7da

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9329-fix-stop-pointer-events-on-audio-widgets-to-prevent-node-drag-3176d73d36508140b236c61e83954f5c)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-02 20:43:25 -05:00
Terry Jia
da77227cf2 fix: clear combo widget value when removing image preview (#9323)
## Summary
The X button on image preview in VueNodes mode only cleared the stored
outputs but left the combo widget value intact, causing the old image to
persist across workflow runs and page refreshes.

## Screenshots (if applicable)
Before

https://github.com/user-attachments/assets/e2146ed1-5d79-41d6-946c-b30667ffac6a

After


https://github.com/user-attachments/assets/359b81fa-acc9-4711-9cee-62c230086f0c

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9323-fix-clear-combo-widget-value-when-removing-image-preview-3166d73d3650816db867eba49b8aeb6c)
by [Unito](https://www.unito.io)
2026-03-02 20:26:44 -05: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
jaeone94
4689581674 feat: enhance manager dialog with initial pack id support (#9169)
## Summary
Adds `initialPackId` support to the manager dialog so callers can
deep-link directly to a specific node pack — pre-filling the search
query, switching to packs search mode, and auto-selecting the matching
pack once results load.

## Changes
- **ManagerDialog.vue**: Added `initialPackId` prop; wires it into
`useRegistrySearch` (forces `packs` mode and pre-fills query) and uses
VueUse `until()` to auto-select the target pack and open the right panel
once `resultsWithKeys` is populated (one-shot, never re-triggers). Also
fixes a latent bug where the effective initial tab (resolving the
persisted tab) was not used when determining the initial search mode and
query — previously `initialTab` (the raw prop) was checked directly,
which would produce incorrect pre-fill when no tab prop was passed but a
Missing tab was persisted.
- **useManagerDialog.ts**: Threads `initialPackId` through `show()` into
the dialog props
- **useManagerState.ts**: Exposes `initialPackId` in `openManager`
options and passes it to `managerDialog.show()`; also removes a stale
fallback `show(ManagerTab.All)` call that was redundant for the
legacy-only error path

### Refactor: remove `executionIdUtil.ts` and distribute its functions
- **`getAncestorExecutionIds` / `getParentExecutionIds`** → moved to
`src/types/nodeIdentification.ts`: both are pure `NodeExecutionId`
string operations with no external dependencies, consistent with the
existing `parseNodeExecutionId` / `createNodeExecutionId` helpers
already in that file
- **`buildSubgraphExecutionPaths`** → moved to
`src/platform/workflow/validation/schemas/workflowSchema.ts`: operates
entirely on `ComfyNode[]` and `SubgraphDefinition` (both defined there),
and `isSubgraphDefinition` is already co-located in the same file
- Tests redistributed accordingly: ancestor/parent ID tests into
`nodeIdentification.test.ts`, `buildSubgraphExecutionPaths` tests into
`workflowSchema.test.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9169-feat-enhance-manager-dialog-with-initial-pack-id-support-3116d73d365081f7b6a3cbfb2f2755bf)
by [Unito](https://www.unito.io)
2026-02-25 22:23:53 +09:00
Benjamin Lu
e4b456bb2c fix: publish desktop-specific frontend release artifact (#9206)
## Summary
- add a desktop-specific frontend release artifact (`dist-desktop.zip`)
in release draft creation
- build `dist-desktop.zip` with `DISTRIBUTION=desktop`
- keep existing `dist.zip` behavior for core/PyPI consumers
- extend `scripts/zipdist.js` to support custom source and output paths

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9206-fix-publish-desktop-specific-frontend-release-artifact-3126d73d3650812495cdf6e9ad2ac280)
by [Unito](https://www.unito.io)
2026-02-25 03:35:41 -08:00
Alexander Brown
482ad401d4 fix: eradicate tailwind @apply usage in vue styles (#9146)
## Summary

Remove Tailwind `@apply` from Vue styles across `src/` and
`apps/desktop-ui/src/` to align with Tailwind v4 guidance, replacing
usages with template utilities or native CSS while preserving behavior.

## Changes

- **What**:
- Batch 1: migrated low-risk template/style utility bundles out of
`@apply`.
- Batch 2: converted PrimeVue/`:deep()` override `@apply` blocks to
native CSS declarations.
- Batch 3: converted `src/components/node/NodeHelpContent.vue` markdown
styling from `@apply` to native CSS/token-based declarations.
- Batch 4: converted final desktop pseudo-element `@apply` styles and
removed stale `@reference` directives no longer required.
- Verified `rg -n "^\s*@apply\b" src apps -g "*.vue"` has no real CSS
`@apply` directives remaining (only known template false-positive event
binding in `NodeSearchContent.vue`).

## Review Focus

- Visual parity in components that previously depended on `@apply` in
`:deep()` selectors and markdown content:
  - topbar tabs/popovers, dialogs, breadcrumb, terminal overrides
  - desktop install/dialog/update/maintenance surfaces
  - node help markdown rendering
- Confirm no regressions from removal of now-unneeded `@reference`
directives.

## Screenshots (if applicable)

- No new screenshots included in this PR.
- Screenshot Playwright suite was run with `--grep="@screenshot"` and
reports baseline diffs in this environment (164 passed, 39 failed, 3
skipped) plus a teardown `EPERM` restore error on local path
`C:\Users\DrJKL\ComfyUI\LTXV\user`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9146-fix-eradicate-tailwind-apply-usage-in-vue-styles-3116d73d3650813d8642e0bada13df32)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-24 21:23:52 -08:00
Benjamin Lu
9082f6bc3c fix: resolve desktop-ui build failure from icon path cwd mismatch (#9185)
## Summary
Desktop UI production builds were failing in distribution due to an icon
path being resolved from the wrong working directory.

## Problem
`@comfyorg/desktop-ui:build` runs with `cwd: apps/desktop-ui`, but
design-system CSS config includes:
`from-folder(comfy, './packages/design-system/src/icons')`

That relative path only exists from workspace root, so desktop builds
errored with:
`ENOENT: no such file or directory, scandir
'./packages/design-system/src/icons/'`

## Fix
Update the desktop build target to run Vite from workspace root by
removing the app-local `cwd` and using a root-relative config path:
- from: `vite build --config vite.config.mts` with `cwd:
apps/desktop-ui`
- to: `vite build --config apps/desktop-ui/vite.config.mts`

This keeps the icon path resolvable while preserving the same desktop
build config.

## Validation
- `pnpm nx run @comfyorg/desktop-ui:build --skip-nx-cache` 
- `pnpm build:desktop --skip-nx-cache` 

(Separate pre-existing issues remain in `@comfyorg/desktop-ui:typecheck`
and `@comfyorg/desktop-ui:lint`; unchanged by this PR.)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9185-fix-resolve-desktop-ui-build-failure-from-icon-path-cwd-mismatch-3126d73d3650813c94cae25a9240f9b7)
by [Unito](https://www.unito.io)
2026-02-24 20:48:41 -08:00
Comfy Org PR Bot
7c34a0e0f6 1.41.5 (#9182)
Patch version increment to 1.41.5

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9182-1-41-5-3126d73d3650811d84d7eaf9e384567a)
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-24 20:42:38 -08:00
Hunter
8c3738fb77 feat: add Free subscription tier support (#8864)
## Summary

Add frontend support for a Free subscription tier — login/signup page
restructuring, telemetry instrumentation, and tier-aware billing gating.

## Changes

- **What**: 
- Restructure login/signup pages: OAuth buttons promoted as primary
sign-in method, email login available via progressive disclosure
- Add Free tier badge on Google sign-up button with dynamic credit count
from remote config
- Add `FREE` subscription tier to type system (tier pricing, tier rank,
registry types)
  - Add `isFreeTier` computed to `useSubscription()`
- Disable credit top-up for Free tier users (dialogService,
purchaseCredits, popover CTA)
- Show subscription/upgrade dialog instead of top-up dialog when Free
tier user hits out-of-credits
- Add funnel telemetry: `trackLoginOpened`, enrich `trackSignupOpened`
with `free_tier_badge_shown`, track email toggle clicks

## Review Focus

- Tier gating logic: Free tier users should see "Upgrade" instead of
"Add Credits" and never reach the top-up flow
- Telemetry event design for Mixpanel funnel analysis
- Progressive disclosure UX on login/signup pages

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8864-feat-add-Free-subscription-tier-support-3076d73d36508133b84ec5f0a67ccb03)
by [Unito](https://www.unito.io)
2026-02-24 23:28:51 -05:00
Jin Yi
aee207f16c [bugfix] Fix workspace dialog pt override losing base styles (#9188)
## Summary
Workspace dialog `pt` overrides were spreading `workspaceDialogPt` then
replacing `pt.root`, which discarded other `pt` properties from the base
config. This fix removes the redundant overrides so all workspace
dialogs consistently use `workspaceDialogPt` as-is.

## Changes
- **What**: Remove incorrect `pt` spread-and-override pattern in 5
workspace dialog calls
- **Why**: The override replaced the entire `pt` object, losing styles
like `header: { class: 'p-0! hidden' }`

## Review Focus
- Verify that the removed `max-w-[400px]` / `max-w-[512px]` constraints
are either unnecessary or already handled by `workspaceDialogPt` or the
dialog components themselves

<img width="709" height="357" alt="스크린샷 2026-02-25 오후 12 16 08"
src="https://github.com/user-attachments/assets/5020664d-1a8c-478b-a16a-14f59bcf0dde"
/>
<img width="784" height="390" alt="스크린샷 2026-02-25 오후 12 16 03"
src="https://github.com/user-attachments/assets/041dc09d-5639-4880-a95d-a8a6e29e303e"
/>
<img width="551" height="392" alt="스크린샷 2026-02-25 오후 12 15 56"
src="https://github.com/user-attachments/assets/b9769a9d-c0fa-4400-b6d7-0358ba806eaa"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9188-bugfix-Fix-workspace-dialog-pt-override-losing-base-styles-3126d73d365081b8a73ffc681ccb52a6)
by [Unito](https://www.unito.io)
2026-02-25 12:26:09 +09:00
Jin Yi
164379bf4b [refactor] Redesign missing models dialog (#9014)
## Summary
Redesign the missing models warning dialog to match the MissingNodes
dialog pattern with header/content/footer separation, type badges, file
sizes, and context-sensitive actions.

## Changes
- **What**: Split `MissingModelsWarning.vue` into `MissingModelsHeader`,
`MissingModelsContent`, `MissingModelsFooter` components following the
established MissingNodes pattern. Added model type badges (VAE,
DIFFUSION, LORA, etc.), inline file sizes, total download size, custom
model warnings, and context-sensitive footer buttons (Download all /
Download available / Ok, got it). Extracted security validation into
shared `missingModelsUtils.ts`. Removed orphaned `FileDownload`,
`ElectronFileDownload`, `useDownload`, and `useCivitaiModel` files.
- **Breaking**: None

## Review Focus
- Badge styling and icon button variants for theme compatibility
- Security validation logic preserved correctly in extracted utility
- E2e test locator updates for the new dialog structure

<img width="641" height="478" alt="스크린샷 2026-02-20 오후 7 35 23"
src="https://github.com/user-attachments/assets/ded27dc7-04e6-431d-9b2e-a96ba61043a4"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9014-refactor-Redesign-missing-models-dialog-30d6d73d365081809cb0c555c2c28034)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-25 10:51:18 +09:00
Dante
9108b7535a feat: support video file drag-drop and paste (#9154) 2026-02-25 07:59:26 +09:00
Alexander Brown
2ff14fadc2 fix: prevent infinite node resize loop in Vue mode (#9177)
## Summary

Fix infinite node resize loop in Vue mode where textarea widgets caused
nodes to grow ~33px per frame indefinitely.

## Changes

- **What**: Two feedback loops broken in the LiteGraph↔Vue layout sync:
1. `_arrangeWidgets()` in LiteGraph's draw loop was calling `setSize()`
every frame with its own computed widget height, which disagreed with
Vue's DOM-measured height. Guarded with `!LiteGraph.vueNodesMode`.
2. `useLayoutSync` was calling `setSize()` which triggers the size
setter → writes back to layoutStore with `source=Canvas` →
`handleLayoutChange` updates CSS vars → ResizeObserver fires → loop.
Changed to direct array assignment (matching the existing position sync
pattern).

## Review Focus

- The `_arrangeWidgets` guard: in Vue mode, the DOM/ResizeObserver is
the source of truth for node sizing, so LiteGraph should not grow nodes
via `setSize()`. Verify no Vue-mode features depend on this growth path.
- The `useLayoutSync` change: `liteNode.size[0] = ...` modifies `_size`
via the getter without triggering the setter, avoiding the Canvas-source
bounce. `onResize` is still called. Verify no downstream code relies on
the setter side effects when syncing from layout store.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9177-fix-prevent-infinite-node-resize-loop-in-Vue-mode-3116d73d365081e4ad88f1cfad51df18)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-02-24 21:12:16 +00:00
Comfy Org PR Bot
87341f2c6e 1.41.4 (#9139)
Patch version increment to 1.41.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9139-1-41-4-3116d73d3650813a9061e5695a844233)
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-24 03:40:20 -08:00
Dante
02a38110cd feat: audio drag-drop and paste support (#9152) 2026-02-24 18:59:57 +09:00
jaeone94
09989b7aff [refactor] Extract manager composables and execution utils (#9163)
## Summary
Extracts inline logic from manager components into dedicated composables
and utilities, and adds a cyclic subgraph fix.

## Changes
- **`usePackInstall`**: New composable extracted from
`PackInstallButton.vue` — handles conflict detection, payload
construction, and `Promise.allSettled`-based batch installation
- **`useApplyChanges`**: New shared composable extracted from
`ManagerProgressToast.vue` — manages ComfyUI restart flow with reconnect
timeout and post-reconnect refresh
- **`executionIdUtil`**: New utility (`getAncestorExecutionIds`,
`getParentExecutionIds`, `buildSubgraphExecutionPaths`) with unit tests;
fixes infinite recursion on cyclic subgraph definitions

## Review Focus
- `useApplyChanges` reconnect timeout (2 min) and setting restore logic
- `buildSubgraphExecutionPaths` visited-set guard for cyclic subgraph
defs

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9163-refactor-Extract-manager-composables-and-execution-utils-3116d73d365081f293d3d5484775ad48)
by [Unito](https://www.unito.io)
2026-02-24 01:39:44 -08:00
Hunter
0f455c73bb fix: sync DOM widget values to widgetValueStore on registration (#9166)
## Summary

Override `setNodeId` in `BaseDOMWidgetImpl` to sync the DOM-resolved
value into the widget value store, fixing empty system prompts in Vue
nodes (Nodes 2.0).

## Changes

- **What**: DOM widgets (e.g. textarea for Gemini system_prompt) resolve
their value through `options.getValue()` / DOM elements, not
`_state.value`. When `BaseWidget.setNodeId` registers with the store, it
spreads `_state.value` which is `undefined` for DOM widgets. The
override captures the DOM-resolved value before registration and syncs
it into the store afterward — keeping the fix in the DOM widget layer
where the mismatch originates, leaving `BaseWidget` unchanged.

## Review Focus

- Whether capturing `this.value` before `super.setNodeId()` and writing
it after is the right sequencing
- Whether this correctly handles all DOM widget subtypes
(`DOMWidgetImpl`, `ComponentWidgetImpl`)

Supersedes #9164

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9166-fix-sync-DOM-widget-values-to-widgetValueStore-on-registration-3116d73d3650816f8cece866a9272baa)
by [Unito](https://www.unito.io)
2026-02-24 01:05:44 -08:00
Christian Byrne
a94574d379 fix: open image in new tab on cloud fetches as blob to avoid GCS auto-download (#9122)
## Summary

Fix "Open Image" on cloud opening a new tab that auto-downloads the
asset instead of displaying it inline.

## Changes

- **What**: Add `openFileInNewTab()` to `downloadUtil.ts` that fetches
cross-origin URLs as blobs before opening in a new tab, avoiding GCS
`Content-Disposition: attachment` redirects. Opens the blank tab
synchronously to preserve user-gesture activation (avoiding popup
blockers), then navigates to a blob URL once the fetch completes. Blob
URLs are revoked after 60s or immediately if the tab was closed. Update
both call sites (`useImageMenuOptions` and `litegraphService`) to use
the new function.

## Review Focus

- The synchronous `window.open('', '_blank')` before the async fetch is
intentional to preserve user-gesture context and avoid popup blockers.
- Blob URL revocation strategy: 60s timeout for successful opens,
immediate revoke if tab was closed, tab closed on fetch failure.
- Shared `fetchAsBlob()` helper is also used by the existing
`downloadViaBlobFetch`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9122-fix-open-image-in-new-tab-on-cloud-fetches-as-blob-to-avoid-GCS-auto-download-3106d73d365081a3bfa6eb7d77fde99f)
by [Unito](https://www.unito.io)
2026-02-23 21:28:16 -08:00
Johnpaul Chiwetelu
78fe639540 feat: periodically re-poll queue progress state (#9136)
## Summary
- Add `useQueuePolling` composable that polls `queueStore.update()`
every 5s while jobs are active
- Calls `update()` immediately on creation so the UI is current after
page reload
- Uses `useIntervalFn` + `watch` pattern (same as `assetDownloadStore`)
to pause/resume based on `activeJobsCount`

## Related Issue
- Related to #8136

## QA
- Queue a prompt, reload page mid-execution, verify queue UI updates
every ~5s
- Verify polling stops when queue empties
- Verify polling resumes when new jobs are queued

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9136-feat-periodically-re-poll-queue-progress-state-3106d73d36508119a32fc5b9c8eda21c)
by [Unito](https://www.unito.io)
2026-02-23 21:27:24 -08:00
Terry Jia
e333ad459e feat: add CurveEditor component (#8860)
## Summary
Prerequisite for upcoming native color correction nodes (ColorCurves).

Reusable curve editor with monotone cubic Hermite interpolation,
drag-to-add/move/delete control points, and SVG-based rendering.
Includes CurvePoint type, LUT generation utility, and useCurveEditor
composable for interaction logic.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/948352c7-bdf2-40f9-a8f0-35bc2b2f3202

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8860-feat-add-CurveEditor-component-and-d3-shape-dependency-3076d73d3650817f8421f98e349569d0)
by [Unito](https://www.unito.io)
2026-02-23 21:19:06 -08:00
Johnpaul Chiwetelu
aab09b5f99 feat: allow custom event names in ComfyApi.addEventListener (#9140)
## Summary
- Add string fallback overloads to `addEventListener` and
`removeEventListener` on `ComfyApi`
- Extensions can now listen for custom event names without TypeScript
rejecting unknown event names
- Known events still get full type safety via the generic overload;
unknown strings fall through to `CustomEvent`

## Related Issue
- Fixes #2088

## QA
- Verify existing typed event listeners (e.g.
`api.addEventListener('status', ...)`) still infer correct types
- Verify custom event names (e.g.
`api.addEventListener('my-custom-event', ...)`) compile without errors

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9140-feat-allow-custom-event-names-in-ComfyApi-addEventListener-3116d73d36508128aad3fab98c34fac3)
by [Unito](https://www.unito.io)
2026-02-23 21:17:35 -08:00
Johnpaul Chiwetelu
1f0ca18737 fix: refresh image previews on media upload nodes when refreshing node definitions (#9141)
## Summary
- When pressing `R` to refresh node definitions, image previews on
LoadImage/LoadVideo nodes now update to reflect external file changes
- Re-triggers the combo widget callback to regenerate preview URLs with
a fresh cache-busting `&rand=` parameter
- Extracts `isMediaUploadComboInput` from `uploadImage.ts` to
`nodeDefSchema.ts` as a shared utility

- Fixes #2082



https://github.com/user-attachments/assets/d18d69ae-6ecd-448d-8d7c-76b2c49fdea5



## Test plan
- [ ] Open a workflow with a LoadImage node and select an image
- [ ] Edit and save the image externally (e.g. in an image editor)
- [ ] Press `R` to refresh node definitions
- [ ] Verify the preview updates to show the edited image
2026-02-23 20:57:24 -08:00
Terry Jia
3b5649232d feat: add batch image navigation to ImageCompare node (#9151)
## Summary

Add batch image navigation to the ImageCompare node so users can browse
all images in a batch instead of only seeing the first one.
## Changes
The backend already returns all batch images in a_images/b_images
arrays, but the frontend only used index [0]. Now all images are mapped
to URLs and a navigation bar with prev/next controls appears above the
comparison slider when either side has more than one image. A/B sides
navigate independently. Extracted a reusable BatchNavigation component
for the index selector UI.

fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/9098

## Screenshots (if applicable)


https://github.com/user-attachments/assets/a801cc96-9182-4b0d-a342-4e6107290f47

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9151-feat-add-batch-image-navigation-to-ImageCompare-node-3116d73d365081498be6d401773619a3)
by [Unito](https://www.unito.io)
2026-02-23 23:56:46 -05:00
Johnpaul Chiwetelu
724827d8cc refactor: replace withDefaults with Vue 3.5 props destructuring (#9150)
## Summary
- Replace all `withDefaults(defineProps<...>())` with Vue 3.5 reactive
props destructuring across 14 components
- Update `props.xxx` references to use destructured variables directly
in script and template

- Fixes #2334

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9150-refactor-replace-withDefaults-with-Vue-3-5-props-destructuring-3116d73d365081e7a721db3369600671)
by [Unito](https://www.unito.io)
2026-02-23 20:30:44 -08:00
Jin Yi
be70f6c1e6 [refactor] Merge sort button into view settings popover (#9143) 2026-02-24 13:18:07 +09:00
Christian Byrne
514425b560 fix: use getAuthHeader for API key auth in subscription/billing (#9142)
## Summary

Fix "User not authenticated" errors when API key users
(desktop/portable) trigger subscription status checks or billing
operations.

## Changes

- **What**: Replace `getFirebaseAuthHeader()` with `getAuthHeader()` in
subscription and billing call sites (`fetchSubscriptionStatus`,
`initiateSubscriptionCheckout`, `fetchBalance`, `addCredits`,
`accessBillingPortal`, `performSubscriptionCheckout`). `getAuthHeader()`
supports the full auth fallback chain (workspace token → Firebase token
→ API key), whereas `getFirebaseAuthHeader()` returns null for API key
users since they bypass Firebase entirely. Also add an `isCloud` guard
to the subscription status watcher so non-cloud environments skip
subscription checks.

## Review Focus

- The `isCloud` guard on the watcher ensures local/desktop users never
hit the subscription endpoint. This was the originally intended design
per code owner confirmation.
- `getAuthHeader()` already exists in `firebaseAuthStore` with proper
fallback logic — no new auth code was added.

Fixes
https://www.notion.so/comfy-org/Bug-Subscription-status-check-occurring-in-non-cloud-environments-causing-authentication-errors-3116d73d365081738b21db157e88a9ed

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9142-fix-use-getAuthHeader-for-API-key-auth-in-subscription-billing-3116d73d3650817fa345deaddc8c3fcd)
by [Unito](https://www.unito.io)
2026-02-23 18:49:32 -08:00
Alexander Brown
c25f9a0e93 feat: synthetic widgets getter for SubgraphNode (proxy-widget-v2) (#8856)
## Summary

Replace the Proxy-based proxy widget system with a store-driven
architecture where `promotionStore` and `widgetValueStore` are the
single sources of truth for subgraph widget promotion and widget values,
and `SubgraphNode.widgets` is a synthetic getter composing lightweight
`PromotedWidgetView` objects from store state.

## Motivation

The subgraph widget promotion system previously scattered state across
multiple unsynchronized layers:

- **Persistence**: `node.properties.proxyWidgets` (tuples on the
LiteGraph node)
- **Runtime**: Proxy-based `proxyWidget.ts` with `Overlay` objects,
`DisconnectedWidget` singleton, and `isProxyWidget` type guards
- **UI**: Each Vue component independently calling `parseProxyWidgets()`
via `customRef` hacks
- **Mutation flags**: Imperative `widget.promoted = true/false` set on
`subgraph-opened` events

This led to 4+ independent parsings of the same data, complex cache
invalidation, and no reactive contract between the promotion state and
the rendering layer. Widget values were similarly owned by LiteGraph
with no Vue-reactive backing.

The core principle driving these changes: **Vue owns truth**. Pinia
stores are the canonical source; LiteGraph objects delegate to stores
via getters/setters; Vue components react to store state directly.

## Changes

### New stores (single sources of truth)

- **`promotionStore`** — Reactive `Map<NodeId, PromotionEntry[]>`
tracking which interior widgets are promoted on which SubgraphNode
instances. Graph-scoped by root graph ID to prevent cross-workflow state
collision. Replaces `properties.proxyWidgets` parsing, `customRef`
hacks, `widget.promoted` mutation, and the `subgraph-opened` event
listener.
- **`widgetValueStore`** — Graph-scoped `Map<WidgetKey, WidgetState>`
that is the canonical owner of widget values. `BaseWidget.value`
delegates to this store via getter/setter when a node ID is assigned.
Eliminates the need for Proxy-based value forwarding.

### Synthetic widgets getter (SubgraphNode)

`SubgraphNode.widgets` is now a getter that reads
`promotionStore.getPromotions(rootGraphId, nodeId)` and returns cached
`PromotedWidgetView` objects. No stubs, no Proxies, no fake widgets
persisted in the array. The setter is a no-op — mutations go through
`promotionStore`.

### PromotedWidgetView

A class behind a `createPromotedWidgetView` factory, implementing the
`PromotedWidgetView` interface. Delegates value/type/options/drawing to
the resolved interior widget and stores. Owns positional state (`y`,
`computedHeight`) for canvas layout. Cached by
`PromotedWidgetViewManager` for object-identity stability across frames.

### DOM widget promotion

Promoted DOM widgets (textarea, image upload, etc.) render on the
SubgraphNode surface via `positionOverride` in `domWidgetStore`.
`DomWidgets.vue` checks for overrides and uses the SubgraphNode's
coordinates instead of the interior node's.

### Promoted previews

New `usePromotedPreviews` composable resolves image/audio/video preview
widgets from promoted entries, enabling SubgraphNodes to display
previews of interior preview nodes.

### Deleted

- `proxyWidget.ts` (257 lines) — Proxy handler, `Overlay`,
`newProxyWidget`, `isProxyWidget`
- `DisconnectedWidget.ts` (39 lines) — Singleton Proxy target
- `useValueTransform.ts` (32 lines) — Replaced by store delegation

### Key architectural changes

- `BaseWidget.value` getter/setter delegates to `widgetValueStore` when
node ID is set
- `LGraph.add()` reordered: `node.graph` assigned before widget
`setNodeId` (enables store registration)
- `LGraph.clear()` cleans up graph-scoped stores to prevent stale
entries across workflow switches
- `promotionStore` and `widgetValueStore` state nested under root graph
UUID for multi-workflow isolation
- `SubgraphNode.serialize()` writes promotions back to
`properties.proxyWidgets` for persistence compatibility
- Legacy `-1` promotion entries resolved and migrated on first load with
dev warning

## Test coverage

- **3,700+ lines of new/updated tests** across 36 test files
- **Unit**: `promotionStore.test.ts`, `widgetValueStore.test.ts`,
`promotedWidgetView.test.ts` (921 lines),
`subgraphNodePromotion.test.ts`, `proxyWidgetUtils.test.ts`,
`DomWidgets.test.ts`, `PromotedWidgetViewManager.test.ts`,
`usePromotedPreviews.test.ts`, `resolvePromotedWidget.test.ts`,
`subgraphPseudoWidgetCache.test.ts`
- **E2E**: `subgraphPromotion.spec.ts` (622 lines) — promote/demote,
manual/auto promotion, paste preservation, seed control augmentation,
image preview promotion; `imagePreview.spec.ts` extended with
multi-promoted-preview coverage
- **Fixtures**: 2 new subgraph workflow fixtures for preview promotion
scenarios

## Review focus

- Graph-scoped store keying (`rootGraphId`) — verify isolation across
workflows/tabs and cleanup on `LGraph.clear()`
- `PromotedWidgetView` positional stability — `_arrangeWidgets` writes
to `y`/`computedHeight` on cached objects; getter returns fresh array
but stable object references
- DOM widget position override lifecycle — overrides set on promote,
cleared on demote/removal/subgraph navigation
- Legacy `-1` entry migration — resolved and written back on first load;
unresolvable entries dropped with dev warning
- Serialization round-trip — `promotionStore` state →
`properties.proxyWidgets` on serialize, hydrated back on configure

## Diff breakdown (excluding lockfile)

- 153 files changed, ~7,500 insertions, ~1,900 deletions (excluding
pnpm-lock.yaml churn)
- ~3,700 lines are tests
- ~300 lines deleted (proxyWidget.ts, DisconnectedWidget.ts,
useValueTransform.ts)

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8856-feat-synthetic-widgets-getter-for-SubgraphNode-proxy-widget-v2-3076d73d365081c7b517f5ec7cb514f3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-23 13:33:41 -08:00
AustinMroz
d7546e68ef Add functionality to quickly disconnect moved input links (#7459)
Disconnections are frequently performed by dragging a link from an input
slot and dropping it on the canvas, but needing to wait for the
searchbox to pop up, and then needing to manually close out of this can
make it feel slow. Sometimes, this will even result in users disabling
the link release action for more responsive graph building.

Instead, this PR introduces new functionality where a link which is
moved only a short distance from a node input and dropped will be
immediately disconnected instead of performing the default link release
action.

![fast-disco_00001](https://github.com/user-attachments/assets/5b0795da-12c0-4347-8ea1-6fc1bcafaae2)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7459-Add-functionality-to-quickly-disconnect-moved-input-links-2c86d73d365081919052f3856db8e672)
by [Unito](https://www.unito.io)
2026-02-23 11:54:54 -08:00
pythongosssss
2634acdd8c App mode - builder toolbar save - 7 (#9030)
## Summary

Implements save flow for the builder toolbar.
The todo will be done in a future PR once the serailized format is
finalized

## Screenshots (if applicable)


https://github.com/user-attachments/assets/124cb7d8-e23b-476a-8691-0ee2c4c9150b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9030-App-mode-builder-toolbar-save-7-30d6d73d3650815e8610fced20e95e6e)
by [Unito](https://www.unito.io)
2026-02-23 10:57:52 -08:00
pythongosssss
b3a5317500 App mode - builder toolbar - 6 (#9029)
## Summary

A toolbar for builder mode, hides various UI elements when in builder
mode

## Screenshots (if applicable)

Toolbar
<img width="706" height="166" alt="image"
src="https://github.com/user-attachments/assets/1f0b08b5-1661-4ed5-96bb-feecc73ca701"
/>

With disabled save and output required popover
<img width="711" height="299" alt="image"
src="https://github.com/user-attachments/assets/4a93aaf8-d850-4afe-ab9f-4abd44a25420"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9029-App-mode-builder-toolbar-6-30d6d73d365081e3aef5c90033ba347d)
by [Unito](https://www.unito.io)
2026-02-23 10:48:07 -08:00
Christian Byrne
19dc48b69a docs: document PrimitiveNode copy/paste semantics and widgets_values loss (#9119)
## Summary

Documents the PrimitiveNode copy/paste bug mechanism and connection
lifecycle semantics in `WIDGET_SERIALIZATION.md`. This is tribal
knowledge from debugging
[#1757](https://github.com/Comfy-Org/ComfyUI_frontend/issues/1757) and
the related [Slack
discussion](https://comfy-organization.slack.com/archives/C09AQRB49QX/p1771806268469089).

## What's documented

- **The clone→serialize gap**: `_serializeItems()` calls
`item.clone()?.serialize()`. The clone has no `this.widgets`
(PrimitiveNode creates them on connection), so `serialize()` silently
drops `widgets_values`.
- **Why seed survives but control_after_generate doesn't**: Primary
widget value is copied from the target on reconnect; secondary widgets
read from `this.widgets_values` which was lost.
- **Current vs. proposed lifecycle**: Empty-on-copy → morph-on-connect
(current) vs. clone-configured-instance → empty-on-disconnect
(proposed).
- **Design considerations**: `input.widget` override flexibility,
deserialization ordering, and the minimal `serialize()` override fix.

## Related

- Issue: #1757
- Fix PR: #8938
- Companion: #9102 (initial WIDGET_SERIALIZATION.md), #9105 (type/JSDoc
improvements)
- Notion: COM-15282

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9119-docs-document-PrimitiveNode-copy-paste-semantics-and-widgets_values-loss-3106d73d3650816ba7f7d9e6f3bb3868)
by [Unito](https://www.unito.io)
2026-02-23 10:47:11 -08:00
pythongosssss
f811b173c6 App Mode - Progress/outputs - 5 (#9028)
## Summary

Adds new store for tracking outputs lin linear mode and reworks outputs
to display the following: skeleton -> latent preview -> node output ->
job result.

## Changes

- **What**: 
- New store for wrangling various events into a usable list of live
outputs
- Replace manual list with reka-ui list box
- Extract various composables

## Review Focus
Getting all the events and stores aligned to happen consistently and in
the correct order was a challenge, unifying the various sources. so
suggestions there would be good

## Screenshots (if applicable)


https://github.com/user-attachments/assets/13449780-ee48-4d9a-b3aa-51dca0a124c7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9028-App-Mode-Progress-outputs-5-30d6d73d3650817aaa9dee622fffe426)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-23 10:43:25 -08:00
pythongosssss
b29dbec916 App mode - landing/arrange screens - 4 (#9026)
## Summary

Updates messaging on app mode welcome pages and adds arrange view

## Screenshots (if applicable)

Build app view
<img width="628" height="470" alt="image"
src="https://github.com/user-attachments/assets/7f648769-1380-4093-8de9-414d05303b87"
/>

Run view
<img width="593" height="471" alt="image"
src="https://github.com/user-attachments/assets/cc81cfd0-fa89-4402-82f4-6bbdd0e7e2f9"
/>

Arrange view
<img width="1071" height="829" alt="image"
src="https://github.com/user-attachments/assets/ae8666ef-dff1-4fde-929e-89479c891261"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9026-App-mode-landing-arrange-screens-4-30d6d73d365081f4b209f34834a2ce5e)
by [Unito](https://www.unito.io)
2026-02-23 10:16:22 -08:00
pythongosssss
0f4bceafdd App mode - App mode toolbar - 3 (#9025)
## Summary

Adds a new toolbar for app mode

## Changes

- **What**:  Adds new toolbar with builder mode disabled

## Screenshots (if applicable)

<img width="172" height="220" alt="image"
src="https://github.com/user-attachments/assets/16f3cf61-bcbe-4b4d-a169-01c934140354"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9025-App-mode-App-mode-toolbar-3-30d6d73d365081549af6fdd1937b098f)
by [Unito](https://www.unito.io)
2026-02-23 10:05:52 -08:00
pythongosssss
d601aba721 App mode - Unify menus - 2 (#9023)
## Summary

Updates subgraph breadcrumbs menu, workflow tabs context menu & linear
mode menu to use a single implementation.
Adds new menu items for enter/exit app mode  
Hides menu when in builder mode

## Changes

- **What**: Changes the components to use either a reka-ui context menu
or dropdown, with a standard inner list
- **Breaking**: Remove existing linear toggle from sidebar as it is now
in the menu


## Screenshots (if applicable)
It looks basically identical other than the icon changes based on mode:

In Graph Mode:
<img width="261" height="497" alt="image"
src="https://github.com/user-attachments/assets/eb9968a2-b528-4e21-9e14-ab4a67e717ae"
/>

In App Mode:
<img width="254" height="499" alt="image"
src="https://github.com/user-attachments/assets/54a89fab-e7b2-4cb0-bcb7-43d6d076ac83"
/>

Right click tab:
<img width="321" height="564" alt="image"
src="https://github.com/user-attachments/assets/c12c7d64-2dba-45bb-be76-2615f3e38cc6"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9023-App-mode-Unify-menus-2-30d6d73d36508162bfc0e308d5f705de)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-23 09:49:52 -08:00
jaeone94
ddcfdb924d fix: fix error overlay and TabErrors filtering for nested subgraphs (#9129)
## Summary

Fix error overlays not showing on subgraph container nodes and nested
error cards not appearing in the Errors tab when a node inside a
subgraph fails.

## Changes

- **What**: Error overlay and Errors tab filtering now use full
hierarchical execution IDs (e.g. `65:70`) instead of local node IDs,
enabling correct ancestor detection at any nesting depth
- Added `getExecutionIdByNode` to
[graphTraversalUtil.ts](src/utils/graphTraversalUtil.ts) to compute a
node's full execution ID chain from the root graph
- Added `errorAncestorExecutionIds` computed set and
`isContainerWithInternalError(node)` helper to
[executionErrorStore.ts](src/stores/executionErrorStore.ts) for O(1)
container checks
- Updated `hasAnyError` in
[LGraphNode.vue](src/extensions/vueNodes/components/LGraphNode.vue) to
use the new store helper
- Fixed `isErrorInSelection` in
[useErrorGroups.ts](src/components/rightSidePanel/errors/useErrorGroups.ts)
to use full execution IDs for selected containers

## Review Focus

- `errorAncestorExecutionIds` is rebuilt reactively whenever the active
errors change — confirm this is efficient enough given typical error
counts
- `getExecutionIdByNode` walks up the graph hierarchy; verify the base
case (root graph, no parent) is handled correctly

## Screenshots


https://github.com/user-attachments/assets/b5be5892-80a9-4e5e-8b6f-fe754b4ebc4e



https://github.com/user-attachments/assets/92ff12b3-3bc9-4f02-ba4a-e2c7384bafe5



https://github.com/user-attachments/assets/be8e95be-ac8c-4699-9be9-b11902294bda



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9129-fix-fix-error-overlay-and-TabErrors-filtering-for-nested-subgraphs-3106d73d365081c1875bc1a3c89eae29)
by [Unito](https://www.unito.io)
2026-02-23 02:52:45 -08:00
pythongosssss
d6c902187d App mode - store - 1 (#9022)
## Summary

Adds a store to control the mode the app is in (app, graph, builder)
Changes the existing linearMode in canvasStore to update new store

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9022-App-mode-store-1-30d6d73d365081498df1c1192c9860f0)
by [Unito](https://www.unito.io)
2026-02-23 02:10:58 -08:00
jaeone94
dd8520020e feat(node): show Enter Subgraph and Error buttons side by side in node footer (#9126)
## Summary
Show the \"Enter Subgraph\" and \"Error\" buttons simultaneously in the
node footer when a subgraph node has errors, instead of only showing the
Error button.

## Changes
- **What**: Replaced the single mutually-exclusive `Button` in the node
footer with a flex container that can display both the \"Enter
Subgraph\" button and the \"Error\" button side by side, separated by a
divider
- **What**: When a subgraph node has errors, the Enter button shows a
short label \"Enter\" to fit alongside the Error button; otherwise it
shows the full \"Enter Subgraph\" label
- **What**: Advanced inputs toggle button is now explicitly restricted
to non-subgraph nodes (preserving existing behavior)
- **What**: Added `"enter": "Enter"` i18n key for the shortened subgraph
entry label

## Review Focus
- Layout behavior when both buttons are visible (subgraph node with
errors)
- Collapsed vs. expanded node states with the new flex container
- The `divide-x divide-component-node-border` divider between buttons

> Note: May need to backport to a stable release branch.

## Screenshot


https://github.com/user-attachments/assets/1eb4afe0-bf82-4677-ad86-6e15a9c0a487



- Fixes <!-- #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9126-feat-node-show-Enter-Subgraph-and-Error-buttons-side-by-side-in-node-footer-3106d73d3650818a9afdeb51813825d9)
by [Unito](https://www.unito.io)
2026-02-23 00:43:17 -08:00
Christian Byrne
a0e8f7d960 ci: skip unit and e2e tests for markdown-only changes (#9125)
## Summary

Skip unit and e2e tests when PRs only contain markdown file changes.

## Changes

- Add `paths-ignore: ['**/*.md']` to `push` and `pull_request` triggers
in ci-tests-unit.yaml and ci-tests-e2e.yaml
- Manual `workflow_dispatch` trigger preserved for e2e tests

## Testing

Create a PR with only `.md` changes to verify both test workflows are
skipped.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9125-ci-skip-unit-and-e2e-tests-for-markdown-only-changes-3106d73d365081bea0dcc06b608a87fc)
by [Unito](https://www.unito.io)
2026-02-22 20:53:17 -08:00
Johnpaul Chiwetelu
02e926471f fix: replace as-unknown-as casts with safer patterns (#9107)
## Summary

- Replace 83 `as unknown as` double casts with safer alternatives across
33 files
- Use `as Partial<X> as X` pattern where TypeScript allows it
- Create/reuse factory functions from `litegraphTestUtils.ts` for mock
objects
- Widen `getWorkflowDataFromFile` return type to include `ComfyMetadata`
directly
- Reduce total `as unknown as` count from ~153 to 71

The remaining 71 occurrences are genuinely necessary due to cross-schema
casts, generic variance, missing index signatures, Float64Array-to-tuple
conversions, and DOM type incompatibilities.

## Test plan

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] All affected unit tests pass

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9107-fix-replace-as-unknown-as-casts-with-safer-patterns-3106d73d3650815cb5bcd613ad635bd7)
by [Unito](https://www.unito.io)
2026-02-22 20:46:12 -08:00
Christian Byrne
8998d92e1b feat: add NightlySurveyPopover component for feature surveys (#9083)
## Summary

Adds NightlySurveyPopover component that displays a Typeform survey to
eligible nightly users after a configurable delay.

## Changes

- **What**: Vue component that uses `useSurveyEligibility` to show/hide
a survey popover with accept, dismiss, and opt-out actions. Loads
Typeform embed script dynamically with HTTPS and deduplication.

## Review Focus

- Typeform script injection security (HTTPS-only, load-once guard,
typeformId alphanumeric validation)
- Timeout lifecycle (clears pending timeout when eligibility changes)

## Part of Nightly Survey System

This is part 4 of a stacked PR chain:
1.  feat/feature-usage-tracker - useFeatureUsageTracker (merged in
#8189)
2.  feat/survey-eligibility - useSurveyEligibility (#8189, merged)
3.  feat/survey-config - surveyRegistry.ts (#8355, merged)
4. **feat/survey-popover** - NightlySurveyPopover.vue (this PR)
5. feat/survey-integration - NightlySurveyController.vue (#8480)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9083-feat-add-NightlySurveyPopover-component-for-feature-surveys-30f6d73d365081d1beb2f92555a4b2f4)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-02-22 20:20:09 -08:00
Comfy Org PR Bot
1267c4b9b3 1.41.3 (#9103)
Patch version increment to 1.41.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9103-1-41-3-3106d73d365081b9b4c7f402c860150e)
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-22 18:14:46 -08:00
Christian Byrne
ddc2159bed docs: clarify widget serialize vs options.serialize in types and JSDoc (#9105)
## Summary

Clarifies the two distinct `serialize` properties on widgets via
improved TypeScript types and JSDoc:

- **`IWidgetOptions.serialize`** — controls whether the widget value is
included in the **API prompt** sent for execution
- **`IBaseWidget.serialize`** — controls whether the widget value is
persisted in the **workflow JSON** (`widgets_values`)

These two properties are easily confused. This PR adds cross-linking
JSDoc, explicit `@default` tags, and a clarifying comment at the check
site in `executionUtil.ts`.

## Changes

| File | Change |
|------|--------|
| `src/lib/litegraph/src/types/widgets.ts` | Add `serialize?: boolean`
to `IWidgetOptions` with JSDoc; expand JSDoc on `IBaseWidget.serialize`
|
| `src/utils/executionUtil.ts` | Clarifying comment at the
`widget.options.serialize` check |
| `src/types/metadataTypes.ts` | Connect `ComfyMetadataTags` enum values
to their corresponding serialize properties |

## Related

- Companion doc: #9102 (`WIDGET_SERIALIZATION.md`)
- Issue: #1757

## Verification

- `vue-tsc --noEmit` passes clean
- `eslint` passes clean on all 3 files
- No runtime changes — JSDoc and types only

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9105-docs-clarify-widget-serialize-vs-options-serialize-in-types-and-JSDoc-3106d73d36508155b618ee56cf18f969)
by [Unito](https://www.unito.io)
2026-02-22 18:13:48 -08:00
sno
5687b44422 Add CI validation for OSS assets (fonts and licenses) (#8828)
## Summary
Adds CI workflow to validate OSS build compliance by checking for
proprietary fonts and non-approved dependency licenses.

## Context
- Part of comprehensive OSS compliance effort (split from closed PR
#6777)
- Uses simple bash/grep approach following proven #8623 pattern
- Complements telemetry checking in PR #8826 and existing #8354

## Implementation

### Font Validation
- Scans dist/ for proprietary ABCROM fonts (.woff, .woff2, .ttf, .otf)
- Fails if any ABCROM fonts found in OSS builds
- Provides clear fix instructions

### License Validation  
- Uses `license-checker` npm package
- Validates all production dependencies
- Only allows OSI-approved licenses:
  - MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC
  - 0BSD, BlueOak-1.0.0, Python-2.0, CC0-1.0
  - Unlicense, CC-BY-4.0, CC-BY-3.0
  - Common dual-license combinations

### Workflow Details
- Two separate jobs for parallel execution
- Runs on PRs and pushes to main/dev
- Builds with `DISTRIBUTION=localhost` for OSS mode
- Clear error messages with remediation steps

## Testing
- [ ] Font check passes on current main (no ABCROM fonts in dist)
- [ ] License check passes on current main (all approved licenses)
- [ ] Intentional violation testing

## Related
- Supersedes remaining parts of closed PR #6777
- Complements PR #8826 (Mixpanel telemetry)
- Follows pattern from PR #8623 (simple bash/grep)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8828-Add-CI-validation-for-OSS-assets-fonts-and-licenses-3056d73d3650812390d5d91ca2f319fc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-02-22 17:35:19 -08:00
Christian Byrne
26fa84ce1b docs: add widget serialization reference (widget.serialize vs widget.options.serialize) (#9102)
Documents the distinction between `widget.serialize` (workflow
persistence, checked by `LGraphNode.serialize()`) and
`widget.options.serialize` (API prompt, checked by `executionUtil.ts`).

These share a property name but live at different levels of the widget
object and are consumed by different code paths — a common source of
confusion when debugging serialization bugs.

Includes:
- Explanation of both properties with code references
- Permutation table of the 4 possible combinations with real examples
- Gotchas section covering the `addWidget` options bag behavior and
`PrimitiveNode` dynamic widgets
- Reference added to `src/lib/litegraph/AGENTS.md`

Context: discovered while debugging #1757 (PrimitiveNode
`control_after_generate` lost on copy-paste).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9102-docs-add-widget-serialization-reference-widget-serialize-vs-widget-options-serialize-30f6d73d365081cd86add44bdaa20d30)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-22 16:40:43 -08:00
Benjamin Lu
e37bba7250 fix: use AssetsListItem in queue overlay expanded (#9055)
## Summary
- replace `JobGroupsList` with `JobAssetsList` in `QueueOverlayExpanded`
- standardize expanded queue rows to use `AssetsListItem`
- add `QueueOverlayExpanded` tests to verify list rendering and action
re-emits

It works surprisingly well.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9055-fix-use-AssetsListItem-in-queue-overlay-expanded-30e6d73d365081c586c7c7bca222c290)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-22 02:02:04 -08:00
Benjamin Lu
35f15d18b4 feat: add job history and assets sidebar badge behavior (#9050)
## Summary
Add sidebar badge behavior for queue/asset visibility updates:
- Job History tab icon shows active jobs count (`queued + running`) only
when the Job History panel is closed.
- Assets tab icon no longer mirrors active jobs; when QPO V2 is enabled
it now shows the number of assets added since the last time Assets was
opened.
- Opening Assets clears the unseen added-assets badge count.

## Changes
- Added `iconBadge` logic to Job History sidebar tab.
- Replaced Assets sidebar badge source with new unseen-assets counter
logic.
- Added `assetsSidebarBadgeStore` to track unseen asset additions from
history updates and reset on Assets open.
- Added/updated unit tests for both sidebar tab composables and the new
store behavior.


https://github.com/user-attachments/assets/33588a2a-c607-4fcc-8221-e7f11c3d79cc



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9050-fix-add-job-history-and-assets-sidebar-badge-behavior-30e6d73d365081c38297fe6aac9cd34c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-22 01:05:39 -08:00
Comfy Org PR Bot
a82c984520 1.41.2 (#9089)
Patch version increment to 1.41.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9089-1-41-2-30f6d73d365081beb90de78b0571f396)
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-22 01:05:31 -08:00
Christian Byrne
54f13930a4 feat: add display name mappings for Essentials tab nodes (#9072)
## Summary

Add frontend-only display name mappings for nodes shown in the
Essentials tab, plus parse the new `essentials_category` field from the
backend.

## Changes

- **What**: Created `src/constants/essentialsDisplayNames.ts` with a
static mapping of node names to user-friendly display names (e.g.
`CLIPTextEncode` → "Text", `ImageScale` → "Resize Image"). Regular nodes
use exact name matching; blueprint nodes use prefix matching since their
filenames include model-specific suffixes. Integrated into
`NodeLibrarySidebarTab.vue`'s `renderedRoot` computed for leaf node
labels with fallback to `display_name`. Added `essentials_category`
(z.string().optional()) to the node def schema and `ComfyNodeDefImpl` to
parse the field already sent by the backend (PR #12357).

## Review Focus

Display names are resolved only in the Essentials tab tree view
(`NodeLibrarySidebarTab.vue`), not globally, to avoid side effects on
search, bookmarks, or other views. Blueprint prefix matching is ordered
longest-first so more specific prefixes (e.g. `image_inpainting_`) match
before shorter ones (e.g. `image_edit`).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9072-feat-add-display-name-mappings-for-Essentials-tab-nodes-30f6d73d3650817c9acdc9b0315ed0be)
by [Unito](https://www.unito.io)
2026-02-22 01:03:15 -08:00
Benjamin Lu
c972dca61e fix: remove duplicate running indicator from queue header (#9032)
## Summary
Remove the extra running-workflow indicator from the expanded Queue
Progress Overlay header to avoid duplicate running-count signals.

## Changes
- Remove `showConcurrentIndicator` and `concurrentWorkflowCount` props
from `QueueOverlayHeader`.
- Stop passing those props through `QueueOverlayExpanded` and
`QueueProgressOverlay`.
- Simplify `QueueOverlayHeader` tests to reflect the updated header
behavior.

## Why
The expanded header was showing redundant running status information
alongside existing running/queued summaries.

Prevent this:
<img width="240" height="92" alt="image"
src="https://github.com/user-attachments/assets/f4b1775c-b347-46f7-8668-3a1054365ada"
/>

Design:
https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=4024-28147&m=dev

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9032-fix-remove-duplicate-running-indicator-from-queue-header-30d6d73d365081d19041de2f1b0c8886)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-22 00:40:43 -08:00
Benjamin Lu
cf8952e205 fix: keep queue overlay clear action static (#9031)
## Summary
Make the Queue Progress Overlay header clear action label static text
(`Clear queue`) and remove queued-count text from that header area.

## Changes
- Replace dynamic header text (`{count} queued`) with static `Clear
queue` text.
- Keep the existing icon clear button behavior/style in the header.
- Keep the clear action visible when queue is empty, but disabled.
- Add disabled visual treatment for the static text when queue is empty.
- Update `QueueOverlayHeader` tests to cover the new static label and
disabled behavior.

<img width="630" height="103" alt="image"
src="https://github.com/user-attachments/assets/0a5870fc-2ad6-4241-a12d-f8c4ef72a5fa"
/>


Design:
https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=4024-28153&m=dev

<img width="528" height="105" alt="image"
src="https://github.com/user-attachments/assets/3d03a4ac-a0f1-449d-afc7-b9a6cb1c8820"
/>
<img width="529" height="124" alt="image"
src="https://github.com/user-attachments/assets/7452cd17-e388-4b6a-b164-fce0b0d55ca1"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9031-fix-keep-queue-overlay-clear-action-static-30d6d73d365081f8b908fa54775ab4d4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-22 00:25:51 -08:00
Christian Byrne
1dcaf5d0dc perf: virtualize FormDropdownMenu to reduce DOM nodes and image requests (#8476)
## Summary

Virtualize the FormDropdownMenu to only render visible items, fixing
slow dropdown performance on cloud.

## Changes

- Integrate `VirtualGrid` into `FormDropdownMenu` for virtualized
rendering
- Add computed properties for grid configuration per layout mode
(grid/list/list-small)
- Extend `VirtualGrid` slot to provide original item index for O(1)
lookups
- Change container from `max-h-[640px]` to fixed `h-[640px]` for proper
virtualization

## Review Focus

- VirtualGrid integration within the popover context
- Layout mode switching with `:key="layoutMode"` to force re-render
- Grid style computed properties match original Tailwind classes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8476-perf-virtualize-FormDropdownMenu-to-reduce-DOM-nodes-and-image-requests-2f86d73d365081b3a79dd5e0b84df944)
by [Unito](https://www.unito.io)


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

* **New Features**
* Dropdowns now render with a virtualized grid/list (stable indexes,
responsive sizing) and show an empty-state icon when no items exist.

* **Bug Fixes**
* Reduced layout shift and rendering glitches with improved
spacer/scroll calculations and more reliable media measurement.

* **Style**
* Simplified media rendering (standard img/video), unified item visuals
and hover/background behavior.

* **Tests**
* Added unit and end-to-end tests for virtualization, indexing, layouts,
dynamic updates, and empty states.

* **Breaking Changes**
* Dropdown item/selection shapes and related component props/events were
updated (adapter changes may be required).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-21 22:54:19 -08:00
Christian Byrne
f707098f05 fix: subgraph unpacking creates extra link to seed widget (#9046)
## Summary

Fix subgraph unpacking creating spurious links to widget inputs (e.g.
seed) when the subgraph contains ComfySwitchNode with duplicate internal
links.

## Changes

- **What**: Two fixes in `_unpackSubgraphImpl`:
1. Strip links from serialized node data **before** `configure()` so
`onConnectionsChange` doesn't resolve subgraph-internal link IDs against
the parent graph's link map (which may contain unrelated links with
colliding numeric IDs).
2. Deduplicate links by `(origin, origin_slot, target, target_slot)`
before reconnecting, preventing repeated disconnect/reconnect cycles on
widget inputs that cause slot index drift.

## Review Focus

- The link-stripping before `configure()` mirrors what
`LGraphNode.clone()` already does — nodes should be configured without
stale link references when links will be recreated separately.
- Deduplication is defensive against malformed subgraph data; the
duplicate links in the reproduction workflow likely originated from a
prior serialization bug.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9046-fix-subgraph-unpacking-creates-extra-link-to-seed-widget-30e6d73d36508125a5fefa1309485516)
by [Unito](https://www.unito.io)
2026-02-21 22:38:05 -08:00
Christian Byrne
d2917be3a7 feat: support custom descriptions for subgraph tooltips (#9003)
## Summary

Adds support for custom descriptions on subgraph nodes that display as
tooltips when hovering.

## Changes

- Add optional `description` field to `ExportedSubgraph` interface and
`Subgraph` class
- Use description with fallback to default string in
`subgraphService.createNodeDef()`
- Add `description` to `SubgraphDefinitionBase` interface and Zod schema
for validation

## Review Focus

- Backwards compatibility: undefined description falls back to `Subgraph
node for ${name}`
- Serialization pattern: conditional spread `...(this.description && {
description })`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9003-feat-support-custom-descriptions-for-subgraph-tooltips-30d6d73d36508129bd75c77eb1c31cfb)
by [Unito](https://www.unito.io)
2026-02-21 22:29:28 -08:00
Christian Byrne
2639248867 fix: replace PrimeVue FloatLabel in WidgetTextarea with CSS-only IFTA label (#9076)
## Summary

Replace PrimeVue `FloatLabel` + `Textarea` in `WidgetTextarea` with a
CSS-only IFTA label and a new shadcn-vue Textarea component, fixing the
label-obscures-content bug.

<img width="965" height="754" alt="image"
src="https://github.com/user-attachments/assets/cab98527-834c-496d-a0ef-942fb21fd862"
/>


## Changes

- **What**: Add `src/components/ui/textarea/Textarea.vue` — thin wrapper
around native `<textarea>` with `cn()` class merging and `defineModel`.
Rewrite `WidgetTextarea.vue` to use a plain `<div>` wrapper with an
absolutely-positioned label and the new Textarea, replacing PrimeVue's
`FloatLabel variant="in"`. Add Storybook stories (Default, Disabled,
WithLabel). Update tests to remove PrimeVue plugin setup.

## Review Focus

- The label uses `absolute left-3 top-1.5 z-10 text-xxs` positioning —
verify it clears textarea content with `pt-5` padding
- `filteredProps` forwards widget options to a native textarea via
`v-bind="restAttrs"` — unknown attrs are silently ignored by the browser

Supersedes #8536

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9076-fix-replace-PrimeVue-FloatLabel-in-WidgetTextarea-with-CSS-only-IFTA-label-30f6d73d3650816fabe5ee30de0c793e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-21 22:09:56 -08:00
Christian Byrne
b41f162607 dx: compact storybook PR comment to single-line header (#9078)
## Summary

Compact the Storybook build status PR comment to a single-line header
with collapsible details, matching the approach from #8677.

## Changes

- **Starting**: Collapsed from multi-line with build steps to `## 🎨
Storybook:  Building...`
- **Completed (success)**: `## 🎨 Storybook:  Built — [View
Storybook](url)` with timestamp/links in `<details>`
- **Completed (failure)**: `## 🎨 Storybook:  Failed` with details
collapsed
- Removed version-bump branch check (starting comment no longer varies
by branch type)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9078-dx-compact-storybook-PR-comment-to-single-line-header-30f6d73d365081b98666c48a94542b70)
by [Unito](https://www.unito.io)
2026-02-21 21:45:25 -08:00
Christian Byrne
3678e65bec feat: add feature flag to disable Essentials tab in node library (#9067)
## Summary

Add a `node_library_essentials_enabled` feature flag to gate the
Essentials tab, allowing the rest of the new node library to ship while
the Essentials tab is finalized.

## Changes

- **What**: New feature flag (`node_library_essentials_enabled`) that
hides the Essentials tab in the node library sidebar and search category
sidebar. Defaults to `true` in dev/nightly builds, `false` in
production. Overridable via remote config or server feature flags. Falls
back to the "All" tab if a user previously had Essentials selected.

**Disabled UI**

<img width="547" height="782" alt="image"
src="https://github.com/user-attachments/assets/bcfcecd4-cbae-4d7b-9bcc-64bdf57929e2"
/>

**Enabled UI**

<img width="547" height="782" alt="image"
src="https://github.com/user-attachments/assets/0fb030ea-3bde-475e-982b-45e8f190cb8f"
/>


## Review Focus

- Feature flag pattern follows existing conventions (e.g.
`linearToggleEnabled`)
- Fallback behavior when essentials tab was previously selected by user

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9067-feat-add-feature-flag-to-disable-Essentials-tab-in-node-library-30e6d73d36508103b3cad9fc5d260611)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-21 21:40:45 -08:00
Christian Byrne
16ddcfdbaf feat: add toolkit node tracking to execution telemetry (#9073)
## Summary

Add toolkit (Essentials) node tracking to execution telemetry, enabling
measurement of toolkit node adoption and popularity.

## Changes

- **What**: Add `has_toolkit_nodes`, `toolkit_node_names`, and
`toolkit_node_count` fields to `ExecutionContext` and
`RunButtonProperties`. Toolkit nodes are identified via a hardcoded set
of node type names (10 novel Essentials nodes) and by `python_module ===
'comfy_essentials'` for blueprint nodes. Detection runs inside the
existing `reduceAllNodes()` traversal — no additional graph walks.

## Review Focus

- Toolkit node identification is frontend-only (no backend flag) — uses
two mechanisms: hardcoded `TOOLKIT_NODE_NAMES` set and
`TOOLKIT_BLUEPRINT_MODULES` for blueprints
- API node overlap is intentional — a node can appear in both
`api_node_names` and `toolkit_node_names`
- Blueprint detection via `python_module` automatically picks up new
essentials blueprints without code changes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9073-feat-add-toolkit-node-tracking-to-execution-telemetry-30f6d73d365081b3ac91e697889c58b6)
by [Unito](https://www.unito.io)
2026-02-21 21:31:09 -08:00
Christian Byrne
ef5198be25 fix: invalidate loader node dropdown cache after model asset deletion (#8434)
## Summary

When deleting a model asset (checkpoint, lora, etc.), the loader node
dropdowns now update correctly by invalidating the category-keyed cache.

## Problem

After deleting a model asset in the asset browser, the loader node
dropdowns (e.g., CheckpointLoaderSimple, LoraLoader) still showed the
deleted model. Users had to refresh or re-open the dropdown to see the
updated list.

## Solution

After successful asset deletion, check each deleted asset's tags for
model categories (checkpoints, loras, etc.) and call
`assetsStore.invalidateCategory()` for each affected category. This
triggers a refetch when the dropdown is next accessed.

## Changes

- In `useMediaAssetActions.ts`:
  - After deletion, iterate through deleted assets' tags
- Check if each tag corresponds to a model category using
`modelToNodeStore.getAllNodeProviders()`
  - Call `invalidateCategory()` for each affected category

- In `useMediaAssetActions.test.ts`:
  - Added mocks for `useAssetsStore` and `useModelToNodeStore`
  - Added tests for deletion invalidation behavior

## Testing

- Added unit tests verifying:
  - Model cache is invalidated when deleting model assets
  - Multiple categories are invalidated when deleting multiple assets
  - Non-model assets (input, output) don't trigger invalidation

## Part of Stack

This is **PR 2 of 2** in a stacked PR series:
1. **[PR 1](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8433)**:
Refactor asset cache to category-keyed (architectural improvement)
2. **This PR**: Fix deletion invalidation using the clean architecture

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8434-fix-invalidate-loader-node-dropdown-cache-after-model-asset-deletion-2f76d73d3650813181aedc373d9799c6)
by [Unito](https://www.unito.io)


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

* **Bug Fixes**
* Improved model cache invalidation after asset deletions — only
relevant model categories are invalidated and non-model assets are
ignored.
* Fixed edge-rendering behavior so reroutes are cleared correctly in the
canvas.

* **Chores**
* Added category-aware cache management and targeted refreshes for model
assets.

* **Tests**
* Expanded tests for cache invalidation, category handling, workflow
interactions, and related mocks.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-21 20:55:32 -08:00
Dante
38675e658f feat: add dev-time feature flag overrides via localStorage (#9075)
## Summary
- Adds `localStorage`-based dev-time override for feature flags, with
`ff:` key prefix (e.g.
`localStorage.setItem('ff:team_workspaces_enabled', 'true')`)
- Override priority: dev localStorage > remoteConfig >
serverFeatureFlags
- Guarded by `import.meta.env.DEV` — tree-shaken to empty function in
production builds
- Extracts `resolveFlag` helper in `useFeatureFlags` to eliminate
repeated fallback pattern

Fixes #9054

## Test plan
- [x] `getDevOverride` unit tests: boolean/number/string/object parsing,
prefix isolation, invalid JSON warning
- [x] `api.getServerFeature` / `serverSupportsFeature` override tests
- [x] `useFeatureFlags` override priority tests, including
`teamWorkspacesEnabled` bypassing guards
- [x] Production build verified: `getDevOverride` compiles to empty
function body, localStorage never accessed
- [x] `pnpm typecheck`, `pnpm lint` clean

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9075-feat-add-dev-time-feature-flag-overrides-via-localStorage-30f6d73d365081b394d3ccc461987b1a)
by [Unito](https://www.unito.io)
2026-02-21 20:36:09 -08:00
Dante
bd95150f82 test: remove unused DebugHelper from browser_tests (#9017)
## Summary

- Remove unused `DebugHelper` class and its import from browser test
fixtures

Fixes #8581

## Test plan

- [x] Verify browser tests still pass without `DebugHelper`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9017-test-remove-unused-DebugHelper-from-browser_tests-30d6d73d36508158b8c9fc83670df4f3)
by [Unito](https://www.unito.io)
2026-02-22 11:52:54 +09:00
661 changed files with 42189 additions and 7567 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]

View File

@@ -26,6 +26,14 @@ jobs:
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Detect browser_tests changes
id: changed-paths
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
browser_tests:
- 'browser_tests/**'
- name: Run ESLint with auto-fix
run: pnpm lint:fix
@@ -60,6 +68,10 @@ jobs:
pnpm format:check
pnpm knip
- name: Typecheck browser tests
if: steps.changed-paths.outputs.browser_tests == 'true'
run: pnpm typecheck:browser
- name: Comment on PR about auto-fix
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true

View File

@@ -0,0 +1,118 @@
name: 'CI: OSS Assets Validation'
on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
push:
branches: [main, dev*]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
validate-fonts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project
run: pnpm build
env:
DISTRIBUTION: localhost
- name: Check for proprietary fonts in dist
run: |
set -euo pipefail
echo '🔍 Checking dist for proprietary ABCROM fonts...'
if [ ! -d "dist" ] || [ -z "$(ls -A dist)" ]; then
echo '❌ ERROR: dist/ directory missing or empty!'
exit 1
fi
# Check for ABCROM font files
if find dist/ -type f -iname '*abcrom*' \
\( -name '*.woff' -o -name '*.woff2' -o -name '*.ttf' -o -name '*.otf' \) \
-print -quit | grep -q .; then
echo ''
echo '❌ ERROR: Found proprietary ABCROM font files in dist!'
echo ''
find dist/ -type f -iname '*abcrom*' \
\( -name '*.woff' -o -name '*.woff2' -o -name '*.ttf' -o -name '*.otf' \)
echo ''
echo 'ABCROM fonts are proprietary and should not ship to OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use conditional font loading based on isCloud'
echo '2. Ensure fonts are dynamically imported, not bundled'
echo '3. Check vite config for font handling'
exit 1
fi
echo '✅ No proprietary fonts found in dist'
validate-licenses:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Validate production dependency licenses
run: |
set -euo pipefail
echo '🔍 Checking production dependency licenses...'
# Use license-checker-rseidelsohn (actively maintained fork, handles monorepos)
# Exclude internal @comfyorg packages from license check
# Run in if condition to capture exit code
if npx license-checker-rseidelsohn@4 \
--production \
--summary \
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
echo ''
echo '✅ All production dependency licenses are approved!'
else
echo ''
echo '❌ ERROR: Found dependencies with non-approved licenses!'
echo ''
echo 'To fix this:'
echo '1. Check the license of the problematic package'
echo '2. Find an alternative package with an approved license'
echo '3. If the license is safe and OSI-approved, add it to the --onlyAllow list'
echo ''
echo 'For more info on OSI-approved licenses:'
echo 'https://opensource.org/licenses'
exit 1
fi

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

@@ -6,9 +6,6 @@ on:
workflows: ['CI: Tests E2E']
types: [requested, completed]
env:
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -63,8 +60,7 @@ jobs:
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting" \
"$(date -u '${{ env.DATE_FORMAT }}')"
"starting"
- name: Download and Deploy Reports
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'

View File

@@ -4,8 +4,10 @@ name: 'CI: Tests E2E'
on:
push:
branches: [main, master, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
workflow_dispatch:
concurrency:
@@ -182,10 +184,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Get start time
id: start-time
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -194,8 +192,7 @@ jobs:
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting" \
"${{ steps.start-time.outputs.time }}"
"starting"
# Deploy and comment for non-forked PRs only
deploy-and-comment:

View File

@@ -6,9 +6,6 @@ on:
workflows: ['CI: Tests Storybook']
types: [requested, completed]
env:
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -63,8 +60,7 @@ jobs:
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting" \
"$(date -u '${{ env.DATE_FORMAT }}')"
"starting"
- name: Download and Deploy Storybook
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'

View File

@@ -24,8 +24,7 @@ jobs:
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting" \
"$(date -u '+%m/%d/%Y, %I:%M:%S %p')"
"starting"
# Build Storybook for all PRs (free Cloudflare deployment)
storybook-build:

View File

@@ -4,8 +4,10 @@ name: 'CI: Tests Unit'
on:
push:
branches: [main, master, dev*, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}

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

@@ -53,7 +53,13 @@ jobs:
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
run: |
pnpm install --frozen-lockfile
pnpm build
# Desktop-specific release artifact with desktop distribution flags.
DISTRIBUTION=desktop pnpm build
pnpm zipdist ./dist ./dist-desktop.zip
# Default release artifact for core/PyPI.
NX_SKIP_NX_CACHE=true pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v6
@@ -62,6 +68,7 @@ jobs:
path: |
dist/
dist.zip
dist-desktop.zip
draft_release:
needs: build
@@ -79,6 +86,7 @@ jobs:
with:
files: |
dist.zip
dist-desktop.zip
tag_name: v${{ needs.build.outputs.version }}
target_commitish: ${{ github.event.pull_request.base.ref }}
make_latest: >-

1
.gitignore vendored
View File

@@ -64,6 +64,7 @@ browser_tests/local/
dist.zip
/temp/
/tmp/
# Generated JSON Schemas
/schemas/

View File

@@ -40,12 +40,12 @@
"block-no-empty": true,
"no-descending-specificity": null,
"no-duplicate-at-import-rules": true,
"at-rule-disallowed-list": ["apply"],
"at-rule-no-unknown": [
true,
{
"ignoreAtRules": [
"tailwind",
"apply",
"layer",
"config",
"theme",

View File

@@ -37,6 +37,10 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
The project uses **Nx** for build orchestration and task management
## Package Manager
This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g., `pnpm test:unit`, `pnpm lint`). To run arbitrary packages not in scripts, use `pnpx` or `pnpm dlx` — never `npx`.
## Build, Test, and Development Commands
- `pnpm dev`: Start Vite dev server.

View File

@@ -61,8 +61,7 @@
"^build"
],
"options": {
"cwd": "apps/desktop-ui",
"command": "vite build --config vite.config.mts"
"command": "vite build --config apps/desktop-ui/vite.config.mts"
},
"outputs": [
"{projectRoot}/dist"

View File

@@ -4,3 +4,39 @@
position: absolute;
inset: 0;
}
.p-button-secondary {
border: none;
background-color: var(--color-neutral-600);
color: var(--color-white);
}
.p-button-secondary:hover {
background-color: var(--color-neutral-550);
}
.p-button-secondary:active {
background-color: var(--color-neutral-500);
}
.p-button-danger {
background-color: var(--color-coral-red-600);
}
.p-button-danger:hover {
background-color: var(--color-coral-red-500);
}
.p-button-danger:active {
background-color: var(--color-coral-red-400);
}
.task-div .p-card {
transition: opacity var(--default-transition-duration);
--p-card-background: var(--p-button-secondary-background);
opacity: 0.9;
}
.task-div .p-card:hover {
opacity: 1;
}

View File

@@ -101,13 +101,15 @@ onUnmounted(() => {
</script>
<style scoped>
@reference '../../../../assets/css/style.css';
/* xterm renders its internal DOM outside Vue templates, so :deep selectors are
* required to style those generated nodes.
*/
:deep(.p-terminal) .xterm {
@apply overflow-hidden;
overflow: hidden;
}
:deep(.p-terminal) .xterm-screen {
@apply bg-neutral-900 overflow-hidden;
overflow: hidden;
background-color: var(--color-neutral-900);
}
</style>

View File

@@ -7,7 +7,7 @@
option-value="value"
:disabled="isSwitching"
:pt="dropdownPt"
:size="props.size"
:size="size"
class="language-selector"
@change="onLocaleChange"
>
@@ -36,16 +36,10 @@ import { i18n, loadLocale, st } from '@/i18n'
type VariantKey = 'dark' | 'light'
type SizeKey = 'small' | 'large'
const props = withDefaults(
defineProps<{
variant?: VariantKey
size?: SizeKey
}>(),
{
variant: 'dark',
size: 'small'
}
)
const { variant = 'dark', size = 'small' } = defineProps<{
variant?: VariantKey
size?: SizeKey
}>()
const dropdownId = `language-select-${Math.random().toString(36).slice(2)}`
@@ -104,10 +98,8 @@ const VARIANT_PRESETS = {
const selectedLocale = ref<string>(i18n.global.locale.value)
const isSwitching = ref(false)
const sizePreset = computed(() => SIZE_PRESETS[props.size as SizeKey])
const variantPreset = computed(
() => VARIANT_PRESETS[props.variant as VariantKey]
)
const sizePreset = computed(() => SIZE_PRESETS[size])
const variantPreset = computed(() => VARIANT_PRESETS[variant])
const dropdownPt = computed(() => ({
root: {
@@ -195,13 +187,17 @@ async function onLocaleChange(event: SelectChangeEvent) {
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.p-dropdown-panel .p-dropdown-item) {
@apply transition-colors;
transition-property: color, background-color, border-color;
transition-duration: var(--default-transition-duration);
}
:deep(.p-dropdown) {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
&:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--color-neutral-900),
0 0 0 4px color-mix(in srgb, var(--color-brand-yellow) 60%, transparent);
}
}
</style>

View File

@@ -269,26 +269,43 @@ const onFocus = async () => {
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.location-picker-accordion) {
@apply px-12;
padding-inline: calc(var(--spacing) * 12);
.p-accordionpanel {
@apply border-0 bg-transparent;
border: 0;
background-color: transparent;
}
.p-accordionheader {
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
margin-top: calc(var(--spacing) * 2);
border: 0;
border-radius: var(--radius-xl);
background-color: color-mix(
in srgb,
var(--color-neutral-800) 50%,
transparent
);
transition:
background-color 0.2s ease,
border-radius 0.5s ease;
&:hover {
background-color: color-mix(
in srgb,
var(--color-neutral-700) 50%,
transparent
);
}
}
/* When panel is expanded, adjust header border radius */
.p-accordionpanel-active {
.p-accordionheader {
@apply rounded-t-xl rounded-b-none;
border-top-left-radius: var(--radius-xl);
border-top-right-radius: var(--radius-xl);
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.p-accordionheader-toggle-icon {
@@ -299,11 +316,24 @@ const onFocus = async () => {
}
.p-accordioncontent {
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
border: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom-right-radius: var(--radius-xl);
border-bottom-left-radius: var(--radius-xl);
background-color: color-mix(
in srgb,
var(--color-neutral-800) 50%,
transparent
);
}
.p-accordioncontent-content {
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
background-color: transparent;
padding-top: calc(var(--spacing) * 3);
padding-right: calc(var(--spacing) * 5);
padding-bottom: calc(var(--spacing) * 5);
padding-left: calc(var(--spacing) * 5);
}
/* Override default chevron icons to use up/down */

View File

@@ -1,11 +1,20 @@
<template>
<div
class="task-div relative grid min-h-52 max-w-48"
:class="{ 'opacity-75': isLoading }"
:class="
cn(
'task-div group/task-card relative grid min-h-52 max-w-48',
isLoading && 'opacity-75'
)
"
>
<Card
class="relative h-full max-w-48 overflow-hidden"
:class="{ 'opacity-65': runner.state !== 'error' }"
:class="
cn(
'relative h-full max-w-48 overflow-hidden',
runner.state !== 'error' && 'opacity-65'
)
"
:pt="cardPt"
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
>
<template #header>
@@ -43,7 +52,7 @@
<i
v-if="!isLoading && runner.state === 'OK'"
class="task-card-ok pi pi-check"
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 col-span-full row-span-full z-10 text-[4rem] text-green-500 opacity-100 transition-opacity group-hover/task-card:opacity-20 [text-shadow:0.25rem_0_0.5rem_black]"
/>
</div>
</template>
@@ -55,6 +64,7 @@ import { computed } from 'vue'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { cn } from '@/utils/tailwindUtil'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
const taskStore = useMaintenanceTaskStore()
@@ -83,51 +93,9 @@ const reactiveExecuting = computed(() => !!runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
const cardPt = {
header: { class: 'z-0' },
body: { class: 'z-[1] grow justify-between' }
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
.task-card-ok {
@apply text-green-500 absolute -right-4 -bottom-4 opacity-100 row-span-full col-span-full transition-opacity;
font-size: 4rem;
text-shadow: 0.25rem 0 0.5rem black;
z-index: 10;
}
.p-card {
@apply transition-opacity;
--p-card-background: var(--p-button-secondary-background);
opacity: 0.9;
&.opacity-65 {
opacity: 0.4;
}
&:hover {
opacity: 1;
}
}
:deep(.p-card-header) {
z-index: 0;
}
:deep(.p-card-body) {
z-index: 1;
flex-grow: 1;
justify-content: space-between;
}
.task-div {
> i {
pointer-events: none;
}
&:hover > i {
opacity: 0.2;
}
}
</style>

View File

@@ -1,10 +1,10 @@
<template>
<div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
<h1 class="font-inter font-semibold text-xl m-0 italic">
{{ t(`desktopDialogs.${id}.title`, title) }}
{{ $t(`desktopDialogs.${id}.title`, title) }}
</h1>
<p class="whitespace-pre-wrap">
{{ t(`desktopDialogs.${id}.message`, message) }}
{{ $t(`desktopDialogs.${id}.message`, message) }}
</p>
<div class="flex w-full gap-2">
<Button
@@ -12,7 +12,7 @@
:key="button.label"
class="rounded-lg first:mr-auto"
:label="
t(
$t(
`desktopDialogs.${id}.buttons.${normalizeI18nKey(button.label)}`,
button.label
)
@@ -31,7 +31,6 @@ import { useRoute } from 'vue-router'
import { getDialog } from '@/constants/desktopDialogs'
import type { DialogAction } from '@/constants/desktopDialogs'
import { t } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'
const route = useRoute()
@@ -41,31 +40,3 @@ const handleButtonClick = async (button: DialogAction) => {
await electronAPI().Dialog.clickButton(button.returnValue)
}
</script>
<style scoped>
@reference '../assets/css/style.css';
.p-button-secondary {
@apply text-white border-none bg-neutral-600;
}
.p-button-secondary:hover {
@apply bg-neutral-550;
}
.p-button-secondary:active {
@apply bg-neutral-500;
}
.p-button-danger {
@apply bg-coral-red-600;
}
.p-button-danger:hover {
@apply bg-coral-red-500;
}
.p-button-danger:active {
@apply bg-coral-red-400;
}
</style>

View File

@@ -6,11 +6,11 @@
<div class="relative m-8 text-center">
<!-- Header -->
<h1 class="download-bg pi-download text-4xl font-bold">
{{ t('desktopUpdate.title') }}
{{ $t('desktopUpdate.title') }}
</h1>
<div class="m-8">
<span>{{ t('desktopUpdate.description') }}</span>
<span>{{ $t('desktopUpdate.description') }}</span>
</div>
<ProgressSpinner class="m-8 w-48 h-48" />
@@ -19,7 +19,7 @@
<Button
style="transform: translateX(-50%)"
class="fixed bottom-0 left-1/2 my-8"
:label="t('maintenance.consoleLogs')"
:label="$t('maintenance.consoleLogs')"
icon="pi pi-desktop"
icon-pos="left"
severity="secondary"
@@ -28,8 +28,8 @@
<TerminalOutputDrawer
v-model="terminalVisible"
:header="t('g.terminal')"
:default-message="t('desktopUpdate.terminalDefaultMessage')"
:header="$t('g.terminal')"
:default-message="$t('desktopUpdate.terminalDefaultMessage')"
/>
</div>
</div>
@@ -44,7 +44,6 @@ import Toast from 'primevue/toast'
import { onUnmounted, ref } from 'vue'
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
import { t } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'
import BaseViewTemplate from './templates/BaseViewTemplate.vue'
@@ -61,10 +60,10 @@ onUnmounted(() => electron.Validation.dispose())
</script>
<style scoped>
@reference '../assets/css/style.css';
.download-bg::before {
@apply m-0 absolute text-muted;
position: absolute;
margin: 0;
color: var(--muted-foreground);
font-family: 'primeicons', sans-serif;
top: -2rem;
right: 2rem;

View File

@@ -183,33 +183,37 @@ onMounted(async () => {
</script>
<style scoped>
@reference '../assets/css/style.css';
:deep(.p-steppanel) {
@apply mt-8 flex justify-center bg-transparent;
margin-top: calc(var(--spacing) * 8);
display: flex;
justify-content: center;
background-color: transparent;
}
/* Remove default padding/margin from StepPanels to make scrollbar flush */
:deep(.p-steppanels) {
@apply p-0 m-0;
margin: 0;
padding: 0;
}
/* Ensure StepPanel content container has no top/bottom padding */
:deep(.p-steppanel-content) {
@apply p-0;
padding: 0;
}
/* Custom overlay scrollbar for WebKit browsers (Electron, Chrome) */
:deep(.p-steppanels::-webkit-scrollbar) {
@apply w-4;
width: calc(var(--spacing) * 4);
}
:deep(.p-steppanels::-webkit-scrollbar-track) {
@apply bg-transparent;
background-color: transparent;
}
:deep(.p-steppanels::-webkit-scrollbar-thumb) {
@apply bg-white/20 rounded-lg border-[4px] border-transparent;
border: 4px solid transparent;
border-radius: var(--radius-lg);
background-color: color-mix(in srgb, var(--color-white) 20%, transparent);
background-clip: content-box;
}
</style>

View File

@@ -77,7 +77,7 @@ const createMockElectronAPI = () => {
}
const ensureElectronAPI = () => {
const globalWindow = window as unknown as { electronAPI?: unknown }
const globalWindow = window as { electronAPI?: unknown }
if (!globalWindow.electronAPI) {
globalWindow.electronAPI = createMockElectronAPI()
}

View File

@@ -114,12 +114,12 @@ import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import RefreshButton from '@/components/common/RefreshButton.vue'
import StatusTag from '@/components/maintenance/StatusTag.vue'
import TaskListPanel from '@/components/maintenance/TaskListPanel.vue'
import TerminalOutputDrawer from '@/components/maintenance/TerminalOutputDrawer.vue'
import { t } from '@/i18n'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceFilter } from '@/types/desktop/maintenanceTypes'
import { electronAPI } from '@/utils/envUtil'
@@ -129,6 +129,7 @@ import BaseViewTemplate from './templates/BaseViewTemplate.vue'
const electron = electronAPI()
const toast = useToast()
const { t } = useI18n()
const taskStore = useMaintenanceTaskStore()
const { clearResolved, processUpdate, refreshDesktopTasks } = taskStore
@@ -220,14 +221,14 @@ onUnmounted(() => electron.Validation.dispose())
</script>
<style scoped>
@reference '../assets/css/style.css';
:deep(.p-tag) {
--p-tag-gap: 0.375rem;
}
.backspan::before {
@apply m-0 absolute text-muted;
position: absolute;
margin: 0;
color: var(--muted-foreground);
font-family: 'primeicons', sans-serif;
top: -2rem;
right: -2rem;

View File

@@ -1,6 +1,6 @@
<template>
<BaseViewTemplate>
<div class="sad-container">
<div class="sad-container grid items-center justify-evenly">
<!-- Right side image -->
<img
class="sad-girl"
@@ -79,10 +79,7 @@ const continueToInstall = async () => {
</script>
<style scoped>
@reference '../assets/css/style.css';
.sad-container {
@apply grid items-center justify-evenly;
grid-template-columns: 25rem 1fr;
& > * {

View File

@@ -232,8 +232,6 @@ onUnmounted(() => {
</script>
<style scoped>
@reference '../assets/css/style.css';
/* Hide the xterm scrollbar completely */
:deep(.p-terminal) .xterm-viewport {
overflow: hidden !important;

View File

@@ -0,0 +1,183 @@
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "e5fb1765-aaaa-bbbb-cccc-ddddeeee0001",
"pos": [600, 400],
"size": [200, 100],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-aaaa-bbbb-cccc-ddddeeee0001",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 5,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Subgraph With Duplicate Links",
"inputNode": {
"id": -10,
"bounding": [200, 400, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [900, 400, 120, 60]
},
"inputs": [],
"outputs": [
{
"id": "out-latent-1",
"name": "LATENT",
"type": "LATENT",
"linkIds": [2],
"pos": [920, 420]
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [400, 100],
"size": [270, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [2]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 2,
"type": "EmptyLatentImage",
"pos": [100, 200],
"size": [200, 106],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1, 3, 4, 5]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": 2,
"origin_slot": 0,
"target_id": 1,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 3,
"origin_id": 2,
"origin_slot": 0,
"target_id": 1,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 4,
"origin_id": 2,
"origin_slot": 0,
"target_id": 1,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 5,
"origin_id": 2,
"origin_slot": 0,
"target_id": 1,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.38.14"
},
"version": 0.4
}

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

@@ -0,0 +1,394 @@
{
"id": "43e9499c-2512-43b5-a5a1-2485eb65da32",
"revision": 0,
"last_node_id": 8,
"last_link_id": 10,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [170.55728894250745, 515.6401487466619],
"size": [282.8166809082031, 363.8333435058594],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [7, 9]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 7,
"type": "21dea088-e1b4-47a4-a01f-3d1bf4504001",
"pos": [500.2639113468392, 519.9960755960157],
"size": [464.95001220703125, 615.8333129882812],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 7
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [10]
}
],
"properties": {
"proxyWidgets": [
["2", "$$canvas-image-preview"],
["4", "$$canvas-image-preview"]
]
},
"widgets_values": []
},
{
"id": 8,
"type": "a7a0350a-af99-4d26-9391-450b4f726206",
"pos": [1000.5293620197185, 499.9253405678786],
"size": [225, 359.8333435058594],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "image1",
"type": "IMAGE",
"link": 9
},
{
"name": "image2",
"type": "IMAGE",
"link": 10
}
],
"outputs": [],
"properties": {
"proxyWidgets": [["6", "$$canvas-image-preview"]]
},
"widgets_values": []
}
],
"links": [
[7, 1, 0, 7, 0, "IMAGE"],
[9, 1, 0, 8, 0, "IMAGE"],
[10, 7, 0, 8, 1, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "21dea088-e1b4-47a4-a01f-3d1bf4504001",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 8,
"lastLinkId": 10,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [297.7833638107301, 502.6302057820892, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1052.8175480718996, 502.6302057820892, 120, 60]
},
"inputs": [
{
"id": "afc8dbc3-12e6-4b3c-9840-9b398d06e6bd",
"name": "images",
"type": "IMAGE",
"linkIds": [1, 2],
"localized_name": "images",
"pos": [397.7833638107301, 522.6302057820892]
}
],
"outputs": [
{
"id": "d0a84974-5f4d-4f4b-b23a-2e7288a9689d",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [5],
"localized_name": "IMAGE",
"pos": [1072.8175480718996, 522.6302057820892]
}
],
"widgets": [],
"nodes": [
{
"id": 4,
"type": "PreviewImage",
"pos": [767.8225773415076, 602.8695134060456],
"size": [225, 303.8333435058594],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 3
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 2,
"type": "PreviewImage",
"pos": [754.9358989867657, 188.55375831225257],
"size": [225, 303.8333435058594],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 1
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 3,
"type": "ImageInvert",
"pos": [477.783932416778, 542.2440719627998],
"size": [225, 71.83333587646484],
"flags": {
"collapsed": false
},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 2
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [3, 5]
}
],
"properties": {
"Node name for S&R": "ImageInvert"
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 3,
"origin_id": 3,
"origin_slot": 0,
"target_id": 4,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 2,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 2,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 5,
"origin_id": 3,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
},
{
"id": "a7a0350a-af99-4d26-9391-450b4f726206",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 8,
"lastLinkId": 10,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [973.7423316105073, 561.9744246746379, 120, 80]
},
"outputNode": {
"id": -20,
"bounding": [1905.487372786412, 581.9744246746379, 120, 40]
},
"inputs": [
{
"id": "20ac4159-6814-4d40-a217-ea260152b689",
"name": "image1",
"type": "IMAGE",
"linkIds": [4],
"localized_name": "image1",
"pos": [1073.7423316105073, 581.9744246746379]
},
{
"id": "c3759a8c-914e-4450-bc41-ca683ffce96b",
"name": "image2",
"type": "IMAGE",
"linkIds": [8],
"localized_name": "image2",
"shape": 7,
"pos": [1073.7423316105073, 601.9744246746379]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "ImageStitch",
"pos": [1153.7423085222254, 396.2033931749105],
"size": [270, 225.1666717529297],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "image1",
"name": "image1",
"type": "IMAGE",
"link": 4
},
{
"localized_name": "image2",
"name": "image2",
"shape": 7,
"type": "IMAGE",
"link": 8
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [6]
}
],
"properties": {
"Node name for S&R": "ImageStitch"
},
"widgets_values": ["right", true, 0, "white"]
},
{
"id": 6,
"type": "PreviewImage",
"pos": [1620.4874189629757, 529.9122050216333],
"size": [225, 307.8333435058594],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 6
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 6,
"origin_id": 5,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 5,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 8,
"origin_id": -10,
"origin_slot": 1,
"target_id": 5,
"target_slot": 1,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.7269777827561446,
"offset": [-35.273237658266034, -55.17394203309256]
},
"frontendVersion": "1.40.8"
},
"version": 0.4
}

View File

@@ -0,0 +1,170 @@
{
"id": "preview-subgraph-test-001",
"revision": 0,
"last_node_id": 11,
"last_link_id": 2,
"nodes": [
{
"id": 5,
"type": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"pos": [318.6320139868054, 212.9091015141833],
"size": [225, 368],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [],
"properties": {
"proxyWidgets": [
["10", "filename_prefix"],
["10", "$$canvas-image-preview"]
],
"cnr_id": "comfy-core",
"ver": "0.13.0",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.6.2",
"input_ue_unconnectable": {}
}
},
"widgets_values": []
},
{
"id": 11,
"type": "LoadImage",
"pos": [-0.5080003681592018, 211.3051121416672],
"size": [282.8333435058594, 364],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
},
"cnr_id": "comfy-core",
"ver": "0.13.0",
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
}
],
"links": [[2, 11, 0, 5, 0, "IMAGE"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [300, 350, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [900, 350, 120, 40]
},
"inputs": [
{
"id": "img-slot-001",
"name": "images",
"type": "IMAGE",
"linkIds": [1],
"pos": [400, 370]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "SaveImage",
"pos": [500.0046924937855, 300.0146992076527],
"size": [315, 340],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 1
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.13.0",
"Node name for S&R": "SaveImage",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.6.2",
"input_ue_unconnectable": {}
}
},
"widgets_values": ["ComfyUI"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {
"ue_links": [],
"links_added_by_ue": []
}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1.1819400303977265,
"offset": [81.66005130613983, -19.028558221588725]
},
"frontendVersion": "1.40.3",
"ue_links": [],
"links_added_by_ue": [],
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}

View File

@@ -24,9 +24,9 @@ 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 { DebugHelper } from './helpers/DebugHelper'
import { DragDropHelper } from './helpers/DragDropHelper'
import { KeyboardHelper } from './helpers/KeyboardHelper'
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
@@ -174,7 +174,6 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly vueNodes: VueNodeHelpers
public readonly debug: DebugHelper
public readonly subgraph: SubgraphHelper
public readonly canvasOps: CanvasHelper
public readonly nodeOps: NodeOperationsHelper
@@ -187,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[] = []
@@ -219,7 +219,6 @@ export class ComfyPage {
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.vueNodes = new VueNodeHelpers(page)
this.debug = new DebugHelper(page, this.canvas)
this.subgraph = new SubgraphHelper(this)
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
this.nodeOps = new NodeOperationsHelper(this)
@@ -232,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() {
@@ -439,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

@@ -11,7 +11,10 @@ export class CommandHelper {
): Promise<void> {
await this.page.evaluate(
({ commandId, metadata }) => {
return window['app'].extensionManager.command.execute(commandId, {
const app = window.app
if (!app) throw new Error('window.app is not available')
return app.extensionManager.command.execute(commandId, {
metadata
})
},

View File

@@ -1,167 +0,0 @@
import type { Locator, Page, TestInfo } from '@playwright/test'
import type { Position } from '../types'
export interface DebugScreenshotOptions {
fullPage?: boolean
element?: 'canvas' | 'page'
markers?: Array<{ position: Position; id?: string }>
}
export class DebugHelper {
constructor(
private page: Page,
private canvas: Locator
) {}
async addMarker(
position: Position,
id: string = 'debug-marker'
): Promise<void> {
await this.page.evaluate(
([pos, markerId]) => {
const existing = document.getElementById(markerId)
if (existing) existing.remove()
const marker = document.createElement('div')
marker.id = markerId
marker.style.position = 'fixed'
marker.style.left = `${pos.x - 10}px`
marker.style.top = `${pos.y - 10}px`
marker.style.width = '20px'
marker.style.height = '20px'
marker.style.border = '2px solid red'
marker.style.borderRadius = '50%'
marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'
marker.style.pointerEvents = 'none'
marker.style.zIndex = '10000'
document.body.appendChild(marker)
},
[position, id] as const
)
}
async removeMarkers(): Promise<void> {
await this.page.evaluate(() => {
document
.querySelectorAll('[id^="debug-marker"]')
.forEach((el) => el.remove())
})
}
async attachScreenshot(
testInfo: TestInfo,
name: string,
options?: DebugScreenshotOptions
): Promise<void> {
if (options?.markers) {
for (const marker of options.markers) {
await this.addMarker(marker.position, marker.id)
}
}
let screenshot: Buffer
const targetElement = options?.element || 'page'
if (targetElement === 'canvas') {
screenshot = await this.canvas.screenshot()
} else if (options?.fullPage) {
screenshot = await this.page.screenshot({ fullPage: true })
} else {
screenshot = await this.page.screenshot()
}
await testInfo.attach(name, {
body: screenshot,
contentType: 'image/png'
})
if (options?.markers) {
await this.removeMarkers()
}
}
async saveCanvasScreenshot(filename: string): Promise<void> {
await this.page.evaluate(async (filename) => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
return new Promise<void>((resolve) => {
canvas.toBlob(async (blob) => {
if (!blob) {
throw new Error('Failed to create blob from canvas')
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
resolve()
}, 'image/png')
})
}, filename)
}
async getCanvasDataURL(): Promise<string> {
return await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
return canvas.toDataURL('image/png')
})
}
async showCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const canvas = document.getElementById(
'graph-canvas'
) as HTMLCanvasElement
if (!canvas) {
throw new Error('Canvas not found')
}
const existingOverlay = document.getElementById('debug-canvas-overlay')
if (existingOverlay) {
existingOverlay.remove()
}
const overlay = document.createElement('div')
overlay.id = 'debug-canvas-overlay'
overlay.style.position = 'fixed'
overlay.style.top = '0'
overlay.style.left = '0'
overlay.style.zIndex = '9999'
overlay.style.backgroundColor = 'white'
overlay.style.padding = '10px'
overlay.style.border = '2px solid red'
const img = document.createElement('img')
img.src = canvas.toDataURL('image/png')
img.style.maxWidth = '800px'
img.style.maxHeight = '600px'
overlay.appendChild(img)
document.body.appendChild(overlay)
})
}
async hideCanvasOverlay(): Promise<void> {
await this.page.evaluate(() => {
const overlay = document.getElementById('debug-canvas-overlay')
if (overlay) {
overlay.remove()
}
})
}
}

View File

@@ -4,6 +4,17 @@ import type { Page } from '@playwright/test'
import type { Position } from '../types'
function getFileType(fileName: string): string {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.svg')) return 'image/svg+xml'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
if (fileName.endsWith('.avif')) return 'image/avif'
return 'application/octet-stream'
}
export class DragDropHelper {
constructor(
private readonly page: Page,
@@ -48,17 +59,6 @@ export class DragDropHelper {
const filePath = this.assetPath(fileName)
const buffer = readFileSync(filePath)
const getFileType = (fileName: string) => {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.svg')) return 'image/svg+xml'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
if (fileName.endsWith('.avif')) return 'image/avif'
return 'application/octet-stream'
}
evaluateParams.fileName = fileName
evaluateParams.fileType = getFileType(fileName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
@@ -115,6 +115,16 @@ export class DragDropHelper {
const dragOverEvent = new DragEvent('dragover', eventOptions)
const dropEvent = new DragEvent('drop', eventOptions)
const graphCanvasElement = document.querySelector('#graph-canvas')
// Keep Litegraph's drag-over node tracking in sync when the drop target is a
// Vue node DOM overlay outside of the graph canvas element.
if (graphCanvasElement && !graphCanvasElement.contains(targetElement)) {
graphCanvasElement.dispatchEvent(
new DragEvent('dragover', eventOptions)
)
}
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
@@ -145,6 +155,104 @@ export class DragDropHelper {
await this.nextFrame()
}
async dragAndDropFiles(
fileNames: string[],
options: {
dropPosition?: Position
waitForUploadCount?: number
} = {}
): Promise<void> {
const { dropPosition = { x: 100, y: 100 }, waitForUploadCount = 0 } =
options
const files = fileNames.map((fileName) => {
const filePath = this.assetPath(fileName)
const buffer = readFileSync(filePath)
return {
fileName,
fileType: getFileType(fileName),
buffer: [...new Uint8Array(buffer)]
}
})
let uploadResponsePromise: Promise<unknown> | null = null
if (waitForUploadCount > 0) {
let uploadCount = 0
uploadResponsePromise = new Promise<void>((resolve) => {
const handler = (resp: { url(): string; status(): number }) => {
if (resp.url().includes('/upload/') && resp.status() === 200) {
uploadCount++
if (uploadCount >= waitForUploadCount) {
this.page.off('response', handler)
resolve()
}
}
}
this.page.on('response', handler)
})
}
await this.page.evaluate(
async (params) => {
const dataTransfer = new DataTransfer()
for (const f of params.files) {
const file = new File([new Uint8Array(f.buffer)], f.fileName, {
type: f.fileType
})
dataTransfer.items.add(file)
}
const targetElement = document.elementFromPoint(
params.dropPosition.x,
params.dropPosition.y
)
if (!targetElement) {
throw new Error(
`No element found at drop position: (${params.dropPosition.x}, ${params.dropPosition.y}).`
)
}
const eventOptions = {
bubbles: true,
cancelable: true,
dataTransfer,
clientX: params.dropPosition.x,
clientY: params.dropPosition.y
}
const graphCanvasElement = document.querySelector('#graph-canvas')
if (graphCanvasElement && !graphCanvasElement.contains(targetElement)) {
graphCanvasElement.dispatchEvent(
new DragEvent('dragover', eventOptions)
)
}
const dropEvent = new DragEvent('drop', eventOptions)
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
targetElement.dispatchEvent(new DragEvent('dragover', eventOptions))
targetElement.dispatchEvent(dropEvent)
},
{ files, dropPosition }
)
if (uploadResponsePromise) {
await uploadResponsePromise
}
await this.nextFrame()
}
async dragAndDropFile(
fileName: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}

View File

@@ -33,6 +33,10 @@ export class NodeOperationsHelper {
})
}
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)
}
async getNodes(): Promise<LGraphNode[]> {
return await this.page.evaluate(() => {
return window.app!.graph.nodes

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

@@ -36,7 +36,7 @@ export class SubgraphHelper {
const currentGraph = app.canvas!.graph!
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
if (!('inputNode' in currentGraph)) {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)
@@ -88,7 +88,7 @@ export class SubgraphHelper {
if (node.onPointerDown) {
node.onPointerDown(
event as unknown as CanvasPointerEvent,
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
@@ -121,7 +121,7 @@ export class SubgraphHelper {
if (node.onPointerDown) {
node.onPointerDown(
event as unknown as CanvasPointerEvent,
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
app.canvas.pointer,
app.canvas.linkConnector
)
@@ -129,7 +129,7 @@ export class SubgraphHelper {
// Trigger double-click
if (app.canvas.pointer.onDoubleClick) {
app.canvas.pointer.onDoubleClick(
event as unknown as CanvasPointerEvent
event as Partial<CanvasPointerEvent> as CanvasPointerEvent
)
}
}

View File

@@ -27,6 +27,7 @@ export const TestIds = {
settingsContainer: 'settings-container',
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
missingNodes: 'missing-nodes-warning',
about: 'about-panel',
whatsNewSection: 'whats-new-section'
},
@@ -43,11 +44,21 @@ export const TestIds = {
node: {
titleInput: 'node-title-input'
},
selectionToolbox: {
colorPickerButton: 'color-picker-button',
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red'
},
widgets: {
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'
},
templates: {
content: 'template-workflows-content',
workflowCard: (id: string) => `template-workflow-${id}`
@@ -69,7 +80,9 @@ export type TestIdValue =
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],
(id: string) => string

View File

@@ -128,7 +128,8 @@ class NodeSlotReference {
nodeSize: [node.size[0], node.size[1]],
rawConnectionPos: [rawPos[0], rawPos[1]],
convertedPos: [convertedPos[0], convertedPos[1]],
currentGraphType: window.app!.canvas.graph!.constructor.name
currentGraphType:
'inputNode' in window.app!.canvas.graph! ? 'Subgraph' : 'LGraph'
}
)
@@ -461,18 +462,44 @@ export class NodeReference {
// Try multiple positions to avoid DOM widget interference
const clickPositions = [
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + titleHeight + 5 },
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + nodeSize.height / 2 },
{
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y + nodeSize.height / 2
},
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
]
// Click the enter_subgraph title button (top-right of title bar).
// This is more reliable than dblclick on the node body because
// promoted DOM widgets can overlay the body and intercept events.
const subgraphButtonPos = {
x: nodePos.x + nodeSize.width - 15,
y: nodePos.y - titleHeight / 2
}
const checkIsInSubgraph = async () => {
return this.comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return graph?.constructor?.name === 'Subgraph'
return !!graph && 'inputNode' in graph
})
}
await expect(async () => {
// Try just clicking the enter button first
await this.comfyPage.canvas.click({
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvas.click({
position: subgraphButtonPos,
force: true
})
await this.comfyPage.nextFrame()
if (await checkIsInSubgraph()) return
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({

View File

@@ -1,9 +1,5 @@
import { test as base } from '@playwright/test'
interface TestWindow extends Window {
__ws__?: Record<string, WebSocket>
}
export const webSocketFixture = base.extend<{
ws: { trigger(data: unknown, url?: string): Promise<void> }
}>({

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

View File

@@ -0,0 +1,64 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
export function isPromotedWidgetEntry(
entry: unknown
): entry is PromotedWidgetEntry {
return (
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
}
export function normalizePromotedWidgets(
value: unknown
): PromotedWidgetEntry[] {
if (!Array.isArray(value)) return []
return value.filter(isPromotedWidgetEntry)
}
export async function getPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
return node?.properties?.proxyWidgets ?? []
}, nodeId)
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetNames(
comfyPage: ComfyPage,
nodeId: string
): Promise<string[]> {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
return promotedWidgets.map(([, widgetName]) => widgetName)
}
export async function getPromotedWidgetCount(
comfyPage: ComfyPage,
nodeId: string
): Promise<number> {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
return promotedWidgets.length
}
export async function getPromotedWidgetCountByName(
comfyPage: ComfyPage,
nodeId: string,
widgetName: string
): Promise<number> {
return comfyPage.page.evaluate(
([id, name]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgets = node?.widgets ?? []
return widgets.filter((widget) => widget.name === name).length
},
[nodeId, widgetName] as const
)
}

View File

@@ -0,0 +1,90 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
test.describe('Batch Image Import', () => {
test('Dropping multiple images creates LoadImage nodes and a BatchImagesNode', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.dragDrop.dragAndDropFiles(
['image32x32.webp', 'image64x64.webp'],
{ waitForUploadCount: 2 }
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount + 3)
const batchNodes =
await comfyPage.nodeOps.getNodeRefsByType('BatchImagesNode')
expect(batchNodes).toHaveLength(1)
})
test('Dropping a single image does not create a BatchImagesNode', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.dragDrop.dragAndDropFile('image32x32.webp', {
waitForUpload: true
})
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount + 1)
const batchNodes =
await comfyPage.nodeOps.getNodeRefsByType('BatchImagesNode')
expect(batchNodes).toHaveLength(0)
})
test('Batch image import produces a single undo entry', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
const initialUndoSize = await comfyPage.workflow.getUndoQueueSize()
await comfyPage.dragDrop.dragAndDropFiles(
['image32x32.webp', 'image64x64.webp'],
{ waitForUploadCount: 2 }
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount + 3)
await expect
.poll(() => comfyPage.workflow.getUndoQueueSize(), { timeout: 5000 })
.toBe((initialUndoSize ?? 0) + 1)
})
test('Batch image import can be undone as a single action', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.dragDrop.dragAndDropFiles(
['image32x32.webp', 'image64x64.webp'],
{ waitForUploadCount: 2 }
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount + 3)
// Call undo directly on the change tracker to avoid keyboard focus issues
await comfyPage.page.evaluate(async () => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
await workflow?.changeTracker.undo()
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount)
})
})

View File

@@ -14,7 +14,9 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.filename
})
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
await expect
.poll(() => comfyPage.page.title())
.toBe(`*${workflowName} - ComfyUI`)
})
// Failing on CI
@@ -51,7 +53,7 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
})
test('Can display default title', async ({ comfyPage }) => {
expect(await comfyPage.page.title()).toBe('ComfyUI')
await expect.poll(() => comfyPage.page.title()).toBe('ComfyUI')
})
})
})

View File

@@ -160,12 +160,12 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
})
// Click empty space to trigger a change detection.
await comfyPage.canvasOps.clickEmptySpace()
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
})
test('Ignores changes in workflow.ds', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
})
})

View File

@@ -245,11 +245,18 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
const parsed = await comfyPage.page.evaluate(() => {
return window['app'].graph.serialize()
const graph = window.app!.graph!
if (typeof graph.serialize !== 'function') {
throw new Error('app.graph.serialize is not available')
}
return graph.serialize() as {
nodes: Array<{ bgcolor?: string; color?: string }>
}
})
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
for (const node of parsed.nodes) {
const nodes = parsed.nodes
for (const node of nodes) {
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
if (node.color) expect(node.color).not.toMatch(/hsla/)
}

View File

@@ -4,6 +4,7 @@ import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/platform/keybindings/types'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
import { TestIds } from '../fixtures/selectors'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -15,8 +16,9 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
// Wait for the element with the .comfy-missing-nodes selector to be visible
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
const missingNodesWarning = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodes
)
await expect(missingNodesWarning).toBeVisible()
})
@@ -25,8 +27,9 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
// Wait for the element with the .comfy-missing-nodes selector to be visible
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
const missingNodesWarning = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodes
)
await expect(missingNodesWarning).toBeVisible()
// Verify the missing node text includes subgraph context
@@ -38,13 +41,14 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const missingNodesWarning = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodes
)
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.page
.locator('.p-dialog')
.getByRole('button', { name: 'Close' })
.click({ force: true })
await comfyPage.page.locator('.p-dialog').waitFor({ state: 'hidden' })
await expect(missingNodesWarning).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(missingNodesWarning).not.toBeVisible()
// Wait for any async operations to complete after dialog closes
await comfyPage.nextFrame()
@@ -55,9 +59,14 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
// Undo and redo the change
await comfyPage.keyboard.undo()
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
await expect(async () => {
await expect(missingNodesWarning).not.toBeVisible()
}).toPass({ timeout: 5000 })
await comfyPage.keyboard.redo()
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
await expect(async () => {
await expect(missingNodesWarning).not.toBeVisible()
}).toPass({ timeout: 5000 })
})
test.describe('Execution error', () => {
@@ -95,15 +104,13 @@ test.describe('Missing models warning', () => {
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const downloadButton = missingModelsWarning.getByText('Download')
await expect(downloadButton).toBeVisible()
// Check that the copy URL button is also visible for Desktop environment
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
await expect(copyUrlButton).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
})
test('Should display a warning when missing models are found in node properties', async ({
@@ -114,15 +121,13 @@ test.describe('Missing models warning', () => {
'missing/missing_models_from_node_properties'
)
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const downloadButton = missingModelsWarning.getByText('Download')
await expect(downloadButton).toBeVisible()
// Check that the copy URL button is also visible for Desktop environment
const copyUrlButton = missingModelsWarning.getByText('Copy URL')
await expect(copyUrlButton).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
})
test('Should not display a warning when no missing models are found', async ({
@@ -163,8 +168,10 @@ test.describe('Missing models warning', () => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
})
test('Should not display warning when model metadata exists but widget values have changed', async ({
@@ -177,8 +184,10 @@ test.describe('Missing models warning', () => {
)
// The missing models warning should NOT appear
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
})
// Flaky test after parallelization
@@ -190,13 +199,15 @@ test.describe('Missing models warning', () => {
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).toBeVisible()
const downloadButton = comfyPage.page.getByText('Download')
await expect(downloadButton).toBeVisible()
const downloadAllButton = comfyPage.page.getByText('Download all')
await expect(downloadAllButton).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await downloadButton.click()
await downloadAllButton.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
@@ -220,13 +231,14 @@ test.describe('Missing models warning', () => {
test('Should disable warning dialog when checkbox is checked', async ({
comfyPage
}) => {
await checkbox.click()
const changeSettingPromise = comfyPage.page.waitForRequest(
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
)
await closeButton.click()
await checkbox.click()
await changeSettingPromise
await closeButton.click()
const settingValue = await comfyPage.settings.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'
)
@@ -401,7 +413,7 @@ test.describe('Signin dialog', () => {
test('Paste content to signin dialog should not paste node on canvas', async ({
comfyPage
}) => {
const nodeNum = (await comfyPage.nodeOps.getNodes()).length
const nodeNum = await comfyPage.nodeOps.getNodeCount()
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick
})
@@ -424,6 +436,6 @@ test.describe('Signin dialog', () => {
await input.press('Control+v')
await expect(input).toHaveValue('test_password')
expect(await comfyPage.nodeOps.getNodes()).toHaveLength(nodeNum)
expect(await comfyPage.nodeOps.getNodeCount()).toBe(nodeNum)
})
})

Binary file not shown.

Before

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

View File

@@ -203,19 +203,19 @@ test.describe('Menu', { tag: '@ui' }, () => {
await topbar.switchTheme('light')
// Verify menu stays open and Light theme shows as active
await expect(menu).toBeVisible()
await expect(themeSubmenu).toBeVisible()
// Check that Light theme is active
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
await expect(async () => {
await expect(menu).toBeVisible()
await expect(themeSubmenu).toBeVisible()
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
}).toPass({ timeout: 5000 })
// Screenshot with light theme active
await comfyPage.attachScreenshot('theme-menu-light-active')
// Verify ColorPalette setting is set to "light"
expect(await comfyPage.settings.getSetting('Comfy.ColorPalette')).toBe(
'light'
)
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'))
.toBe('light')
// Close menu to see theme change
await topbar.closeTopbarMenu()
@@ -228,20 +228,22 @@ test.describe('Menu', { tag: '@ui' }, () => {
await topbar.switchTheme('dark')
// Verify menu stays open and Dark theme shows as active
await expect(menu).toBeVisible()
await expect(themeItems2.submenu).toBeVisible()
// Check that Dark theme is active and Light theme is not
expect(await topbar.isMenuItemActive(themeItems2.darkTheme)).toBe(true)
expect(await topbar.isMenuItemActive(themeItems2.lightTheme)).toBe(false)
await expect(async () => {
await expect(menu).toBeVisible()
await expect(themeItems2.submenu).toBeVisible()
expect(await topbar.isMenuItemActive(themeItems2.darkTheme)).toBe(true)
expect(await topbar.isMenuItemActive(themeItems2.lightTheme)).toBe(
false
)
}).toPass({ timeout: 5000 })
// Screenshot with dark theme active
await comfyPage.attachScreenshot('theme-menu-dark-active')
// Verify ColorPalette setting is set to "dark"
expect(await comfyPage.settings.getSetting('Comfy.ColorPalette')).toBe(
'dark'
)
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'))
.toBe('dark')
// Close menu
await topbar.closeTopbarMenu()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 90 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: 89 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,6 +1,8 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
const test = comfyPageFixture
@@ -10,6 +12,17 @@ test.beforeEach(async ({ comfyPage }) => {
const BLUE_COLOR = 'rgb(51, 51, 85)'
const RED_COLOR = 'rgb(85, 51, 51)'
const getColorPickerButton = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
const getColorPickerCurrentColor = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerCurrentColor)
const getColorPickerGroup = (comfyPage: { page: Page }) =>
comfyPage.page.getByRole('group').filter({
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
})
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
@@ -132,28 +145,24 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
// Color picker button should be visible
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
const colorPickerButton = getColorPickerButton(comfyPage)
await expect(colorPickerButton).toBeVisible()
// Click color picker button
await colorPickerButton.click()
// Color picker dropdown should be visible
const colorPickerDropdown = comfyPage.page.locator(
'.color-picker-container'
)
await expect(colorPickerDropdown).toBeVisible()
const colorPickerGroup = getColorPickerGroup(comfyPage)
await expect(colorPickerGroup).toBeVisible()
// Select a color (e.g., blue)
const blueColorOption = colorPickerDropdown.locator(
'i[data-testid="blue"]'
const blueColorOption = colorPickerGroup.getByTestId(
TestIds.selectionToolbox.colorBlue
)
await blueColorOption.click()
// Dropdown should close after selection
await expect(colorPickerDropdown).not.toBeVisible()
await expect(colorPickerGroup).not.toBeVisible()
// Node should have the selected color class/style
// Note: Exact verification method depends on how color is applied to nodes
@@ -172,22 +181,21 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
'CLIP Text Encode (Prompt)'
])
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
const colorPickerButton = getColorPickerButton(comfyPage)
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
// Initially should show default color
await expect(colorPickerButton).not.toHaveAttribute('color')
// Click color picker and select a color
await colorPickerButton.click()
const redColorOption = comfyPage.page.locator(
'.color-picker-container i[data-testid="red"]'
const redColorOption = getColorPickerGroup(comfyPage).getByTestId(
TestIds.selectionToolbox.colorRed
)
await redColorOption.click()
// Button should now show the selected color
await expect(colorPickerButton).toHaveCSS('color', RED_COLOR)
await expect(colorPickerCurrentColor).toHaveCSS('color', RED_COLOR)
})
test('color picker shows mixed state for differently colored selections', async ({
@@ -195,17 +203,17 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// Select first node and color it
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorBlue)
.click()
await comfyPage.nodeOps.selectNodes(['KSampler'])
// Select second node and color it differently
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="red"]')
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorRed)
.click()
// Select both nodes
@@ -215,9 +223,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
])
// Color picker should show null/mixed state
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
const colorPickerButton = getColorPickerButton(comfyPage)
await expect(colorPickerButton).not.toHaveAttribute('color')
})
@@ -226,9 +232,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// First color a node
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorBlue)
.click()
// Clear selection
@@ -238,10 +244,8 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nodeOps.selectNodes(['KSampler'])
// Color picker button should show the correct color
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
await expect(colorPickerButton).toHaveCSS('color', BLUE_COLOR)
const colorPickerCurrentColor = getColorPickerCurrentColor(comfyPage)
await expect(colorPickerCurrentColor).toHaveCSS('color', BLUE_COLOR)
})
test('colorization via color picker can be undone', async ({
@@ -249,9 +253,9 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// Select a node and color it
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
await getColorPickerButton(comfyPage).click()
await getColorPickerGroup(comfyPage)
.getByTestId(TestIds.selectionToolbox.colorBlue)
.click()
// Undo the colorization

View File

@@ -83,17 +83,15 @@ test.describe('Workflows sidebar', () => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
await comfyPage.command.executeCommand('Comfy.LoadDefaultWorkflow')
const originalNodeCount = (await comfyPage.nodeOps.getNodes()).length
const originalNodeCount = await comfyPage.nodeOps.getNodeCount()
await tab.insertWorkflow(tab.getPersistedItem('workflow1.json'))
await expect
.poll(() => comfyPage.nodeOps.getNodes().then((n) => n.length))
.poll(() => comfyPage.nodeOps.getNodeCount())
.toEqual(originalNodeCount + 1)
await tab.getPersistedItem('workflow1.json').click()
await expect
.poll(() => comfyPage.nodeOps.getNodes().then((n) => n.length))
.toEqual(1)
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toEqual(1)
})
test('Can rename nested workflow from opened workflow item', async ({

View File

@@ -53,7 +53,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
): Promise<boolean> {
return await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return graph?.constructor?.name === 'Subgraph'
return !!graph && 'inputNode' in graph
})
}
@@ -375,6 +375,45 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
})
})
test.describe('Subgraph Unpacking', () => {
test('Unpacking subgraph with duplicate links does not create extra links', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-duplicate-links'
)
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
if (!subgraphNode || !subgraphNode.isSubgraphNode()) {
return { error: 'No subgraph node found' }
}
graph.unpackSubgraph(subgraphNode)
const linkCount = graph.links.size
const nodes = graph.nodes
const ksampler = nodes.find((n) => n.type === 'KSampler')
if (!ksampler) return { error: 'No KSampler found after unpack' }
const linkedInputCount = ksampler.inputs.filter(
(i) => i.link != null
).length
return { linkCount, linkedInputCount, nodeCount: nodes.length }
})
expect(result).not.toHaveProperty('error')
// Should have exactly 1 link (EmptyLatentImage→KSampler)
// not 4 (with 3 duplicates). The KSampler→output link is dropped
// because the subgraph output has no downstream connection.
expect(result.linkCount).toBe(1)
// KSampler should have exactly 1 linked input (latent_image)
expect(result.linkedInputCount).toBe(1)
})
})
test.describe('Subgraph Creation and Deletion', () => {
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')

View File

@@ -0,0 +1,690 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import { fitToViewInstant } from '../helpers/fitToView'
import {
getPromotedWidgetNames,
getPromotedWidgetCount,
getPromotedWidgets
} from '../helpers/promotedWidgets'
/**
* Check whether we're currently in a subgraph.
*/
async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async function exitSubgraphViaBreadcrumb(comfyPage: ComfyPage): Promise<void> {
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
await breadcrumb.waitFor({ state: 'visible', timeout: 5000 })
const parentLink = breadcrumb.getByRole('link').first()
await expect(parentLink).toBeVisible()
await parentLink.click()
await comfyPage.nextFrame()
}
test.describe(
'Subgraph Widget Promotion',
{ tag: ['@subgraph', '@widget'] },
() => {
test.describe('Auto-promotion on Convert to Subgraph', () => {
test('Recommended widgets are auto-promoted when creating a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Select just the KSampler node (id 3) which has a "seed" widget
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// SubgraphNode should exist
expect(await subgraphNode.exists()).toBe(true)
// The KSampler has a "seed" widget which is in the recommended list.
// The promotion store should have at least the seed widget promoted.
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames).toContain('seed')
// SubgraphNode should have widgets (promoted views)
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
expect(widgetCount).toBeGreaterThan(0)
})
test('CLIPTextEncode text widget is auto-promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Select the positive CLIPTextEncode node (id 6)
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
await clipNode.click('title')
const subgraphNode = await clipNode.convertToSubgraph()
await comfyPage.nextFrame()
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames.length).toBeGreaterThan(0)
// CLIPTextEncode is in the recommendedNodes list, so its text widget
// should be promoted
expect(promotedNames).toContain('text')
})
test('SaveImage/PreviewImage nodes get pseudo-widget promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
// Select the SaveImage node (id 9 in default workflow)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.click('title')
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
const promotedNames = await getPromotedWidgetNames(
comfyPage,
String(subgraphNode.id)
)
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
expect(promotedNames).toContain('filename_prefix')
})
})
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {
test('Promoted text widget is visible on SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
// The subgraph node (id 11) should have a text widget promoted
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
await expect(textarea).toHaveCount(1)
})
test('Multiple promoted widgets all render on SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
const count = await textareas.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Visibility in Vue Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted text widget renders on SubgraphNode in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
// SubgraphNode (id 11) should render with its body
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
// It should have the Enter Subgraph button
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
await expect(enterButton).toBeVisible()
// The promoted text widget should render inside the node
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
await expect(nodeBody).toBeVisible()
// Widgets section should exist and have at least one widget
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
})
test('Enter Subgraph button navigates into subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
const widgets = nodeBody.locator('.lg-node-widgets > div')
const count = await widgets.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Reactivity', () => {
test('Value changes on promoted widget sync to interior widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'promoted-value-sync-test'
// Type into the promoted textarea on the SubgraphNode
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Interior CLIPTextEncode textarea should have the same value
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
})
test('Value changes on interior widget sync to promoted widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'interior-value-sync-test'
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Type into the interior CLIPTextEncode textarea
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await interiorTextarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate back to parent graph
await exitSubgraphViaBreadcrumb(comfyPage)
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(testContent)
})
test('Value persists through repeated navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'persistence-through-navigation'
// Set value on promoted widget
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
// Navigate in and out multiple times
for (let i = 0; i < 3; i++) {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
await exitSubgraphViaBreadcrumb(comfyPage)
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(testContent)
}
})
})
test.describe('Manual Promote/Demote via Context Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Can promote a widget from inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get the KSampler node (id 1) inside the subgraph
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
// Right-click on the KSampler's "steps" widget (index 2) to promote it
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
// Look for the Promote Widget menu entry
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
// SubgraphNode should now have the promoted widget
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(widgetCount).toBeGreaterThan(0)
})
test('Can un-promote a widget from inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
// First promote a canvas-rendered widget (KSampler "steps")
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await exitSubgraphViaBreadcrumb(comfyPage)
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(initialWidgetCount).toBeGreaterThan(0)
// Navigate back in and un-promote
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode2.navigateIntoSubgraph()
const stepsWidget2 = await (
await comfyPage.nodeOps.getNodeRefById('1')
).getWidget(2)
const widgetPos2 = await stepsWidget2.getPosition()
await comfyPage.canvas.click({
position: widgetPos2,
button: 'right',
force: true
})
await comfyPage.nextFrame()
const unpromoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Un-Promote Widget/ })
await expect(unpromoteEntry).toBeVisible()
await unpromoteEntry.click()
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
// SubgraphNode should have fewer widgets
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
})
})
test.describe('Pseudo-Widget Promotion', () => {
test('Promotion store tracks pseudo-widget entries for subgraph with preview node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames.length).toBeGreaterThan(0)
expect(promotedNames).toContain('filename_prefix')
})
test('Converting SaveImage to subgraph promotes its widgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
// Select SaveImage (id 9)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.click('title')
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
// SaveImage is a recommended node, so filename_prefix should be promoted
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames.length).toBeGreaterThan(0)
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
expect(widgetCount).toBeGreaterThan(0)
})
})
test.describe('Legacy And Round-Trip Coverage', () => {
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
).toBe(false)
expect(
promotedWidgets.some(
([interiorNodeId, widgetName]) =>
interiorNodeId !== '-1' && widgetName === 'batch_size'
)
).toBe(true)
})
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(beforePromoted).toContain('text')
const serialized = await comfyPage.page.evaluate(() => {
return window.app!.graph!.serialize()
})
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
return window.app!.loadGraphData(workflow)
}, serialized as ComfyWorkflowJSON)
await comfyPage.nextFrame()
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(afterPromoted).toContain('text')
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(widgetCount).toBeGreaterThan(0)
})
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
const originalPos = await originalNode.getPosition()
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
await comfyPage.nextFrame()
const subgraphNodeIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph.nodes
.filter(
(n) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((n) => String(n.id))
})
expect(subgraphNodeIds.length).toBeGreaterThan(1)
for (const nodeId of subgraphNodeIds) {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
}
})
})
test.describe('Vue Mode - Promoted Preview Content', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('SubgraphNode with preview node shows hasCustomContent area in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(subgraphVueNode).toBeVisible()
// The node body should exist
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
await expect(nodeBody).toBeVisible()
})
})
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
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
// Verify promotions exist
const namesBefore = await getPromotedWidgetNames(comfyPage, '11')
expect(namesBefore.length).toBeGreaterThan(0)
// Delete the subgraph node
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
// Node no longer exists, so promoted widgets should be gone
const nodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.canvas.graph!.getNodeById('11')
})
expect(nodeExists).toBe(false)
})
test('Removing I/O slot removes associated promoted widget', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(initialWidgetCount).toBeGreaterThan(0)
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Remove the text input slot
await comfyPage.subgraph.rightClickInputSlot('text')
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await comfyPage.nextFrame()
// Navigate back via breadcrumb
await comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.waitFor({ state: 'visible', timeout: 5000 })
const homeBreadcrumb = comfyPage.page.getByRole('link', {
name: 'subgraph-with-promoted-text-widget'
})
await homeBreadcrumb.waitFor({ state: 'visible' })
await homeBreadcrumb.click()
await comfyPage.nextFrame()
// Widget count should be reduced
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
})
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -2,15 +2,20 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
import {
getPromotedWidgetNames,
getPromotedWidgetCountByName
} from '../../../../helpers/promotedWidgets'
test.describe('Vue Nodes Image Preview', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})
async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
@@ -25,16 +30,19 @@ test.describe('Vue Nodes Image Preview', () => {
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return imagePreview
return {
imagePreview,
nodeId: String(loadImageNode.id)
}
}
// TODO(#8143): Re-enable after image preview sync is working in CI
test.fixme('opens mask editor from image preview button', async ({
comfyPage
}) => {
const imagePreview = await loadImageOnNode(comfyPage)
const { imagePreview } = await loadImageOnNode(comfyPage)
await imagePreview.locator('[role="img"]').hover()
await imagePreview.locator('[role="img"]').focus()
await comfyPage.page.getByLabel('Edit or mask image').click()
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
@@ -42,9 +50,11 @@ test.describe('Vue Nodes Image Preview', () => {
// TODO(#8143): Re-enable after image preview sync is working in CI
test.fixme('shows image context menu options', async ({ comfyPage }) => {
await loadImageOnNode(comfyPage)
const { nodeId } = await loadImageOnNode(comfyPage)
const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.lg-node-header')
await nodeHeader.click()
await nodeHeader.click({ button: 'right' })
@@ -55,4 +65,69 @@ test.describe('Vue Nodes Image Preview', () => {
await expect(contextMenu.getByText('Save Image')).toBeVisible()
await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
})
test(
'renders promoted image previews for each subgraph node',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.vueNodes.waitForNodes()
const firstSubgraphNode = comfyPage.vueNodes.getNodeLocator('7')
const secondSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
await expect(firstSubgraphNode).toBeVisible()
await expect(secondSubgraphNode).toBeVisible()
const firstPromotedWidgets = await getPromotedWidgetNames(comfyPage, '7')
const secondPromotedWidgets = await getPromotedWidgetNames(comfyPage, '8')
expect(firstPromotedWidgets).toEqual([
'$$canvas-image-preview',
'$$canvas-image-preview'
])
expect(secondPromotedWidgets).toEqual(['$$canvas-image-preview'])
expect(
await getPromotedWidgetCountByName(
comfyPage,
'7',
'$$canvas-image-preview'
)
).toBe(2)
expect(
await getPromotedWidgetCountByName(
comfyPage,
'8',
'$$canvas-image-preview'
)
).toBe(1)
await expect(
firstSubgraphNode.locator('.lg-node-widgets')
).not.toContainText('$$canvas-image-preview')
await expect(
secondSubgraphNode.locator('.lg-node-widgets')
).not.toContainText('$$canvas-image-preview')
await comfyPage.command.executeCommand('Comfy.Canvas.FitView')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
const firstPreviewImages = firstSubgraphNode.locator('.image-preview img')
const secondPreviewImages =
secondSubgraphNode.locator('.image-preview img')
await expect(firstPreviewImages).toHaveCount(2, { timeout: 30_000 })
await expect(secondPreviewImages).toHaveCount(1, { timeout: 30_000 })
await expect(firstPreviewImages.first()).toBeVisible({ timeout: 30_000 })
await expect(firstPreviewImages.nth(1)).toBeVisible({ timeout: 30_000 })
await expect(secondPreviewImages.first()).toBeVisible({ timeout: 30_000 })
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-multiple-promoted-previews.png'
)
}
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -2,6 +2,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
import { TestIds } from '../../../fixtures/selectors'
test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -19,10 +20,16 @@ test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
})
await loadCheckpointNode.getByText('Load Checkpoint').click()
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container')
.locator('i[data-testid="blue"]')
const colorPickerButton = comfyPage.page.getByTestId(
TestIds.selectionToolbox.colorPickerButton
)
await colorPickerButton.click()
const colorPickerGroup = comfyPage.page.getByRole('group').filter({
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
})
await colorPickerGroup
.getByTestId(TestIds.selectionToolbox.colorBlue)
.click()
await expect(comfyPage.canvas).toHaveScreenshot(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -9,17 +9,20 @@ test.describe('Vue Upload Widgets', () => {
await comfyPage.vueNodes.waitForNodes()
})
test(
'should hide canvas-only upload buttons',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')
await comfyPage.vueNodes.waitForNodes()
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')
await comfyPage.vueNodes.waitForNodes()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-upload-widgets.png'
)
}
)
await expect(
comfyPage.page.getByText('choose file to upload', { exact: true })
).not.toBeVisible()
await expect
.poll(() => comfyPage.page.getByText('Error loading image').count())
.toBeGreaterThan(0)
await expect
.poll(() => comfyPage.page.getByText('Error loading video').count())
.toBeGreaterThan(0)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -0,0 +1,58 @@
# Widget Serialization: `widget.serialize` vs `widget.options.serialize`
Two properties named `serialize` exist at different levels of a widget object. They control different serialization layers and are checked by completely different code paths.
**`widget.serialize`** — Controls **workflow persistence**. Checked by `LGraphNode.serialize()` and `configure()` when reading/writing `widgets_values` in the workflow JSON. When `false`, the widget is skipped in both serialization and deserialization. Used for UI-only widgets (image previews, progress text, audio players). Typed as `IBaseWidget.serialize` in `src/lib/litegraph/src/types/widgets.ts`.
**`widget.options.serialize`** — Controls **prompt/API serialization**. Checked by `executionUtil.ts` when building the API payload sent to the backend. When `false`, the widget is excluded from prompt inputs. Used for client-side-only controls (`control_after_generate`, combo filter lists) that the server doesn't need. Typed as `IWidgetOptions.serialize` in `src/lib/litegraph/src/types/widgets.ts`.
These correspond to the two data formats in `ComfyMetadata` embedded in output files (PNG, GLTF, WebM, AVIF, etc.): `widget.serialize``ComfyMetadataTags.WORKFLOW`, `widget.options.serialize``ComfyMetadataTags.PROMPT`.
## Permutation table
| `widget.serialize` | `widget.options.serialize` | In workflow? | In prompt? | Examples |
| ------------------ | -------------------------- | ------------ | ---------- | -------------------------------------------------------------------- |
| ✅ default | ✅ default | Yes | Yes | seed, cfg, sampler_name |
| ✅ default | ❌ false | Yes | No | control_after_generate, combo filter list |
| ❌ false | ✅ default | No | Yes | No current usage (would be a transient value computed at queue time) |
| ❌ false | ❌ false | No | No | Image/video previews, audio players, progress text |
## Gotchas
- `addWidget('combo', name, value, cb, { serialize: false })` puts `serialize` into `widget.options`, **not** onto `widget` directly. These are different properties consumed by different systems.
- `LGraphNode.serialize()` checks `widget.serialize === false` (line 967). It does **not** check `widget.options.serialize`. A widget with `options.serialize = false` is still included in `widgets_values`.
- `LGraphNode.serialize()` only writes `widgets_values` if `this.widgets` is truthy. Nodes that create widgets dynamically (like `PrimitiveNode`) will have no `widgets_values` in serialized output if serialized before widget creation — even if `this.widgets_values` exists on the instance from a prior `configure()` call.
- `widget.options.serialize` is typed as `IWidgetOptions.serialize` — both properties share the name `serialize` but live at different levels of the widget object.
## PrimitiveNode and copy/paste
`PrimitiveNode` creates widgets dynamically on connection — it starts as an empty polymorphic node and morphs to match its target widget in `_onFirstConnection()`. This interacts badly with the copy/paste pipeline.
### The clone→serialize gap
`LGraphCanvas._serializeItems()` copies nodes via `item.clone()?.serialize()` (line 3911). For PrimitiveNode this fails:
1. `clone()` calls `this.serialize()` on the **original** node (which has widgets, so `widgets_values` is captured correctly).
2. `clone()` creates a **fresh** PrimitiveNode via `LiteGraph.createNode()` and calls `configure(data)` on it — this stores `widgets_values` on the instance.
3. But the fresh PrimitiveNode has no `this.widgets` (widgets are created only on connection), so when `serialize()` is called on the clone, `LGraphNode.serialize()` skips the `widgets_values` block entirely (line 964: `if (widgets && this.serialize_widgets)`).
Result: `widgets_values` is silently dropped from the clipboard data.
### Why seed survives but control_after_generate doesn't
When the pasted PrimitiveNode reconnects to the pasted target node, `_createWidget()` copies `theirWidget.value` from the target (line 254). This restores the **primary** widget value (e.g., `seed`).
But `control_after_generate` is a **secondary** widget created by `addValueControlWidgets()`, which reads its initial value from `this.widgets_values?.[1]` (line 263). That value was lost during clone→serialize, so it falls back to `'fixed'` (line 265).
See [ADR-0006](adr/0006-primitive-node-copy-paste-lifecycle.md) for proposed fixes and design tradeoffs.
## Code references
- `widget.serialize` checked: `src/lib/litegraph/src/LGraphNode.ts` serialize() and configure()
- `widget.options.serialize` checked: `src/utils/executionUtil.ts`
- `widget.options.serialize` set: `src/scripts/widgets.ts` addValueControlWidgets()
- `widget.serialize` set: `src/composables/node/useNodeImage.ts`, `src/extensions/core/previewAny.ts`, etc.
- Metadata types: `src/types/metadataTypes.ts`
- PrimitiveNode: `src/extensions/core/widgetInputs.ts`
- Copy/paste serialization: `src/lib/litegraph/src/LGraphCanvas.ts` `_serializeItems()`
- Clone: `src/lib/litegraph/src/LGraphNode.ts` `clone()`

View File

@@ -0,0 +1,45 @@
# 6. PrimitiveNode Copy/Paste Lifecycle
Date: 2026-02-22
## Status
Proposed
## Context
PrimitiveNode creates widgets dynamically on connection. When copied, the clone has no `this.widgets`, so `LGraphNode.serialize()` drops `widgets_values` from the clipboard data. This causes secondary widget values (e.g., `control_after_generate`) to be lost on paste. See [WIDGET_SERIALIZATION.md](../WIDGET_SERIALIZATION.md#primitiveno-and-copypaste) for the full mechanism.
Related: [#1757](https://github.com/Comfy-Org/ComfyUI_frontend/issues/1757), [#8938](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8938)
## Options
### A. Minimal fix: override `serialize()` on PrimitiveNode
Override `serialize()` to fall back to `this.widgets_values` (set during `configure()`) when the base implementation omits it due to missing `this.widgets`.
- **Pro**: No change to connection lifecycle semantics. Lowest risk.
- **Pro**: Doesn't affect workflow save/load (which already works via `onAfterGraphConfigured`).
- **Con**: Doesn't address the deeper design issue — primitives are still empty on copy.
### B. Clone-configured-instance lifecycle
On copy, the primitive is a clone of the configured instance (with widgets intact). On disconnect or paste without connections, it returns to empty state.
- **Pro**: Copy→serialize captures `widgets_values` correctly. Matches OOP expectations.
- **Pro**: Secondary widget state survives round-trips without special-casing.
- **Con**: `input.widget[CONFIG]` allows extensions to make PrimitiveNode create a _different_ widget than the target. Widget config is derived at connection time, not stored, so cloning the configured state may not be faithful.
- **Con**: Deserialization ordering — `configure()` runs before links are restored. PrimitiveNode needs links to know what widgets to create. `onAfterGraphConfigured()` handles this for workflow load, but copy/paste uses a different code path.
- **Con**: Higher risk of regressions in extension compatibility.
### C. Projection model (like Subgraph widgets)
Primitives act as a synchronization mechanism — no own state, just a projection of the target widget's resolved value.
- **Pro**: Cleanest conceptual model. Eliminates state duplication.
- **Con**: Primitives can connect to multiple targets. Projection with multiple targets is ambiguous.
- **Con**: Major architectural change with broad impact.
## Decision
Pending. Option A is the most pragmatic first step. Option B can be revisited after Option A ships and stabilizes.

View File

@@ -57,7 +57,7 @@ settings: testData as any
// Don't add test settings to src/schemas/apiSchema.ts
// ❌ Don't chain through unknown to bypass types
data as unknown as SomeType // Avoid; prefer explicit typings or helpers
data as unknown as SomeType // Avoid; prefer `as Partial<SomeType> as SomeType` or explicit typings
```
### Accessing Internal State

View File

@@ -36,6 +36,25 @@ When you must handle uncertain types, prefer these approaches in order:
- Don't expose internal implementation types (e.g., Pinia store internals)
- Reactive refs (`ComputedRef<T>`) should be unwrapped before exposing
## Avoiding Circular Dependencies
Extract type guards and their associated interfaces into **leaf modules** — files with only `import type` statements. This keeps them safe to import from anywhere without pulling in heavy transitive dependencies.
```typescript
// ✅ myTypes.ts — leaf module (only type imports)
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export interface MyView extends IBaseWidget {
/* ... */
}
export function isMyView(w: IBaseWidget): w is MyView {
return 'myProp' in w
}
// ❌ myView.ts — heavy module (runtime imports from stores, utils, etc.)
// Importing the type guard from here drags in the entire dependency tree.
```
## Utility Libraries
- Use `es-toolkit` for utility functions (not lodash)

View File

@@ -39,6 +39,10 @@ Prefer Vue native options when available:
- Use inline Tailwind CSS only (no `<style>` blocks)
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
- Exception: when third-party libraries render runtime DOM outside Vue templates
(for example xterm internals inside PrimeVue terminal wrappers), scoped
`:deep()` selectors are allowed. Add a brief inline comment explaining why the
exception is required.
## Best Practices

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

@@ -39,7 +39,11 @@ const config: KnipConfig = {
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts'
'src/scripts/ui/components/splitButton.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.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

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