Compare commits

..

77 Commits

Author SHA1 Message Date
jaeone94
db424661e7 Merge branch 'main' into feat/error-tab-node-installation 2026-02-24 14:23:26 +09:00
jaeone94
d9b2e1204f fix(useApplyChanges): catch rejection from fire-and-forget conflict analysis
void discards the promise entirely; .catch() preserves the
fire-and-forget intent while preventing a silent unhandled rejection
if runFullConflictAnalysis throws asynchronously.
2026-02-24 14:01:08 +09: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
jaeone94
a7d33edc4d fix(useApplyChanges): add concurrency guard to prevent re-entrant invocations
Claim the isRestarting lock synchronously at the top of applyChanges
before any await. A concurrent caller that arrives while a restart is
already in flight now returns immediately rather than clobbering the
shared flags and registering a second reconnected listener.
Previously, isRestarting was reset to false at entry and only set back
to true inside the try block after the first await, leaving a window
where a second call could pass the guard and register a duplicate
listener — causing post-reconnect work to run twice on a single event.
2026-02-24 13:49:06 +09: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
jaeone94
bdc080823f fix(manager): stop opening dialog for legacy-only commands and localize install button label
useManagerState.ts:
- Remove managerDialog.show(ManagerTab.All) from the isLegacyOnly branch;
  when a command has no NEW_UI equivalent the toast alone should be shown
  and the manager must not open
- Convert ManagerTab import to import type since it is now type-only
PackInstallButton.vue:
- Remove hardcoded default 'Install' from the label prop so computedLabel
  can reach its i18n fallback (t('g.install') / t('manager.installSelected'));
  the previous default made that branch permanently dead code
2026-02-24 13:30:27 +09:00
jaeone94
8dbd3b206d fix(useApplyChanges): reset state on re-entry and surface post-reconnect errors
Reset isRestarting and isRestartCompleted at the start of applyChanges so
that a second invocation on the shared singleton does not begin with stale
state from the previous restart cycle.
Add a catch block inside onReconnect so that errors thrown by
RefreshNodeDefinitions or reloadCurrentWorkflow are logged to the console
rather than silently becoming unhandled promise rejections. The finally
block continues to guarantee state cleanup regardless of outcome.
2026-02-24 13:26:03 +09:00
Jin Yi
be70f6c1e6 [refactor] Merge sort button into view settings popover (#9143) 2026-02-24 13:18:07 +09:00
jaeone94
efd32f0f13 fix(manager): address additional CodeRabbit review items
useApplyChanges.ts:
- Hoist onReconnect and stopReconnectListener before the try block so
  the variable can be declared with const; behavior is identical since
  the reconnected event cannot fire before rebootComfyUI() is called

usePackInstall.ts:
- Hoist useConflictDetection() to composable setup scope; calling a
  Vue composable inside an async runtime function skips lifecycle
  registration and leaks reactive state on every invocation
- Wrap Promise.all in try/finally so managerStore.installPack.clear()
  is always called even when an install fails, preventing the UI from
  staying stuck in the installing state
- Guard hasConflict with an early return when conflictInfo is null,
  blocking installation instead of silently bypassing the conflict check

executionErrorStore.ts:
- Remove missingAncestorExecutionIds and errorAncestorExecutionIds
  from the store's public API; both are internal implementation
  details only consumed by their respective isContainer* helpers
2026-02-24 12:47:38 +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
3fc401a892 refactor(errors): extract execution utilities and fix nested subgraph error detection
Add nodeExecutionUtil.ts with getAncestorExecutionIds, getParentExecutionIds,
and buildSubgraphExecutionPaths, consolidating duplicated prefix-generation logic
from executionErrorStore.ts and app.ts.
Refactor executionErrorStore.ts:
- Extract long lastNodeErrors watcher into module-level helpers:
  clearAllNodeErrorFlags, markNodeSlotErrors, applyNodeError
- Add missingAncestorExecutionIds, activeMissingNodeGraphIds,
  isContainerWithMissingNode following the same pattern as
  errorAncestorExecutionIds / isContainerWithInternalError
- Update LGraphNode.vue hasAnyError to include isContainerWithMissingNode,
  so subgraph container nodes highlight when a nested node is missing
Fix buildSubgraphExecutionPaths to return Map<string, string[]> instead of
Map<string, string>, resolving a bug where only the first container of a
given subgraph definition was tracked; all instances are now processed.
Address CodeRabbit review items:
- Fix race condition in useErrorGroups.ts: mark all pending types RESOLVING
  synchronously before the first await to prevent duplicate resolutions
- Fix NaN sort for composite execution IDs (e.g. '10:5') by replacing
  Number() subtraction with localeCompare({ numeric: true })
- Localize hardcoded error strings in usePackInstall.ts and app.ts
  (manager.packInstall.nodeIdRequired, g.inSubgraph)
2026-02-24 00:11:47 +09:00
jaeone94
6a756d8de4 Merge branch 'main' into feat/error-tab-node-installation 2026-02-23 20:44:49 +09: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
jaeone94
c1e54f4839 feat(error-tab): integrate Missing Node support and related UI/functional improvements
- Add MissingNodeCard and MissingPackGroupRow components to support error summaries and actions for Missing Nodes in the right panel TabErrors
- Improve parsing and grouping states to extract detailed info (nodeId, cnrId) when collecting Missing Nodes in app.ts and executionErrorStore
- Extract and refactor usePackInstall and useApplyChanges composables to allow node pack installation and ComfyUI restart directly from Missing Node info
- Add optional parameters to ManagerDialog to support deep linking (initialPackId) for immediate search and auto-selection of specific packages
- Add i18n support for Missing Node texts and reflect UI feedback regarding button height and layout in ErrorNodeCard
2026-02-23 03:25:29 +09: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
Alexander Brown
f9317e7078 fix: refresh deps and clear production audit vulnerabilities (#9068)
## Summary
- refresh workspace dependency catalog and lockfile for security and
maintenance updates
- resolve production audit findings (runtime dependencies now report
clean)
- align Tiptap dependencies on v2 and pin `@tiptap/pm` to avoid
mixed-version type issues
- update Storybook preview toolbar config for Storybook 10 typing
(`showName` removed)

## Validation
- `pnpm typecheck` 
- `pnpm lint`  (warnings only)
- `pnpm test:unit` 
- `pnpm audit --prod` 
- `pnpm audit` ⚠️ remaining dev-only transitive advisories
(`minimatch`/`ajv`/`brace-expansion`) in upstream toolchain deps

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9068-fix-refresh-deps-and-clear-production-audit-vulnerabilities-30e6d73d36508122b778ec3ca99d41a4)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-02-21 17:44:33 -08:00
Comfy Org PR Bot
79e71a5761 1.41.1 (#9069)
Patch version increment to 1.41.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9069-1-41-1-30f6d73d365081148845cb3024b7987f)
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-21 17:19:38 -08:00
Christian Byrne
3e4d273832 fix: update imagePreview browser tests to use current fixture APIs (#8995)
The browser tests added in #8143 were failing on main because they were
written against stale `ComfyPage` APIs that were refactored in #8510
(merged Feb 3, before #8143 merged Feb 18).

### Changes
- `comfyPage.dragAndDropFile` → `comfyPage.dragDrop.dragAndDropFile`
- `comfyPage.setSetting` → `comfyPage.settings.setSetting`
- `comfyPage.loadWorkflow` → `comfyPage.workflow.loadWorkflow`
- `comfyPage.getNodeRefsByType` → `comfyPage.nodeOps.getNodeRefsByType`
- Fix `comfyPage` type parameter to use `ComfyPage` import
- Remove `test.fixme` since root cause was API mismatch, not test logic

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8995-fix-update-imagePreview-browser-tests-to-use-current-fixture-APIs-30d6d73d365081219c1eda4ea7251160)
by [Unito](https://www.unito.io)
2026-02-21 16:56:25 -08:00
jaeone94
8aa4e36fd5 [refactor] Extract executionErrorStore from executionStore (#9060)
## Summary
Extracts error-related state and logic from `executionStore` into a
dedicated `executionErrorStore` for better separation of concerns.

## Changes
- **New store**: `executionErrorStore` with all error state
(`lastNodeErrors`, `lastExecutionError`, `lastPromptError`), computed
properties (`hasAnyError`, `totalErrorCount`,
`activeGraphErrorNodeIds`), and UI state (`isErrorOverlayOpen`,
`showErrorOverlay`, `dismissErrorOverlay`)
- **Moved util**: `executionIdToNodeLocatorId` extracted to
`graphTraversalUtil`, reusing `traverseSubgraphPath` and accepting
`rootGraph` as parameter
- **Updated consumers**: 12 files updated to import from
`executionErrorStore`
- **Backward compat**: Deprecated getters retained in `ComfyApp` for
extension compatibility

## Review Focus
- Deprecated getters in `app.ts` — can be removed in a future
breaking-change PR once extension authors migrate

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9060-refactor-Extract-executionErrorStore-from-executionStore-30e6d73d36508101973de835ab6b199f)
by [Unito](https://www.unito.io)
2026-02-21 16:51:22 -08:00
Christian Byrne
d9fdb01d9b fix: handle failed global subgraph blueprint loading gracefully (#9063)
## Summary

Fix "Failed to load subgraph blueprints Error: [ASSERT] Workflow content
should be loaded" error occurring on cloud.

## Changes

- **What**: `getGlobalSubgraphData` now throws on API failure instead of
returning empty string, global blueprint data is validated before
loading, and individual global blueprint errors are properly propagated
to the toast/console reporting instead of being silently swallowed.

## Review Focus

Two root causes were fixed:
1. `getGlobalSubgraphData` returned `""` on failure — this empty string
was set as `originalContent`, which is falsy, triggering the assertion
in `ComfyWorkflow.load()`.
2. `loadInstalledBlueprints` used an internal `Promise.allSettled` whose
results were discarded, so individual global blueprint failures never
reached the error reporting in `fetchSubgraphs`.

Fixes COM-15199

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9063-fix-handle-failed-global-subgraph-blueprint-loading-gracefully-30e6d73d3650818d9cc8ecf81cd0264e)
by [Unito](https://www.unito.io)
2026-02-21 16:30:01 -08:00
Alexander Brown
8f48b11f6a fix: resolve missing i18n key warnings (#9064)
## Summary

Fix multiple i18n missing key console warnings by correcting key paths
and adding missing translations.

## Changes

- **What**: 
- `ScrubableNumberInput`: Fixed references to non-existent
`g.ariaLabel.decrement`/`g.ariaLabel.increment` keys → use
`g.decrement`/`g.increment`
- `SidebarIcon`: Replaced `t()` with `st()` (safe translate) to prevent
double-translation when parent components pass pre-translated strings
- `en/main.json`: Added missing `menuLabels.Copy`, `menuLabels.Paste`,
`menuLabels.Select All` keys

## Review Focus

The `SidebarIcon` change from `t()` to `st()` is the key design
decision. `SidebarIcon` receives both i18n keys (from sidebar tabs via
`SideToolbar`) and pre-translated strings (from dedicated sidebar button
components). Using `st()` (which checks `te()` before translating)
handles both cases without warnings.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9064-fix-resolve-missing-i18n-key-warnings-30e6d73d3650816eaad3ce030f9c1d3f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-21 15:26:37 -08:00
Christian Byrne
bb40ffae3c feat: add survey registry for feature survey configurations (#8355)
## Summary
Adds a centralized registry for feature survey configurations.

## Changes
- Add `surveyRegistry.ts` with `FEATURE_SURVEYS` record for survey
configs
- Add helper functions `getSurveyConfig()` and `getEnabledSurveys()`
- Export `FeatureId` type for type-safe feature references

## Part of Nightly Survey System
This is part 3 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 (this PR)
4. feat/survey-popover - NightlySurveyPopover.vue
5. feat/survey-integration - NightlySurveyController.vue

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8355-feat-add-survey-registry-for-feature-survey-configurations-2f66d73d365081faae6bda0c14c069d9)
by [Unito](https://www.unito.io)
2026-02-21 13:38:06 -08:00
Dante
de131133bd fix: make serverFeatureFlags reactive via Vue ref (#9051) 2026-02-21 18:43:58 +09:00
Benjamin Lu
17f34788dc fix: disable inspect for non-previewable assets (#8989)
## Summary
Prevent text/other assets from opening a blank fullscreen viewer by
restricting inspect/zoom to previewable media kinds.

## Changes
- Add `isPreviewableMediaType` helper in shared `formatUtil`.
- Gate inspect/zoom actions in `AssetsSidebarTab`, `MediaAssetCard`, and
`MediaAssetContextMenu` using an allowlist (`image`, `video`, `audio`,
`3D`).
- Build gallery items from previewable assets only.
- Add unit tests for `isPreviewableMediaType`.

## Why
`ResultGallery` only renders image/video/audio; text/other assets could
previously enter fullscreen with no renderable content.

## Review Focus
- Verify text/other assets no longer show Inspect and do not open
fullscreen.
- Verify image/video/audio behavior is unchanged.
- Verify 3D still opens the 3D viewer dialog.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8989-fix-disable-inspect-for-non-previewable-assets-30c6d73d36508103a9b9da4fe50236ea)
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-21 01:43:14 -08:00
Benjamin Lu
9184f9bce4 fix: support text and misc generated asset states (#8914)
## Summary
Align generated-asset state classification to a single shared source and
implement the missing text/misc states in both card and list previews.

## Changes
- **What**:
- Extended `getMediaTypeFromFilename` in
`packages/shared-frontend-utils` to return `text` and `other`, and
changed unknown/no-extension fallback from `image` to `other`.
- Added text extension handling (`txt`, `md`, `json`, `csv`, `yaml/yml`,
`xml`, `log`) and kept existing media kinds.
- Updated generated-assets UI to use shared media-type detection
directly (removed the local generated-assets classifier).
  - Added text and misc card preview components:
    - `text` -> `icon-[lucide--text]`
    - `other` -> `icon-[lucide--check-check]`
- Updated list-item preview behavior so only `image`/`video` use preview
media URLs; `text`/`other` use icon fallback.
- Widened media kind schema for asset display metadata to include `text`
and `other`.
- **Breaking**: No API breaking changes; internal media kind union
widened for frontend asset display paths.
- **Dependencies**: None.

## Review Focus
- Verify generated text assets render paragraph/text icon state in card
+ list.
- Verify unknown/misc assets consistently render double-check icon state
in card + list.
- Verify existing image/video/audio/3D behavior remains unchanged.

## Screenshots (if applicable)
<img width="282" height="158" alt="image"
src="https://github.com/user-attachments/assets/76cf2d1b-9d34-4c7c-92a1-50bbc55871e5"
/>
<img width="432" height="489" alt="image"
src="https://github.com/user-attachments/assets/024fece3-f241-484d-a37e-11948559ebbc"
/>
<img width="421" height="494" alt="image"
src="https://github.com/user-attachments/assets/ed64ba0c-bf46-4c3b-996e-4bc613ee029e"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8914-fix-support-text-and-misc-generated-asset-states-3096d73d365081f28ca7c32f306e4b50)
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-21 01:27:28 -08:00
Comfy Org PR Bot
ea7bbb744f 1.41.0 (#9059)
Minor version increment to 1.41.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9059-1-41-0-30e6d73d36508103b6cbef6d402f05de)
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-21 01:13:57 -08:00
Benjamin Lu
25880aa024 fix: update asset video previews for card and list (#8908)
## Summary
Render generated video previews in list items using a real video element (instead of an image element (this caused errors before)) and include a custom play button with dimming per [the designs](https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3928-39270&m=dev).

## Changes
- **What**:
  - List item preview path now renders a `video` element when `isVideoPreview` is true.
  - Video list preview uses `preload="metadata"`, `muted`, `playsinline`, and `pointer-events-none` so row click behavior stays unchanged.
  - Kept the custom overlay/play affordance and increased overlay dimming from `bg-black/10` to `bg-black/15`.
  - Updated tests for `AssetsListItem`, `MediaVideoTop`, and `AssetsSidebarListView`.

## Review Focus
- Confirm list item click behavior still opens/selects asset (no inline playback interaction).
- Confirm video list previews now show actual video frame path instead of broken image fallback.

## Limitation
Backend does not currently provide a dedicated poster/thumbnail image for video outputs in the job preview payload. In the frontend today, we can either show a video icon placeholder, or load/render the full video itself to obtain a preview frame.

## Screenshots (if applicable)
<img width="427" height="499" alt="image" src="https://github.com/user-attachments/assets/3f974817-9d73-4fee-9fa5-2f1f68942c06" />
<img width="230" height="92" alt="image" src="https://github.com/user-attachments/assets/1fbfdd6a-72dd-47e2-96bf-8f7eb41c36f2" />
2026-02-21 00:59:36 -08:00
Christian Byrne
40aa7c5974 feat(persistence): fix QuotaExceededError and cross-workspace draft leakage (#8520)
## Summary

Completes the workflow persistence overhaul by integrating the new draft
system into the app and migrating existing data. Fixes two critical
bugs:

1. **QuotaExceededError** - localStorage fills up with workflow drafts,
breaking auto-save
2. **Cross-workspace data leakage** - Drafts from one ComfyUI instance
appear in another

## Changes

- **What**: 
- `useWorkflowPersistenceV2.ts` - Main composable that hooks into graph
changes with 512ms debounce
- `migrateV1toV2.ts` - One-time migration of existing drafts to the new
scoped format
  - Updated E2E tests for new storage key patterns
- **Why**: Users lose work when storage quota is exceeded, and see
confusing workflows from other instances

## How It Works

- **Workspace scoping**: Each ComfyUI instance (identified by server
URL) has isolated draft storage
- **LRU eviction**: When storage is full, oldest drafts are
automatically removed (keeps 32 most recent)
- **Tab isolation**: Each browser tab tracks its own active/open
workflows via sessionStorage
- **Debounced saves**: Graph changes are batched with 512ms delay to
reduce storage writes

## Migration

Existing V1 drafts are automatically migrated on first load. The
migration:
1. Reads drafts from old `Comfy.Workflow.*` keys
2. Converts to new workspace-scoped format
3. Cleans up old keys after successful migration

---
*Part 4 of 4 in the workflow persistence improvements stack*

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
2026-02-21 00:57:50 -08:00
Comfy Org PR Bot
3d3a4dd1a2 1.40.10 (#9058)
Patch version increment to 1.40.10

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9058-1-40-10-30e6d73d36508133b358d9f35840055a)
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-21 00:57:11 -08:00
Christian Byrne
39af93ae3e feat: read category from blueprint subgraph definition (#9053)
Read `category` from `definitions.subgraphs[0].category` in blueprint
JSON files as a fallback default for node categorization.

This allows blueprint authors to set the category directly in the
blueprint file without needing backend `index.json` support. The
precedence order is:
1. Explicit overrides (e.g. `info.category` from API, or `'Subgraph
Blueprints/User'` for user blueprints)
2. `definitions.subgraphs[0].category` from the blueprint JSON content
3. Bare `'Subgraph Blueprints'` fallback

Companion PR: Comfy-Org/ComfyUI#12552 (adds essential blueprints with
categories matching the Figma design)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9053-feat-read-category-from-blueprint-subgraph-definition-30e6d73d3650810ca23bfc5a1e97cb31)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-20 23:58:15 -08:00
Dante
4849d4a6c9 fix: raise graphdialog z-index above menu bars (#9049)
## Summary

- Raise `.graphdialog` z-index from 41 to 1500 so the widget prompt
dialog is no longer hidden behind menu bars when a node is near the top
of the screen.

### Why 1500 instead of 10000?

The issue suggested 10000, but that would place the dialog **above**
context menus (9999) and at the same level as toasts (10000). A value of
1500 is sufficient to sit above the top menu bar (1001) and actionbar
(1300), while staying **below** popovers (1700), context menus (9999),
and toasts (10000).

| Element | z-index |
|---------|---------|
| `.comfyui-body-top` (top menu) | 1001 |
| Actionbar | 1300 |
| **`.graphdialog` (this PR)** | **1500** |
| Popover | 1700 |
| Context menus / search box | 9999 |
| Toast | 10000 |

- Fixes #4573
### test

#### AS IS
<img width="534" height="120" alt="스크린샷 2026-02-21 오후 1 27 56"
src="https://github.com/user-attachments/assets/e58922b4-ae3f-4083-a0e1-06c27efb64af"
/>

#### TO BE
<img width="659" height="140" alt="스크린샷 2026-02-21 오후 1 47 09"
src="https://github.com/user-attachments/assets/980334f4-b5d2-43f5-a237-d7c2a7dfb6c9"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9049-fix-raise-graphdialog-z-index-above-menu-bars-30e6d73d36508172b9b3c728c3628477)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:03:35 -08:00
Christian Byrne
55ee6e7e63 fix: "Add Subgraph to Library" context menu not saving subgraph (#9056)
## Summary

Fix "Add Subgraph to Library" context menu option which was bookmarking
(UI favorite) instead of actually saving the subgraph as a reusable
blueprint.

## Changes

- **What**: Replace `nodeBookmarkStore.addBookmark()` with
`subgraphStore.publishSubgraph()` in `addSubgraphToLibrary`, matching
the working toolbar button behavior. Hide the menu option when multiple
items are selected since `publishSubgraph` requires exactly one
SubgraphNode.

## Review Focus

The original implementation (PR #5218) used `addBookmark` which only
adds a star/favorite — it never called the `publishSubgraph` function
that serializes, prompts for a name, and saves the subgraph as a
blueprint file. The toolbar button (`SaveToSubgraphLibrary.vue`) worked
correctly because it calls the `Comfy.PublishSubgraph` command which
uses `publishSubgraph`.

The multi-select visibility guard (`!hasMultipleSelection`) matches
`SaveToSubgraphLibrary.vue`'s `v-show` guard. "Unpack Subgraph" remains
visible for multi-select since it handles multiple SubgraphNodes
correctly.

Fixes COM-15200

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9056-fix-Add-Subgraph-to-Library-context-menu-not-saving-subgraph-30e6d73d36508177ba7ef97a2fe9b893)
by [Unito](https://www.unito.io)
2026-02-20 22:56:53 -08:00
Yourz
74d285bda9 fix: resolve bookmark lookup for subgraph blueprints (#9057)
## Summary

Bookmarked subgraph blueprint nodes were not appearing in the favorites
tree because `buildBookmarkTree` looked up nodes only in
`nodeDefsByName`, which excludes subgraph blueprints.

## Changes

- **What**: Added `allNodeDefsByName` computed in `nodeDefStore` that
includes both regular nodes and subgraph blueprints. Updated
`nodeBookmarkStore.buildBookmarkTree` to use it for bookmark resolution.

## Review Focus

- `allNodeDefsByName` iterates over `nodeDefs` (which merges
`subgraphBlueprints` + `nodeDefsByName`). Confirm this doesn't introduce
performance concerns for large node sets.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9057-fix-resolve-bookmark-lookup-for-subgraph-blueprints-30e6d73d365081259bcae9d0c197de43)
by [Unito](https://www.unito.io)
2026-02-21 14:48:57 +08:00
Christian Byrne
1bcebd8293 fix: display Blueprint badge instead of UUID for global subgraph blueprints (#9048)
## Problem

The node search box badge displays long UUID strings (e.g.,
`comfyui-ltx-video-0fbc55c6-...`) for global/core blueprints, while
user-created subgraphs correctly show "Blueprint" as the badge.


![image](https://github.com/user-attachments/assets/placeholder-before.png)

## Root Cause

In `loadGlobalBlueprint`, global blueprints set `python_module:
v.info.node_pack` which contains UUID-like strings. The
`getNodeSource()` function processes `python_module` to determine badge
text, but UUID strings don't match any known pattern, resulting in ugly
badge text.

## Solution

- Change global blueprints to use `python_module: 'blueprint'` so they
display "Blueprint" badge like user blueprints
- Add `isGlobal` boolean flag to `ComfyNodeDef` schema to distinguish
global from user blueprints
- Update `isGlobalBlueprint()` to check the new `isGlobal` flag instead
of `python_module !== 'blueprint'`

## Changes

| File | Change |
|------|--------|
| `src/schemas/nodeDefSchema.ts` | Add optional `isGlobal?: boolean`
field |
| `src/stores/nodeDefStore.ts` | Add `isGlobal` field to
`ComfyNodeDefImpl` class |
| `src/stores/subgraphStore.ts` | Use `python_module: 'blueprint'` +
`isGlobal: true` for global blueprints; update `isGlobalBlueprint()`
check |

## Testing

- [x] Existing unit tests pass
- [x] TypeScript compiles without errors
- [x] Lint passes

Fixes COM-15168

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9048-fix-display-Blueprint-badge-instead-of-UUID-for-global-subgraph-blueprints-30e6d73d3650813cac27e02f8f2088df)
by [Unito](https://www.unito.io)
2026-02-20 22:34:48 -08:00
Dante
dac58ad811 feat: show template version in system info for local builds (#9018)
## Summary
<img width="985" height="417" alt="스크린샷 2026-02-21 오전 1 05 10"
src="https://github.com/user-attachments/assets/4543a5aa-d1ae-4be7-971e-7b1454625829"
/>



- Add `installed_templates_version` and `required_templates_version` to
the system stats schema
- Display the template version as a badge on the About page alongside
ComfyUI and Frontend badges
- Display the template version as a row in the local System Info table
- Highlight badge (red severity) and table row (red text) when installed
version doesn't match required version, so users can quickly spot
outdated templates

Fixes #4006

## Test plan

- [x] Open Settings > About and verify "Templates v{version}" badge
appears
- [x] Verify "Templates Version" row appears in System Info table
- [ ] When `installed_templates_version` matches
`required_templates_version`: badge is default color, table row is
default color
- [ ] When versions don't match: badge turns red, table row turns red
- [ ] When `installed_templates_version` is absent (package not
installed): badge is hidden, table row shows empty

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:21:21 +09:00
Yourz
6ee3803770 feat: implement NodeLibrarySidebarTabV2 with Reka UI components (#8548)
## Summary

Implement a redesigned Node Library sidebar using Reka UI components
with virtualized tree rendering and improved UX.

## Changes

- **What**: 
  - Add three-tab structure (Essential, All, Custom) using Reka UI Tabs
- Implement TreeExplorerV2 with virtualized tree using
TreeRoot/TreeVirtualizer for performance
  - Add node hover preview with teleport to show NodePreview component
  - Implement context menu for toggling favorites on nodes
  - Add search functionality that auto-expands matching folders
- Create panel components: EssentialNodesPanel, AllNodesPanel,
CustomNodesPanel
  - Add 'Open Manager' button in CustomNodesPanel
  - Use custom icons: comfy--node for nodes, ph--folder-fill for folders
  - New node preview component: `NodePreviewCard`
  - Api node folder icon
  - Node drag preview

- **Feature Flag**: Enabled via URL parameter `?nodeRedesign=true`

## Review Focus

- TreeExplorerV2.vue uses `[...expandedKeys]` to prevent internal
mutation by Reka UI TreeRoot
- Context menu injection key is exported from TreeExplorerV2Node.vue and
imported by TreeExplorerV2.vue
- Hover preview uses teleport to
`#node-library-node-preview-container-v2`

## Screenshots (if applicable)

| Feature | Screenshot | 
|---|---|
| All nodes tab |<img width="323" height="761" alt="image"
src="https://github.com/user-attachments/assets/1976222b-83dc-4a1b-838a-2d49aedea3b8"
/>|
| Custom nodes tab | <img width="308" height="748" alt="image"
src="https://github.com/user-attachments/assets/2c23bffb-bdaa-4c6c-8cac-7610fb7f3fb7"
/>|
|Api nodes icon | <img width="299" height="523" alt="image"
src="https://github.com/user-attachments/assets/e9ca05b0-1143-44cf-b227-6462173c7cd0"
/>|
| node preview|<img width="499" height="544" alt="image"
src="https://github.com/user-attachments/assets/8961a7b4-77ae-4e57-99cf-62d9e4e17088"
/>|
| node drag preview | <img width="434" height="289" alt="image"
src="https://github.com/user-attachments/assets/b5838c90-65d4-4bee-b2b3-c41b57870da8"
/>|



Test by adding `?nodeRedesign=true` to the URL

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8548-WIP-feat-implement-NodeLibrarySidebarTabV2-with-Reka-UI-components-2fb6d73d36508134b7e0f75a2c9b976a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-02-20 22:06:09 -08:00
Jin Yi
fd2ffb7100 [feat] Replace view mode toggle with settings dropdown menu (#8950) 2026-02-21 13:49:19 +09:00
Jin Yi
c05644045f fix(assets): dismiss context menu on scroll and outside click (#8952) 2026-02-21 13:19:13 +09:00
Comfy Org PR Bot
5fe902358c 1.40.9 (#9034)
Patch version increment to 1.40.9

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9034-1-40-9-30e6d73d365081a1b1e4e7a1c0b77629)
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>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-20 20:16:39 -08:00
Christian Byrne
c1a569211d fix: skip MatchType recalculation during graph configuration (#9004)
## Summary

Fixes COM-14955: "Bug: Switch node in subgraph causes link disconnection
on export"

## Problem

When a MatchType node (like Switch) inside a subgraph is
configured/restored, `LGraphNode.configure()` calls
`onConnectionsChange` for each input sequentially. The
`withComfyMatchType` callback was running before all links were
restored, seeing incomplete state and incorrectly computing types, which
could cause link disconnection.

## Solution

Add early return when `app.configuringGraph` is true to defer type
recalculation until after all links are restored. This pattern is
already used throughout the codebase:
- `widgetInputs.ts`
- `rerouteNode.ts`
- `customWidgets.ts`

Post-configure recomputation is handled by the existing
`requestAnimationFrame` callback in `applyMatchType`.

## Changes

- `src/core/graph/widgets/dynamicWidgets.ts` - Added 1 line: `if
(app.configuringGraph) return`
- `src/core/graph/widgets/matchTypeConfiguring.test.ts` - New test file
with 3 tests

## Testing

- All existing tests pass
- Added 3 new tests:
  - `skips type recalculation when configuringGraph is true`
  - `performs type recalculation during normal operation`
  - `connects both inputs with same type`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9004-fix-skip-MatchType-recalculation-during-graph-configuration-30d6d73d365081339088ffd8aebba107)
by [Unito](https://www.unito.io)
2026-02-20 19:44:34 -08:00
Jin Yi
2b69d7b49c [bugfix] Fix node replacements not loading due to feature flag timing (#9037)
## Summary
- Node replacements were never loaded because
`useNodeReplacementStore().load()` was called before `api.init()`,
meaning `serverFeatureFlags` was always empty at that point
- Dispatch `feature_flags` as a custom event from `api.ts` and trigger
`load()` in response within `addApiUpdateHandlers()`

## Changes
- **`api.ts`**: Dispatch `feature_flags` custom event after storing
server feature flags (already typed in `BackendApiCalls`)
- **`app.ts`**: Replace eager `load()` call with `feature_flags` event
listener inside `addApiUpdateHandlers()`, consistent with other API
event handlers
- **`nodeReplacementStore.ts`**: Use `api.getServerFeature()` directly
instead of `useFeatureFlags` composable; remove side effects from store
setup
- **`nodeReplacementStore.test.ts`**: Update mocks to match new
`api.getServerFeature` usage

## Review Focus
- Initialization ordering: listener registered before `api.init()`
ensures no missed events

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9037-bugfix-Fix-node-replacements-not-loading-due-to-feature-flag-timing-30e6d73d36508107ae2cd72e83c01e1a)
by [Unito](https://www.unito.io)
2026-02-20 19:16:01 -08:00
Christian Byrne
ee0789e153 fix: promoted widget labels show widgetName instead of "nodeId: widgetName" (#9013)
## Summary

Promoted/proxied widgets on subgraph nodes showed generic "nodeId:
widgetName" labels (e.g., "3: seed") in the LiteGraph renderer. Now they
correctly show just the widget name (e.g., "seed").

## Changes

- **What**: Set proxy widget overlay `label` to `widgetName` instead of
`name` (which contains the unique `"nodeId: widgetName"` format). The
overlay `name` still retains the unique format for internal
identification.
- Added 2 tests verifying proxy widget label defaults and user rename
behavior.

## Review Focus

- The one-line fix in `proxyWidget.ts` line 157: `label: widgetName`
instead of `label: name`
- The overlay `name` property is unchanged — only `label` (used for
display) is affected
- No code parses the label string for identification; all lookups use
`_overlay.nodeId` and `_overlay.widgetName` directly

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9013-fix-promoted-widget-labels-show-widgetName-instead-of-nodeId-widgetName-30d6d73d365081ca8d2feb68585fc187)
by [Unito](https://www.unito.io)
2026-02-20 19:14:57 -08:00
Christian Byrne
c1d07d6424 fix: restore strictExecutionOrder for Storybook Chromatic builds (#9038)
## Summary

Restore `strictExecutionOrder: true` in `.storybook/main.ts`
rolldownOptions, accidentally removed in #8834 as a merge artifact.

## Problem

Chromatic visual regression tests fail on `version-bump-*` release
branches with:
```
Error: __STORYBOOK_MODULE_CORE_EVENTS_PREVIEW_ERRORS__ is not defined
```

Rolldown without `strictExecutionOrder` doesn't guarantee module
execution order. Storybook's internal module system defines
`__STORYBOOK_MODULE_*` globals during initialization — without strict
ordering, downstream code references them before they're defined.

Only `version-bump-*` branches are affected because the
`chromatic-deployment` CI job (which actually loads and extracts stories
at runtime) is gated to those branches.

## Changes

- Restore `strictExecutionOrder: true` in `.storybook/main.ts`
rolldownOptions output config

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9038-fix-restore-strictExecutionOrder-for-Storybook-Chromatic-builds-30e6d73d365081489c53cea131b97a2f)
by [Unito](https://www.unito.io)
2026-02-20 19:03:06 -08:00
469 changed files with 29757 additions and 7081 deletions

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

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

1
.gitignore vendored
View File

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

View File

@@ -100,7 +100,8 @@ const config: StorybookConfig = {
rolldownOptions: {
treeshake: false,
output: {
keepNames: true
keepNames: true,
strictExecutionOrder: true
},
onwarn: (warning, warn) => {
// Suppress specific warnings

View File

@@ -90,7 +90,6 @@ const preview: Preview = {
{ value: 'light', icon: 'sun', title: 'Light' },
{ value: 'dark', icon: 'moon', title: 'Dark' }
],
showName: true,
dynamicTitle: true
}
}

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

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

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

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

@@ -26,7 +26,6 @@ import { Topbar } from './components/Topbar'
import { CanvasHelper } from './helpers/CanvasHelper'
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 +173,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
@@ -219,7 +217,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)

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

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

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

@@ -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'
},
@@ -46,8 +47,12 @@ export const TestIds = {
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}`
@@ -70,6 +75,7 @@ export type TestIdValue =
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (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

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

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

@@ -244,24 +244,19 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
const parsed = await (
await comfyPage.page.waitForFunction(
() => {
const workflow = localStorage.getItem('workflow')
if (!workflow) return null
try {
const data = JSON.parse(workflow)
return Array.isArray(data?.nodes) ? data : null
} catch {
return null
}
},
{ timeout: 3000 }
)
).jsonValue()
const parsed = await comfyPage.page.evaluate(() => {
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', () => {
@@ -401,7 +410,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 +433,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

@@ -37,12 +37,9 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Monitor for server feature flags
const checkInterval = setInterval(() => {
if (
window.app?.api?.serverFeatureFlags &&
Object.keys(window.app.api.serverFeatureFlags).length > 0
) {
window.__capturedMessages!.serverFeatureFlags =
window.app.api.serverFeatureFlags
const flags = window.app?.api?.serverFeatureFlags?.value
if (flags && Object.keys(flags).length > 0) {
window.__capturedMessages!.serverFeatureFlags = flags
clearInterval(checkInterval)
}
}, 100)
@@ -96,7 +93,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}) => {
// Get the actual server feature flags from the backend
const serverFlags = await comfyPage.page.evaluate(() => {
return window.app!.api.serverFeatureFlags
return window.app!.api.serverFeatureFlags.value
})
// Verify we received real feature flags from the backend
@@ -129,8 +126,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Test that the method only returns true for boolean true values
const testResults = await comfyPage.page.evaluate(() => {
// Temporarily modify serverFeatureFlags to test behavior
const original = window.app!.api.serverFeatureFlags
window.app!.api.serverFeatureFlags = {
const original = window.app!.api.serverFeatureFlags.value
window.app!.api.serverFeatureFlags.value = {
bool_true: true,
bool_false: false,
string_value: 'yes',
@@ -147,7 +144,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}
// Restore original
window.app!.api.serverFeatureFlags = original
window.app!.api.serverFeatureFlags.value = original
return results
})
@@ -282,8 +279,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Monitor when feature flags arrive by checking periodically
const checkFeatureFlags = setInterval(() => {
if (
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined
window.app?.api?.serverFeatureFlags?.value
?.supports_preview_metadata !== undefined
) {
window.__appReadiness!.featureFlagsReceived = true
clearInterval(checkFeatureFlags)
@@ -320,8 +317,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Wait for feature flags to be received
await newPage.waitForFunction(
() =>
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined,
window.app?.api?.serverFeatureFlags?.value
?.supports_preview_metadata !== undefined,
{
timeout: 10000
}
@@ -331,7 +328,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
const readiness = await newPage.evaluate(() => {
return {
...window.__appReadiness,
currentFlags: window.app!.api.serverFeatureFlags
currentFlags: window.app!.api.serverFeatureFlags.value
}
})

View File

@@ -736,6 +736,25 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
)
// Wait for V2 persistence debounce to save the modified workflow
const start = Date.now()
await comfyPage.page.waitForFunction((since) => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
const json = window.localStorage.getItem(key)
if (!json) continue
try {
const index = JSON.parse(json)
if (typeof index.updatedAt === 'number' && index.updatedAt >= since) {
return true
}
} catch {
// ignore
}
}
return false
}, start)
await comfyPage.setup({ clearStorage: false })
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
@@ -758,10 +777,17 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage to persist the workflow paths before reloading
await comfyPage.page.waitForFunction(
() => !!window.localStorage.getItem('Comfy.OpenWorkflowsPaths')
)
// Wait for sessionStorage to persist the workflow paths before reloading
// V2 persistence uses sessionStorage with client-scoped keys
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.sessionStorage.length; i++) {
const key = window.sessionStorage.key(i)
if (key?.startsWith('Comfy.Workflow.OpenPaths:')) {
return true
}
}
return false
})
await comfyPage.setup({ clearStorage: false })
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

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

After

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

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 90 KiB

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,622 @@
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('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: 74 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: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

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

@@ -1,23 +1,27 @@
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.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
async function loadImageOnNode(
comfyPage: Awaited<
ReturnType<(typeof test)['info']>
>['fixtures']['comfyPage']
) {
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
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]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragAndDropFile('image64x64.webp', {
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
@@ -26,24 +30,31 @@ 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()
})
// 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' })
@@ -54,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: 104 KiB

After

Width:  |  Height:  |  Size: 107 KiB

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: 138 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,7 +39,9 @@ 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'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -6,10 +6,19 @@ export default {
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
...formatAndEslint(stagedFiles),
'pnpm typecheck'
]
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
const commands = [...formatAndEslint(stagedFiles), 'pnpm typecheck']
const hasBrowserTestsChanges = stagedFiles
.map((f) => path.relative(process.cwd(), f).replace(/\\/g, '/'))
.some((f) => f.startsWith('browser_tests/'))
if (hasBrowserTestsChanges) {
commands.push('pnpm typecheck:browser')
}
return commands
}
}
function formatAndEslint(fileNames: string[]) {

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.40.8",
"version": "1.41.3",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -70,13 +70,14 @@
"@primevue/themes": "catalog:",
"@sentry/vue": "catalog:",
"@sparkjsdev/spark": "catalog:",
"@tiptap/core": "^2.10.4",
"@tiptap/extension-link": "^2.10.4",
"@tiptap/extension-table": "^2.10.4",
"@tiptap/extension-table-cell": "^2.10.4",
"@tiptap/extension-table-header": "^2.10.4",
"@tiptap/extension-table-row": "^2.10.4",
"@tiptap/starter-kit": "^2.10.4",
"@tiptap/core": "catalog:",
"@tiptap/extension-link": "catalog:",
"@tiptap/extension-table": "catalog:",
"@tiptap/extension-table-cell": "catalog:",
"@tiptap/extension-table-header": "catalog:",
"@tiptap/extension-table-row": "catalog:",
"@tiptap/pm": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"@xterm/addon-fit": "^0.10.0",
@@ -93,9 +94,9 @@
"extendable-media-recorder-wav-encoder": "^7.0.129",
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"glob": "^11.0.3",
"glob": "catalog:",
"jsonata": "catalog:",
"jsondiffpatch": "^0.6.0",
"jsondiffpatch": "catalog:",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "catalog:",

View File

@@ -99,6 +99,10 @@
--color-magenta-300: #ceaac9;
--color-magenta-700: #6a246a;
--color-ocean-300: #badde8;
--color-ocean-600: #2f687a;
--color-ocean-900: #253236;
--color-danger-100: #c02323;
--color-danger-200: #d62952;
@@ -113,6 +117,7 @@
--color-interface-panel-job-progress-secondary: var(
--color-alpha-azure-600-30
);
--color-interface-panel-job-progress-border: var(--base-foreground);
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
@@ -154,6 +159,7 @@
--comfy-menu-bg: #353535;
--comfy-menu-secondary-bg: #292929;
--comfy-topbar-height: 2.5rem;
--workflow-tabs-height: 2.375rem;
--comfy-input-bg: #222;
--input-text: #ddd;
--descrip-text: #999;
@@ -218,6 +224,10 @@
--interface-panel-surface: var(--color-white);
--interface-stroke: var(--color-smoke-300);
--interface-builder-mode-background: var(--color-ocean-300);
--interface-builder-mode-button-background: var(--color-ocean-600);
--interface-builder-mode-button-foreground: var(--color-white);
--nav-background: var(--color-white);
--node-border: var(--color-smoke-300);
@@ -377,6 +387,10 @@
--interface-panel-surface: var(--color-charcoal-800);
--interface-stroke: var(--color-charcoal-400);
--interface-builder-mode-background: var(--color-ocean-900);
--interface-builder-mode-button-background: var(--color-ocean-600);
--interface-builder-mode-button-foreground: var(--color-white);
--nav-background: var(--color-charcoal-800);
--node-border: var(--color-charcoal-500);
@@ -412,6 +426,7 @@
--color-interface-panel-job-progress-secondary: var(
--color-alpha-azure-600-30
);
--color-interface-panel-job-progress-border: var(--base-foreground);
--text-secondary: var(--color-slate-100);
--text-primary: var(--color-white);
@@ -510,6 +525,15 @@
--color-comfy-menu-bg: var(--comfy-menu-bg);
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
--color-interface-builder-mode-background: var(
--interface-builder-mode-background
);
--color-interface-builder-mode-button-background: var(
--interface-builder-mode-button-background
);
--color-interface-builder-mode-button-foreground: var(
--interface-builder-mode-button-foreground
);
--color-interface-stroke: var(--interface-stroke);
--color-nav-background: var(--nav-background);
--color-node-border: var(--node-border);
@@ -1192,7 +1216,7 @@ button.comfy-queue-btn {
.graphdialog {
min-height: 1em;
background-color: var(--comfy-menu-bg);
z-index: 41; /* z-index is set to 41 here in order to appear over selection-overlay-container which should have a z-index of 40 */
z-index: 1500;
}
.graphdialog .name {
@@ -1877,3 +1901,22 @@ audio.comfy-audio.empty-audio-widget {
margin-left: 15px;
}
/* ===================== End of Mask Editor Styles ===================== */
/* ===================== Skeleton Shimmer ===================== */
.skeleton-shimmer {
background:
linear-gradient(135deg, rgb(188 188 188 / 0.5) 0%, rgb(0 0 0 / 0.5) 100%) 0
0 / 200% 200%,
#000;
animation: skeleton-shimmer 2s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
0%,
100% {
background-position: 100% 100%;
}
50% {
background-position: 0 0;
}
}

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
getMediaTypeFromFilename,
highlightQuery,
isPreviewableMediaType,
truncateFilename
} from './formatUtil'
@@ -56,7 +57,8 @@ describe('formatUtil', () => {
{ filename: 'image.jpeg', expected: 'image' },
{ filename: 'animation.gif', expected: 'image' },
{ filename: 'web.webp', expected: 'image' },
{ filename: 'bitmap.bmp', expected: 'image' }
{ filename: 'bitmap.bmp', expected: 'image' },
{ filename: 'modern.avif', expected: 'image' }
]
it.for(imageTestCases)(
@@ -96,26 +98,37 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
})
})
describe('text files', () => {
it('should identify text file extensions correctly', () => {
expect(getMediaTypeFromFilename('notes.txt')).toBe('text')
expect(getMediaTypeFromFilename('readme.md')).toBe('text')
expect(getMediaTypeFromFilename('data.json')).toBe('text')
expect(getMediaTypeFromFilename('table.csv')).toBe('text')
expect(getMediaTypeFromFilename('config.yaml')).toBe('text')
})
})
describe('edge cases', () => {
it('should handle empty strings', () => {
expect(getMediaTypeFromFilename('')).toBe('image')
expect(getMediaTypeFromFilename('')).toBe('other')
})
it('should handle files without extensions', () => {
expect(getMediaTypeFromFilename('README')).toBe('image')
expect(getMediaTypeFromFilename('README')).toBe('other')
})
it('should handle unknown extensions', () => {
expect(getMediaTypeFromFilename('document.pdf')).toBe('image')
expect(getMediaTypeFromFilename('data.json')).toBe('image')
expect(getMediaTypeFromFilename('document.pdf')).toBe('other')
expect(getMediaTypeFromFilename('archive.bin')).toBe('other')
})
it('should handle files with multiple dots', () => {
expect(getMediaTypeFromFilename('my.file.name.png')).toBe('image')
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('image')
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('other')
})
it('should handle paths with directories', () => {
@@ -124,8 +137,8 @@ describe('formatUtil', () => {
})
it('should handle null and undefined gracefully', () => {
expect(getMediaTypeFromFilename(null)).toBe('image')
expect(getMediaTypeFromFilename(undefined)).toBe('image')
expect(getMediaTypeFromFilename(null)).toBe('other')
expect(getMediaTypeFromFilename(undefined)).toBe('other')
})
it('should handle special characters in filenames', () => {
@@ -184,4 +197,18 @@ describe('formatUtil', () => {
)
})
})
describe('isPreviewableMediaType', () => {
it('returns true for image/video/audio/3D', () => {
expect(isPreviewableMediaType('image')).toBe(true)
expect(isPreviewableMediaType('video')).toBe(true)
expect(isPreviewableMediaType('audio')).toBe(true)
expect(isPreviewableMediaType('3D')).toBe(true)
})
it('returns false for text/other', () => {
expect(isPreviewableMediaType('text')).toBe(false)
expect(isPreviewableMediaType('other')).toBe(false)
})
})
})

View File

@@ -494,19 +494,41 @@ export function formatDuration(milliseconds: number): string {
return parts.join(' ')
}
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] as const
const IMAGE_EXTENSIONS = [
'png',
'jpg',
'jpeg',
'gif',
'webp',
'bmp',
'avif',
'tif',
'tiff'
] as const
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
const TEXT_EXTENSIONS = [
'txt',
'md',
'markdown',
'json',
'csv',
'yaml',
'yml',
'xml',
'log'
] as const
const MEDIA_TYPES = ['image', 'video', 'audio', '3D'] as const
type MediaType = (typeof MEDIA_TYPES)[number]
const MEDIA_TYPES = ['image', 'video', 'audio', '3D', 'text', 'other'] as const
export type MediaType = (typeof MEDIA_TYPES)[number]
// Type guard helper for checking array membership
type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
type AudioExtension = (typeof AUDIO_EXTENSIONS)[number]
type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number]
type TextExtension = (typeof TEXT_EXTENSIONS)[number]
/**
* Truncates a filename while preserving the extension
@@ -543,20 +565,30 @@ export function truncateFilename(
/**
* Determines the media type from a filename's extension (singular form)
* @param filename The filename to analyze
* @returns The media type: 'image', 'video', 'audio', or '3D'
* @returns The media type: 'image', 'video', 'audio', '3D', 'text', or 'other'
*/
export function getMediaTypeFromFilename(
filename: string | null | undefined
): MediaType {
if (!filename) return 'image'
if (!filename) return 'other'
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'image'
if (!ext) return 'other'
// Type-safe array includes check using type assertion
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image'
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video'
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio'
if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D'
if (TEXT_EXTENSIONS.includes(ext as TextExtension)) return 'text'
return 'image'
return 'other'
}
export function isPreviewableMediaType(mediaType: MediaType): boolean {
return (
mediaType === 'image' ||
mediaType === 'video' ||
mediaType === 'audio' ||
mediaType === '3D'
)
}

5095
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +9,12 @@ catalog:
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind4': ^1.2.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@lobehub/i18n-cli': ^1.26.1
'@nx/eslint': 22.2.6
'@nx/playwright': 22.2.6
'@nx/storybook': 22.2.4
'@nx/vite': 22.2.6
'@nx/eslint': 22.5.2
'@nx/playwright': 22.5.2
'@nx/storybook': 22.5.2
'@nx/vite': 22.5.2
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@primeuix/forms': 0.0.2
@@ -27,11 +27,19 @@ catalog:
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^0.1.10
'@storybook/addon-docs': ^10.1.9
'@storybook/addon-docs': ^10.2.10
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9
'@tailwindcss/vite': ^4.1.12
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.2.0
'@tiptap/core': ^2.27.2
'@tiptap/extension-link': ^2.27.2
'@tiptap/extension-table': ^2.27.2
'@tiptap/extension-table-cell': ^2.27.2
'@tiptap/extension-table-header': ^2.27.2
'@tiptap/extension-table-row': ^2.27.2
'@tiptap/pm': 2.27.2
'@tiptap/starter-kit': ^2.27.2
'@types/fs-extra': ^11.0.4
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
@@ -45,7 +53,7 @@ catalog:
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
axios: ^1.8.2
axios: ^1.13.5
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: ^3.3.1
@@ -55,24 +63,26 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-storybook: ^10.1.9
eslint-plugin-storybook: ^10.2.10
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
firebase: ^11.6.0
glob: ^13.0.6
globals: ^16.5.0
happy-dom: ^20.0.11
husky: ^9.1.7
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^5.75.1
lint-staged: ^16.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.2.6
oxfmt: ^0.26.0
oxlint: ^1.33.0
oxlint-tsgolint: ^0.9.1
nx: 22.5.2
oxfmt: ^0.34.0
oxlint: ^1.49.0
oxlint-tsgolint: ^0.14.2
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
@@ -81,9 +91,9 @@ catalog:
primevue: ^4.2.5
reka-ui: ^2.5.0
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.1.9
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.1.12
tailwindcss: ^4.2.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
@@ -100,10 +110,10 @@ catalog:
vitest: ^4.0.16
vue: ^3.5.13
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.2.0
vue-i18n: ^9.14.3
vue-eslint-parser: ^10.4.0
vue-i18n: ^9.14.5
vue-router: ^4.4.3
vue-tsc: ^3.2.1
vue-tsc: ^3.2.5
vuefire: ^3.2.1
wwobjloader2: ^6.2.1
yjs: ^13.6.27
@@ -130,4 +140,5 @@ onlyBuiltDependencies:
- oxc-resolver
overrides:
'@tiptap/pm': 2.27.2
'@types/eslint': '-'

View File

@@ -2,7 +2,7 @@
set -e
# Deploy Playwright test reports to Cloudflare Pages and comment on PR
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status>
# Input validation
# Validate PR number is numeric
@@ -31,8 +31,6 @@ case "$STATUS" in
;;
esac
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
# Required environment variables
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
@@ -135,23 +133,8 @@ post_comment() {
# Main execution
if [ "$STATUS" = "starting" ]; then
# Post concise starting comment
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎭 Playwright Tests: ⏳ Running...
Tests started at $START_TIME UTC
<details>
<summary>📊 Browser Tests</summary>
- **chromium**: Running...
- **chromium-0.5x**: Running...
- **chromium-2x**: Running...
- **mobile-chrome**: Running...
</details>
EOF
)
comment="$COMMENT_MARKER
## 🎭 Playwright: ⏳ Running..."
post_comment "$comment"
else
@@ -300,7 +283,7 @@ else
# Generate compact single-line comment
comment="$COMMENT_MARKER
**Playwright:** $status_icon $total_passed passed, $total_failed failed$flaky_note"
## 🎭 Playwright: $status_icon $total_passed passed, $total_failed failed$flaky_note"
# Extract and display failed tests from all browsers (flaky tests are treated as passing)
if [ $total_failed -gt 0 ]; then

View File

@@ -2,7 +2,7 @@
set -e
# Deploy Storybook to Cloudflare Pages and comment on PR
# Usage: ./pr-storybook-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
# Usage: ./pr-storybook-deploy-and-comment.sh <pr_number> <branch_name> <status>
# Input validation
# Validate PR number is numeric
@@ -31,7 +31,6 @@ case "$STATUS" in
;;
esac
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
# Required environment variables
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
@@ -120,50 +119,9 @@ post_comment() {
# Main execution
if [ "$STATUS" = "starting" ]; then
# Check if this is a version-bump branch
IS_VERSION_BUMP="false"
if echo "$BRANCH_NAME" | grep -q "^version-bump-"; then
IS_VERSION_BUMP="true"
fi
# Post starting comment with appropriate message
if [ "$IS_VERSION_BUMP" = "true" ]; then
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎨 Storybook Build Status
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
⏰ Started at: $START_TIME UTC
### 🚀 Building Storybook
- 📦 Installing dependencies...
- 🔧 Building Storybook components...
- 🎨 Running Chromatic visual tests...
---
⏱️ Please wait while the Storybook build is in progress...
EOF
)
else
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎨 Storybook Build Status
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
⏰ Started at: $START_TIME UTC
### 🚀 Building Storybook
- 📦 Installing dependencies...
- 🔧 Building Storybook components...
- 🌐 Preparing deployment to Cloudflare Pages...
---
⏱️ Please wait while the Storybook build is in progress...
EOF
)
fi
# Post starting comment
comment="$COMMENT_MARKER
## 🎨 Storybook: <img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> Building..."
post_comment "$comment"
elif [ "$STATUS" = "completed" ]; then
@@ -192,56 +150,57 @@ elif [ "$STATUS" = "completed" ]; then
WORKFLOW_CONCLUSION="${WORKFLOW_CONCLUSION:-success}"
WORKFLOW_URL="${WORKFLOW_URL:-}"
# Generate completion comment based on conclusion
# Generate compact header based on conclusion
if [ "$WORKFLOW_CONCLUSION" = "success" ]; then
status_icon="✅"
status_text="Build completed successfully!"
footer_text="🎉 Your Storybook is ready for review!"
status_text="Built"
elif [ "$WORKFLOW_CONCLUSION" = "skipped" ]; then
status_icon="⏭️"
status_text="Build skipped."
footer_text=" Chromatic was skipped for this PR."
status_text="Skipped"
elif [ "$WORKFLOW_CONCLUSION" = "cancelled" ]; then
status_icon="🚫"
status_text="Build cancelled."
footer_text=" The Chromatic run was cancelled."
status_text="Cancelled"
else
status_icon="❌"
status_text="Build failed!"
footer_text="⚠️ Please check the workflow logs for error details."
status_text="Failed"
fi
comment="$COMMENT_MARKER
## 🎨 Storybook Build Status
$status_icon **$status_text**
# Build compact header with optional storybook link
header="## 🎨 Storybook: $status_icon $status_text"
if [ "$deployment_url" != "Not deployed" ] && [ "$deployment_url" != "Deployment failed" ] && [ "$WORKFLOW_CONCLUSION" = "success" ]; then
header="$header$deployment_url"
fi
# Build details section
details="<details>
<summary>Details</summary>
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
### 🔗 Links
**Links**
- [📊 View Workflow Run]($WORKFLOW_URL)"
# Add deployment status
if [ "$deployment_url" != "Not deployed" ]; then
if [ "$deployment_url" = "Deployment failed" ]; then
comment="$comment
details="$details
- ❌ Storybook deployment failed"
elif [ "$WORKFLOW_CONCLUSION" = "success" ]; then
comment="$comment
- 🎨 $deployment_url"
else
comment="$comment
- ⚠️ Build failed - $deployment_url"
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
details="$details
- ⚠️ Build failed — $deployment_url"
fi
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
comment="$comment
details="$details
- ⏭️ Storybook deployment skipped (build did not succeed)"
fi
comment="$comment
---
$footer_text"
details="$details
</details>"
comment="$COMMENT_MARKER
$header
$details"
post_comment "$comment"
fi

View File

@@ -11,7 +11,7 @@ import {
} from '../packages/shared-frontend-utils/src/formatUtil'
import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands'
import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig'
import type { FormItem, SettingParams } from '../src/platform/settings/types'
import type { SettingParams } from '../src/platform/settings/types'
import type { ComfyCommandImpl } from '../src/stores/commandStore'
const localePath = './src/locales/en/main.json'
@@ -123,8 +123,8 @@ test('collect-i18n-general', async ({ comfyPage }) => {
SERVER_CONFIG_ITEMS.map((config) => [
normalizeI18nKey(config.id),
{
name: (config as unknown as FormItem).name,
tooltip: (config as unknown as FormItem).tooltip
name: config.name,
tooltip: config.tooltip
}
])
)

View File

@@ -190,7 +190,7 @@ describe('downloadUtil', () => {
ok: false,
status: 404,
blob: vi.fn()
} as unknown as Response)
} as Partial<Response> as Response)
downloadFile(testUrl)

View File

@@ -169,6 +169,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -189,6 +190,7 @@ const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const queueUIStore = useQueueUIStore()
const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
@@ -262,7 +264,7 @@ const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
})
const { hasAnyError } = storeToRefs(executionStore)
const { hasAnyError } = storeToRefs(executionErrorStore)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const appModeStore = useAppModeStore()
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
const isAssetsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
)
const isWorkflowsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'workflows'
)
function enterBuilderMode() {
appModeStore.setMode('builder:select')
}
function openAssets() {
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
}
function showApps() {
void commandStore.execute('Workspace.ToggleSidebarTab.workflows')
}
function openTemplates() {
useWorkflowTemplateSelectorDialog().show('sidebar')
}
</script>
<template>
<div class="flex flex-col gap-2 pointer-events-auto">
<WorkflowActionsDropdown source="app_mode_toolbar">
<template #button>
<Button
v-tooltip.right="{
value: t('sideToolbar.labels.menu'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:aria-label="t('sideToolbar.labels.menu')"
class="h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i class="icon-[lucide--panels-top-left] size-4" />
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
</Button>
</template>
</WorkflowActionsDropdown>
<Button
v-if="appModeStore.enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilderMode"
>
<i class="icon-[lucide--hammer] size-4" />
</Button>
<div
class="flex flex-col w-10 rounded-lg bg-secondary-background overflow-hidden"
>
<Button
v-tooltip.right="{
value: t('sideToolbar.mediaAssets.title'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('sideToolbar.mediaAssets.title')"
:class="
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
"
@click="openAssets"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
<Button
v-tooltip.right="{
value: t('linearMode.appModeToolbar.apps'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('linearMode.appModeToolbar.apps')"
:class="
cn('size-10', isWorkflowsActive && 'bg-secondary-background-hover')
"
@click="showApps"
>
<i class="icon-[lucide--panels-top-left] size-4" />
</Button>
<Button
v-tooltip.right="{
value: t('sideToolbar.templates'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('sideToolbar.templates')"
class="size-10"
@click="openTemplates"
>
<i class="icon-[comfy--template] size-4" />
</Button>
</div>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<template>
<div
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
data-testid="subgraph-breadcrumb"
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
@@ -13,17 +14,10 @@
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
}"
>
<Button
class="context-menu-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
icon="pi pi-bars"
text
severity="secondary"
size="small"
@click="handleMenuClick"
/>
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
<Button
v-if="isInSubgraph"
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 ml-1.5 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
text
severity="secondary"
size="small"
@@ -41,7 +35,6 @@
>
<template #item="{ item }">
<SubgraphBreadcrumbItem
:ref="(el) => setItemRef(item, el)"
:item="item"
:is-active="item.key === activeItemKey"
/>
@@ -60,6 +53,7 @@ import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -77,12 +71,6 @@ const ICON_WIDTH = 20
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const rootItemRef = ref<InstanceType<typeof SubgraphBreadcrumbItem>>()
const setItemRef = (item: MenuItem, el: unknown) => {
if (item.key === 'root') {
rootItemRef.value = el as InstanceType<typeof SubgraphBreadcrumbItem>
}
}
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const isBlueprint = computed(() =>
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
@@ -136,13 +124,6 @@ const items = computed(() => {
const activeItemKey = computed(() => items.value.at(-1)?.key)
const handleMenuClick = (event: MouseEvent) => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_menu_selected'
})
rootItemRef.value?.toggleMenu(event)
}
const handleBackClick = () => {
void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
}

View File

@@ -78,9 +78,7 @@ interface Props {
isActive?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const { item, isActive = false } = defineProps<Props>()
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
@@ -103,7 +101,7 @@ const rename = async (
) => {
if (newName && newName !== initialName) {
// Synchronize the node titles with the new name
props.item.updateTitle?.(newName)
item.updateTitle?.(newName)
if (workflowStore.activeSubgraph) {
workflowStore.activeSubgraph.name = newName
@@ -127,13 +125,13 @@ const rename = async (
}
}
const isRoot = props.item.key === 'root'
const isRoot = item.key === 'root'
const tooltipText = computed(() => {
if (hasMissingNodes.value && isRoot) {
return t('breadcrumbsMenu.missingNodesWarning')
}
return props.item.label
return item.label
})
const startRename = async () => {
@@ -145,7 +143,7 @@ const startRename = async () => {
}
isEditing.value = true
itemLabel.value = props.item.label as string
itemLabel.value = item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
@@ -165,12 +163,12 @@ const handleClick = (event: MouseEvent) => {
}
if (event.detail === 1) {
if (props.isActive) {
if (isActive) {
menu.value?.toggle(event)
} else {
props.item.command?.({ item: props.item, originalEvent: event })
item.command?.({ item: item, originalEvent: event })
}
} else if (props.isActive && event.detail === 2) {
} else if (isActive && event.detail === 2) {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
@@ -180,19 +178,11 @@ const handleClick = (event: MouseEvent) => {
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
await rename(itemLabel.value, item.label as string)
}
isEditing.value = false
}
const toggleMenu = (event: MouseEvent) => {
menu.value?.toggle(event)
}
defineExpose({
toggleMenu
})
</script>
<style scoped>

View File

@@ -0,0 +1,41 @@
<template>
<div class="flex w-full min-w-96 flex-col rounded-2xl bg-base-background">
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<div class="flex items-center gap-2">
<slot name="header-icon" />
<h2 class="m-0 text-sm font-normal text-base-foreground">
<slot name="title" />
</h2>
</div>
<Button
variant="muted-textonly"
class="-mr-1"
:aria-label="$t('g.close')"
@click="$emit('close')"
>
<i class="pi pi-times size-4" />
</Button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<slot />
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<slot name="footer" />
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
defineEmits<{
close: []
}>()
</script>

View File

@@ -0,0 +1,117 @@
<template>
<BuilderDialog @close="onClose">
<template #title>
{{ $t('builderToolbar.saveAs') }}
</template>
<!-- Filename -->
<div class="flex flex-col gap-2">
<label :for="inputId" class="text-sm text-muted-foreground">
{{ $t('builderToolbar.filename') }}
</label>
<input
:id="inputId"
v-model="filename"
autofocus
type="text"
class="flex h-10 min-h-8 items-center self-stretch rounded-lg border-none bg-secondary-background pl-4 text-sm text-base-foreground focus:outline-none"
@keydown.enter="filename.trim() && onSave(filename.trim(), openAsApp)"
/>
</div>
<!-- Save as type -->
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground">
{{ $t('builderToolbar.saveAsLabel') }}
</label>
<div role="radiogroup" class="flex flex-col gap-2">
<Button
v-for="option in saveTypeOptions"
:key="option.value.toString()"
role="radio"
:aria-checked="openAsApp === option.value"
:class="
cn(
itemClasses,
openAsApp === option.value && 'bg-secondary-background'
)
"
variant="textonly"
@click="openAsApp = option.value"
>
<div
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
>
<i :class="cn(option.icon, 'size-4')" />
</div>
<div class="mx-2 flex flex-1 flex-col items-start">
<span class="text-sm font-medium text-base-foreground">
{{ option.title }}
</span>
<span class="text-xs text-muted-foreground">
{{ option.subtitle }}
</span>
</div>
<i
v-if="openAsApp === option.value"
class="icon-[lucide--check] size-4 text-base-foreground"
/>
</Button>
</div>
</div>
<template #footer>
<Button variant="muted-textonly" size="lg" @click="onClose">
{{ $t('g.cancel') }}
</Button>
<Button
variant="secondary"
size="lg"
:disabled="!filename.trim()"
@click="onSave(filename.trim(), openAsApp)"
>
{{ $t('g.save') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import { ref, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import BuilderDialog from './BuilderDialog.vue'
const { t } = useI18n()
const { defaultFilename, onSave, onClose } = defineProps<{
defaultFilename: string
onSave: (filename: string, openAsApp: boolean) => void
onClose: () => void
}>()
const inputId = useId()
const filename = ref(defaultFilename)
const openAsApp = ref(true)
const saveTypeOptions = [
{
value: true,
icon: 'icon-[lucide--app-window]',
title: t('builderToolbar.app'),
subtitle: t('builderToolbar.appDescription')
},
{
value: false,
icon: 'icon-[comfy--workflow]',
title: t('builderToolbar.nodeGraph'),
subtitle: t('builderToolbar.nodeGraphDescription')
}
]
const itemClasses =
'flex h-14 cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background'
</script>

View File

@@ -0,0 +1,51 @@
<template>
<BuilderDialog @close="onClose">
<template #header-icon>
<i class="icon-[lucide--circle-check-big] size-4 text-green-500" />
</template>
<template #title>
{{ $t('builderToolbar.saveSuccess') }}
</template>
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessAppMessage', { name: workflowName }) }}
</p>
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessAppPrompt') }}
</p>
<p v-else class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessGraphMessage', { name: workflowName }) }}
</p>
<template #footer>
<Button
:variant="savedAsApp ? 'muted-textonly' : 'secondary'"
size="lg"
@click="onClose"
>
{{ $t('g.close') }}
</Button>
<Button
v-if="savedAsApp && onViewApp"
variant="primary"
size="lg"
@click="onViewApp"
>
{{ $t('builderToolbar.viewApp') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineProps<{
workflowName: string
savedAsApp: boolean
onViewApp?: () => void
onClose: () => void
}>()
</script>

View File

@@ -0,0 +1,105 @@
<template>
<nav
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-[1000] -translate-x-1/2"
:aria-label="t('builderToolbar.label')"
>
<div
class="inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<template
v-for="(step, index) in [selectStep, arrangeStep]"
:key="step.id"
>
<button
:class="
cn(
stepClasses,
activeStep === step.id && 'bg-interface-builder-mode-background',
activeStep !== step.id &&
'hover:bg-secondary-background bg-transparent'
)
"
:aria-current="activeStep === step.id ? 'step' : undefined"
@click="appModeStore.setMode(step.id)"
>
<StepBadge :step :index :model-value="activeStep" />
<StepLabel :step />
</button>
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
</template>
<!-- Save -->
<ConnectOutputPopover
v-if="!appModeStore.hasOutputs"
:is-select-active="activeStep === 'builder:select'"
@switch="appModeStore.setMode('builder:select')"
>
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
</button>
</ConnectOutputPopover>
<button
v-else
:class="
cn(
stepClasses,
activeStep === 'save'
? 'bg-interface-builder-mode-background'
: 'hover:bg-secondary-background bg-transparent'
)
"
@click="appModeStore.setBuilderSaving(true)"
>
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
</button>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppModeStore } from '@/stores/appModeStore'
import type { AppMode } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import StepBadge from './StepBadge.vue'
import StepLabel from './StepLabel.vue'
import type { BuilderToolbarStep } from './types'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const activeStep = computed(() =>
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
)
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
const selectStep: BuilderToolbarStep<AppMode> = {
id: 'builder:select',
title: t('builderToolbar.select'),
subtitle: t('builderToolbar.selectDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
const arrangeStep: BuilderToolbarStep<AppMode> = {
id: 'builder:arrange',
title: t('builderToolbar.arrange'),
subtitle: t('builderToolbar.arrangeDescription'),
icon: 'icon-[lucide--layout-panel-left]'
}
const saveStep: BuilderToolbarStep<'save'> = {
id: 'save',
title: t('builderToolbar.save'),
subtitle: t('builderToolbar.saveDescription'),
icon: 'icon-[lucide--cloud-upload]'
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<PopoverRoot>
<PopoverTrigger as-child>
<slot />
</PopoverTrigger>
<PopoverContent
side="bottom"
:side-offset="8"
:collision-padding="10"
class="z-[1001] w-80 rounded-xl border border-border-default bg-base-background shadow-interface will-change-[transform,opacity] data-[state=open]:data-[side=bottom]:animate-slideUpAndFade"
>
<div class="flex h-12 items-center justify-between px-4">
<h3 class="text-sm font-medium text-base-foreground">
{{ t('builderToolbar.connectOutput') }}
</h3>
<PopoverClose
:aria-label="t('g.close')"
class="flex cursor-pointer appearance-none items-center justify-center border-none bg-transparent p-0 text-muted-foreground hover:text-base-foreground"
>
<i class="icon-[lucide--x] size-4" />
</PopoverClose>
</div>
<div class="border-t border-border-default" />
<p class="mt-3 px-4 text-xs text-muted-foreground leading-relaxed">
{{ t('builderToolbar.connectOutputBody1') }}
</p>
<p
v-if="!isSelectActive"
class="mt-2 px-4 text-xs text-muted-foreground leading-relaxed"
>
{{ t('builderToolbar.connectOutputBody2') }}
</p>
<div class="mt-4 flex items-center justify-end gap-2 px-4 pb-4">
<template v-if="isSelectActive">
<PopoverClose as-child>
<Button variant="secondary" size="md">
{{ t('g.ok') }}
</Button>
</PopoverClose>
</template>
<template v-else>
<PopoverClose as-child>
<Button variant="muted-textonly" size="md">
{{ t('g.cancel') }}
</Button>
</PopoverClose>
<PopoverClose as-child>
<Button variant="secondary" size="md" @click="emit('switch')">
{{ t('builderToolbar.switchToSelect') }}
</Button>
</PopoverClose>
</template>
</div>
</PopoverContent>
</PopoverRoot>
</template>
<script setup lang="ts">
import {
PopoverClose,
PopoverContent,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { isSelectActive = false } = defineProps<{
isSelectActive?: boolean
}>()
const { t } = useI18n()
const emit = defineEmits<{
switch: []
}>()
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-interface-builder-mode-button-background text-interface-builder-mode-button-foreground"
>
<i v-if="modelValue === step.id" :class="cn(step.icon, 'size-5')" />
<span v-else class="text-sm font-bold">
{{ index + 1 }}
</span>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import type { BuilderToolbarStep } from './types'
defineProps<{
step: BuilderToolbarStep
index: number
modelValue: string
}>()
</script>

View File

@@ -0,0 +1,20 @@
<template>
<div class="flex flex-col gap-0.5 text-left">
<span class="text-sm font-medium text-base-foreground">
{{ step.title }}
</span>
<span
class="hidden whitespace-nowrap text-xs text-muted-foreground sm:inline"
>
{{ step.subtitle }}
</span>
</div>
</template>
<script setup lang="ts">
import type { BuilderToolbarStep } from './types'
const { step } = defineProps<{
step: BuilderToolbarStep
}>()
</script>

View File

@@ -0,0 +1,6 @@
export interface BuilderToolbarStep<T extends string = string> {
id: T
title: string
subtitle: string
icon: string
}

View File

@@ -0,0 +1,124 @@
import { watch } from 'vue'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
export function useBuilderSave() {
const appModeStore = useAppModeStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
watch(
() => appModeStore.isBuilderSaving,
(saving) => {
if (saving) void onBuilderSave()
}
)
async function onBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) {
resetSaving()
return
}
// TODO: Update this to show the save dialog if it is temp OR if the user has not saved app mode before.
// If they have saved app mode before, just save the workflow, but use the initial app mode state not current.
if (!workflow.isTemporary) {
try {
workflow.changeTracker?.checkState()
await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
} catch {
resetSaving()
}
return
}
showSaveDialog(workflow.filename)
}
function showSaveDialog(defaultFilename: string) {
dialogService.showLayoutDialog({
key: SAVE_DIALOG_KEY,
component: BuilderSaveDialogContent,
props: {
defaultFilename,
onSave: handleSave,
onClose: handleCancelSave
},
dialogComponentProps: {
onClose: resetSaving
}
})
}
function handleCancelSave() {
closeSaveDialog()
resetSaving()
}
async function handleSave(filename: string, openAsApp: boolean) {
try {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
const saved = await workflowService.saveWorkflowAs(workflow, {
filename,
openAsApp
})
if (!saved) return
closeSaveDialog()
showSuccessDialog(filename, openAsApp)
} catch {
closeSaveDialog()
resetSaving()
}
}
function showSuccessDialog(workflowName: string, savedAsApp: boolean) {
dialogService.showLayoutDialog({
key: SUCCESS_DIALOG_KEY,
component: BuilderSaveSuccessDialogContent,
props: {
workflowName,
savedAsApp,
onViewApp: () => {
appModeStore.setMode('app')
closeSuccessDialog()
},
onClose: closeSuccessDialog
},
dialogComponentProps: {
onClose: resetSaving
}
})
}
function closeSaveDialog() {
dialogStore.closeDialog({ key: SAVE_DIALOG_KEY })
}
function closeSuccessDialog() {
dialogStore.closeDialog({ key: SUCCESS_DIALOG_KEY })
resetSaving()
}
function resetSaving() {
appModeStore.setBuilderSaving(false)
}
}

View File

@@ -6,7 +6,7 @@
<slot name="background" />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.decrement')"
:aria-label="t('g.decrement')"
data-testid="decrement"
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
@@ -51,7 +51,7 @@
<slot />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.increment')"
:aria-label="t('g.increment')"
data-testid="increment"
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"

View File

@@ -6,10 +6,12 @@
</h2>
<div class="grid grid-cols-2 gap-2">
<template v-for="col in systemColumns" :key="col.field">
<div class="font-medium">
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
{{ col.header }}
</div>
<div>{{ getDisplayValue(col) }}</div>
<div :class="cn(isOutdated(col) && 'text-danger-100')">
{{ getDisplayValue(col) }}
</div>
</template>
</div>
</div>
@@ -47,6 +49,7 @@ import DeviceInfo from '@/components/common/DeviceInfo.vue'
import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
stats: SystemStats
@@ -76,7 +79,8 @@ const localColumns: ColumnDef[] = [
{ field: 'pytorch_version', header: 'Pytorch Version' },
{ field: 'argv', header: 'Arguments' },
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize }
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize },
{ field: 'installed_templates_version', header: 'Templates Version' }
]
/** Columns for cloud distribution */
@@ -97,6 +101,13 @@ const cloudColumns: ColumnDef[] = [
const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
function isOutdated(column: ColumnDef): boolean {
if (column.field !== 'installed_templates_version') return false
const installed = props.stats.system.installed_templates_version
const required = props.stats.system.required_templates_version
return !!installed && !!required && installed !== required
}
const getDisplayValue = (column: ColumnDef) => {
const value = systemInfo.value[column.field]
if (column.formatNumber && typeof value === 'number') {

View File

@@ -0,0 +1,189 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { nextTick, ref } from 'vue'
import VirtualGrid from './VirtualGrid.vue'
type TestItem = { key: string; name: string }
let mockedWidth: Ref<number>
let mockedHeight: Ref<number>
let mockedScrollY: Ref<number>
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
return {
...actual,
useElementSize: () => ({ width: mockedWidth, height: mockedHeight }),
useScroll: () => ({ y: mockedScrollY })
}
})
beforeEach(() => {
mockedWidth = ref(400)
mockedHeight = ref(200)
mockedScrollY = ref(0)
})
function createItems(count: number): TestItem[] {
return Array.from({ length: count }, (_, i) => ({
key: `item-${i}`,
name: `Item ${i}`
}))
}
describe('VirtualGrid', () => {
const defaultGridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '1rem'
}
it('renders items within the visible range', async () => {
const items = createItems(100)
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 100,
defaultItemWidth: 100,
maxColumns: 4,
bufferRows: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems.length).toBeGreaterThan(0)
expect(renderedItems.length).toBeLessThan(items.length)
wrapper.unmount()
})
it('provides correct index in slot props', async () => {
const items = createItems(20)
const receivedIndices: number[] = []
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 50,
defaultItemWidth: 100,
maxColumns: 1,
bufferRows: 0
},
slots: {
item: ({ index }: { index: number }) => {
receivedIndices.push(index)
return null
}
},
attachTo: document.body
})
await nextTick()
expect(receivedIndices.length).toBeGreaterThan(0)
expect(receivedIndices[0]).toBe(0)
for (let i = 1; i < receivedIndices.length; i++) {
expect(receivedIndices[i]).toBe(receivedIndices[i - 1] + 1)
}
wrapper.unmount()
})
it('respects maxColumns prop', async () => {
const items = createItems(10)
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
maxColumns: 2
},
attachTo: document.body
})
await nextTick()
const gridElement = wrapper.find('[style*="display: grid"]')
expect(gridElement.exists()).toBe(true)
const gridEl = gridElement.element as HTMLElement
expect(gridEl.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
wrapper.unmount()
})
it('renders empty when no items provided', async () => {
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items: [],
gridStyle: defaultGridStyle
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
}
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems.length).toBe(0)
wrapper.unmount()
})
it('forces cols to maxColumns when maxColumns is finite', async () => {
mockedWidth.value = 100
mockedHeight.value = 200
mockedScrollY.value = 0
const items = createItems(20)
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 50,
defaultItemWidth: 200,
maxColumns: 4,
bufferRows: 0
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems.length).toBeGreaterThan(0)
expect(renderedItems.length % 4).toBe(0)
wrapper.unmount()
})
})

View File

@@ -1,17 +1,16 @@
<template>
<div
ref="container"
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
class="h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable] scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
>
<div :style="topSpacerStyle" />
<div :style="mergedGridStyle">
<div
v-for="item in renderedItems"
v-for="(item, i) in renderedItems"
:key="item.key"
class="transition-[width] duration-150 ease-out"
data-virtual-grid-item
>
<slot name="item" :item="item" />
<slot name="item" :item :index="state.start + i" />
</div>
</div>
<div :style="bottomSpacerStyle" />
@@ -66,9 +65,10 @@ const { y: scrollY } = useScroll(container, {
eventListenerOptions: { passive: true }
})
const cols = computed(() =>
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
)
const cols = computed(() => {
if (maxColumns !== Infinity) return maxColumns
return Math.floor(width.value / itemWidth.value) || 1
})
const mergedGridStyle = computed<CSSProperties>(() => {
if (maxColumns === Infinity) return gridStyle
@@ -101,8 +101,9 @@ const renderedItems = computed(() =>
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
)
function rowsToHeight(rows: number): string {
return `${(rows / cols.value) * itemHeight.value}px`
function rowsToHeight(itemsCount: number): string {
const rows = Math.ceil(itemsCount / cols.value)
return `${rows * itemHeight.value}px`
}
const topSpacerStyle = computed<CSSProperties>(() => ({
height: rowsToHeight(state.value.start)
@@ -118,11 +119,10 @@ whenever(
}
)
const updateItemSize = () => {
function updateItemSize(): void {
if (container.value) {
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
// Don't update item size if the first item is not rendered yet
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
if (itemHeight.value !== firstItem.clientHeight) {

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import {
DropdownMenuContent,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
const { source, align = 'start' } = defineProps<{
source: string
align?: 'start' | 'center' | 'end'
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const { menuItems } = useWorkflowActionsMenu(
() => useCommandStore().execute('Comfy.RenameWorkflow'),
{ isRoot: true }
)
function handleOpen(open: boolean) {
if (open) {
useTelemetry()?.trackUiButtonClicked({
button_id: source
})
}
}
</script>
<template>
<DropdownMenuRoot @update:open="handleOpen">
<DropdownMenuTrigger as-child>
<slot name="button">
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
showDelay: 300,
hideDelay: 300
}"
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i
class="size-4"
:class="
canvasStore.linearMode
? 'icon-[lucide--panels-top-left]'
: 'icon-[comfy--workflow]'
"
/>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
</Button>
</slot>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:align
:side-offset="5"
:collision-padding="10"
class="z-1000 rounded-lg px-2 py-3 min-w-56 bg-base-background shadow-interface border border-border-subtle"
>
<WorkflowActionsList :items="menuItems" />
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>

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