Compare commits

..

60 Commits

Author SHA1 Message Date
bymyself
b8128881ee fix: space bar panning over Vue nodes in standard nav mode
Bridge LGraphCanvas.read_only to Vue reactivity via onReadOnlyChanged
callback so the existing CSS pointer-events-auto/none toggle on
LGraphNode.vue and NodeWidgets.vue re-evaluates when space key
toggles panning mode. Events then fall through to the LiteGraph
canvas naturally — no per-handler forwarding or force flags needed.

Fixes #7806

Amp-Thread-ID: https://ampcode.com/threads/T-019c796c-e83c-769d-85f4-20a349994bad
2026-02-21 21:37:26 -08:00
Christian Byrne
38edba7024 fix: exclude missing assets from cloud mode dropdown (COM-14333) (#8747)
## Summary

Fixes a bug where non-existent images appeared in the asset search
dropdown when loading workflows that reference images the user doesn't
have in cloud mode.

## Changes

- Add `displayItems` prop to `FormDropdown` and `FormDropdownInput` for
showing selected values that aren't in the dropdown list
- Exclude `missingValueItem` from cloud asset mode `dropdownItems` while
still displaying it in the input field via `displayItems`
- Use localized error messages in `ImagePreview` for missing images
(`g.imageDoesNotExist`, `g.unknownFile`)
- Add tests for cloud asset mode behavior in
`WidgetSelectDropdown.test.ts`

## Context

The `missingValueItem` was originally added in PR #8276 for template
workflows. This fix keeps that behavior for local mode but excludes it
from cloud asset mode dropdown. Cloud users can't access files they
don't own, so showing them as search results causes confusion.

## Testing

- Added unit tests for cloud asset mode behavior
- Verified existing tests pass
- All quality gates pass: typecheck, lint, format, tests

Fixes COM-14333



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8747-fix-exclude-missing-assets-from-cloud-mode-dropdown-COM-14333-3016d73d365081e3ab47c326d791257e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-13 14:30:55 -08:00
Comfy Org PR Bot
f851c3189f 1.40.2 (#8842)
Patch version increment to 1.40.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8842-1-40-2-3066d73d365081638b92c580d8d8579d)
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>
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-02-13 08:12:53 -08:00
pythongosssss
71d26eb4d9 Fix storybook build (#8849)
## Summary

The strictExecutionOrder looks to have accidently been removed.
This breaks the storybook build:
https://github.com/vitejs/rolldown-vite/issues/182#issuecomment-3035289157

https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/21979369339/job/63498007630?pr=8842
2026-02-13 15:57:58 +01:00
Terry Jia
d04dd32235 fix: make ResizeHandle interface properties readonly (#8847)
## Summary

improve for https://github.com/Comfy-Org/ComfyUI_frontend/pull/8845

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8847-fix-make-ResizeHandle-interface-properties-readonly-3066d73d3650814da6cee7bb955bbeb7)
by [Unito](https://www.unito.io)
2026-02-12 23:23:44 -05:00
Terry Jia
c52f48af45 feat(vueNodes): support resizing from all four corners (#8845)
## Summary
(Not sure we need this, and I don't know the reason why we only have one
cornor support previously, but it is requested by QA reporting in
Notion)

Add resize handles at all four corners (NW, NE, SW, SE) of Vue nodes,
matching litegraph's multi-corner resize behavior.

Vue nodes previously only supported resizing from the bottom-right (SE)
corner. This adds handles at all four corners with direction-aware delta
math, snap-to-grid support, and minimum size enforcement that keeps the
opposite corner anchored.
The content-driven minimum height is measured from the DOM at resize
start to prevent the node from sliding when dragged past its minimum
size.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/c9d30d93-8243-4c44-a417-5ca6e9978b3b
2026-02-12 22:28:21 -05:00
AustinMroz
01cf3244b8 Fix hover state on collapsed bottom button (#8837)
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/0de0790a-e9f0-432c-9501-eae633e341b6"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/588221b0-b34a-4d35-8393-1bc1ec36c285"
/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8837-Fix-hover-state-on-collapsed-bottom-button-3056d73d36508139919fc1d750c6b135)
by [Unito](https://www.unito.io)
2026-02-12 18:35:37 -08:00
Terry Jia
0f33444eef fix: undo breaking Vue node image preview reactivity (#8839)
## Summary
restoreOutputs was assigning the same object reference to both
app.nodeOutputs and the Pinia reactive ref. This caused subsequent
writes via setOutputsByLocatorId to mutate the reactive proxy's target
through the raw reference before the proxy write, making Vue detect no
change and skip reactivity updates permanently.

Shallow-copy the outputs when assigning to the reactive ref so the proxy
target remains a separate object from app.nodeOutputs.

## Screenshots
before


https://github.com/user-attachments/assets/98f2b17c-87b9-41e7-9caa-238e36c3c032


after


https://github.com/user-attachments/assets/cb6e1d25-bd2e-41ed-a536-7b8250f858ec

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8839-fix-undo-breaking-Vue-node-image-preview-reactivity-3056d73d365081d2a1c7d4d9553f30e0)
by [Unito](https://www.unito.io)
2026-02-12 15:37:02 -05:00
pythongosssss
44ce9379eb Defer vue node layout calculations on hidden browser tabs (#8805)
## Summary

If you load the window in Nodes 2.0, then switch tabs while it is still
loading, the position of the nodes is calculated incorrectly due to
useElementBounding returning left=0, top=0 for the canvas element in a
hidden tab, causing clientPosToCanvasPos to miscalculate node positions
from the ResizeObserver measurements

## Changes

- **What**: 
- Store observed elements while document is in hidden state
- Re-observe when tab becomes visible

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8805-Defer-vue-node-layout-calculations-on-hidden-browser-tabs-3046d73d365081019ae6c403c0ac6d1a)
by [Unito](https://www.unito.io)
2026-02-12 11:48:20 -08:00
pythongosssss
138fa6a2ce Resolve issues with undo with Nodes 2.0 to fix link dragging/rendering (#8808)
## Summary

Resolves the following issue:
1. Enable Nodes 2.0
2. Load default workflow
3. Move any node e.g. VAE decode
4. Undo

All links go invisible, input/output slots no longer function

## Changes

- **What**
- Fixes slot layouts being deleted during undo/redo in
`handleDeleteNode`, which prevented link dragging from nodes after undo.
Vue patches (not remounts) components with the same key, so `onMounted`
never fires to re-register them - these were already being cleared up on
unmounted
- Fixes links disappearing after undo by clearing `pendingSlotSync` when
slot layouts already exist (undo/redo preserved them), rather than
waiting for Vue mounts that do not happen

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8808-Resolve-issues-with-undo-with-Nodes-2-0-to-fix-link-dragging-rendering-3046d73d3650818bbb0adf0104a5792d)
by [Unito](https://www.unito.io)
2026-02-12 11:46:49 -08:00
Comfy Org PR Bot
ce9d0ca670 1.40.1 (#8815)
Patch version increment to 1.40.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8815-1-40-1-3056d73d365081ed8adcf690bd07e1cd)
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-12 02:48:38 -08:00
Terry Jia
6cf0357b3e fix(vueNodes): sync node size changes from extensions to Vue components (#7993)
## Summary
When extensions like KJNodes call node.setSize(), the Vue component now
properly updates its CSS variables to reflect the new size.

## Changes:
- LGraphNode pos/size setters now always sync to layoutStore with Canvas
source
- LGraphNode.vue listens to layoutStore changes and updates CSS
variables
- Fixed height calculation to account for NODE_TITLE_HEIGHT difference
- Removed _syncToLayoutStore flag (simplified - layoutStore ignores
non-existent nodes)
- Use setPos() helper method instead of direct pos[0]/pos[1] assignment

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/236a173a-e41d-485b-8c63-5c28ef1c69bf


after

https://github.com/user-attachments/assets/5fc3f7e4-35c7-40e1-81ac-38a35ee0ac1b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7993-fix-vueNodes-sync-node-size-changes-from-extensions-to-Vue-components-2e76d73d3650815799c5f2d9d8c7dcbf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-12 05:38:18 -05:00
Christian Byrne
c0c81dba49 fix: rename "Manage Extensions" label to "Extensions" (#8681)
## Summary

Rename the manager button label from "Manage Extensions" to "Extensions"
in both the `menu` and `commands` i18n sections.

## Changes

Updated `manageExtensions` value in `src/locales/en/main.json` (both
`menu` and `commands` sections).

- Fixes follow-up from #8644

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8681-fix-rename-Manage-Extensions-label-to-Extensions-2ff6d73d365081c9aef5c94f745299f6)
by [Unito](https://www.unito.io)
2026-02-12 18:36:07 +09:00
Jin Yi
553ea63357 [refactor] Migrate SettingDialog to BaseModalLayout design system (#8270) 2026-02-12 16:27:11 +09:00
Alexander Brown
995ebc4ba4 fix: default onboardingSurveyEnabled flag to false (#8829)
Fixes race condition where the onboarding survey could appear
intermittently before the actual feature flag value is fetched from the
server.

https://claude.ai/code/session_01EpCtunck6b89gpUFfWGKmZ

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8829-fix-default-onboardingSurveyEnabled-flag-to-false-3056d73d3650819b9f4cd0530efe8f39)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-11 23:13:12 -08:00
AustinMroz
d282353370 Add z-index to popover component (#8823)
This fixes the inability to see the value control popover in the
properties panel.
<img width="574" height="760" alt="image"
src="https://github.com/user-attachments/assets/c2cfa00f-f9b1-4c86-abda-830fd780d3f8"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8823-Add-z-index-to-popover-component-3056d73d365081e6b2dbe15517e4c4e0)
by [Unito](https://www.unito.io)
2026-02-11 21:33:05 -08:00
Simula_r
85ae0a57c3 feat: invite member upsell for single-seat plans (#8801)
## Summary

- Show an upsell dialog when single-seat plan users try to invite
members, with a banner on the members panel directing them to upgrade.
- Misc fixes for member max seat display

## Changes

- **What**: `InviteMemberUpsellDialogContent.vue`,
`MembersPanelContent.vue`, `WorkspacePanelContent.vue`

## Screenshots

<img width="2730" height="1907" alt="image"
src="https://github.com/user-attachments/assets/e39a23be-8533-4ebb-a4ae-2797fc382bc2"
/>
<img width="2730" height="1907" alt="image"
src="https://github.com/user-attachments/assets/bec55867-1088-4d3a-b308-5d5cce64c8ae"
/>



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8801-feat-invite-member-upsell-for-single-seat-plans-3046d73d365081349b09fe1d4dc572e8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-11 21:15:59 -08:00
Terry Jia
0d64d503ec fix(vueNodes): propagate height to DOM widget children (#8821)
## Summary
DOM widgets using CSS background-image (e.g. KJNodes FastPreview)
collapsed to 0px height in vueNodes mode because background-image
doesn't contribute to intrinsic element size. Make WidgetDOM a flex
column container so mounted extension elements fill the available grid
row space.

## Screenshots (if applicable)
before 



https://github.com/user-attachments/assets/85de0295-1f7c-4142-ac15-1e813c823e3f


after


https://github.com/user-attachments/assets/824ab662-14cb-412d-93dd-97c0f549f992

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8821-fix-vueNodes-propagate-height-to-DOM-widget-children-3056d73d36508134926dc67336fd0d70)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-11 23:05:54 -05:00
Alexander Brown
30ef6f2b8c Fix: Disabling textarea when linked. (#8818)
## Summary

Misaligned option setting when building the SimplifiedWidget.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8818-Fix-Disabling-textarea-when-linked-3056d73d365081f581a9f1322aaf60bd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-11 18:45:52 -08:00
Alexander Brown
6012341fd1 Fix: Widgets Column sizing and ProgressText Widget (#8817)
## Summary

Fixes an issue where the label/control columns for widgets were
dependent on the contents of the progress text display widget.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8817-Fix-Widgets-Column-sizing-and-ProgressText-Widget-3056d73d36508141a714fe342c386eef)
by [Unito](https://www.unito.io)
2026-02-11 18:45:35 -08:00
Brian Jemilo II
a80f6d7922 Batch Drag & Drop Images (#8282)
## Summary

<!-- One sentence describing what changed and why. -->
Added feature to drag and drop multiple images into the UI and connect
them with a Batch Images node with tests to add convenience for users.
Only works with a group of images, mixing files not supported.

## Review Focus
<!-- Critical design decisions or edge cases that need attention -->
I've updated our usage of Litegraph.createNode, honestly, that method is
pretty bad, onNodeCreated option method doesn't even return the node
created. I think I will probably go check out their repo to do a PR over
there. Anyways, I made a createNode method to avoid race conditions when
creating nodes for the paste actions. Will allow us to better
programmatically create nodes that do not have workflows that also need
to be connected to other nodes.

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

https://www.notion.so/comfy-org/Implement-Multi-image-drag-and-drop-to-canvas-2eb6d73d36508195ad8addfc4367db10

## Screenshots (if applicable)

https://github.com/user-attachments/assets/d4155807-56e2-4e39-8ab1-16eda90f6a53

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8282-Batch-Drag-Drop-Images-2f16d73d365081c1ab31ce9da47a7be5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Austin Mroz <austin@comfy.org>
2026-02-11 17:39:41 -08:00
Johnpaul Chiwetelu
0f5aca6726 fix: set audio widget value after file upload (#8814)
## Summary

Set `audioWidget.value` after uploading an audio file so the combo
dropdown reflects the newly uploaded file.

## Changes

- **What**: Added `audioWidget.value = path` in the `uploadFile`
function before the callback call, matching the pattern used by the
image upload widget.

## Review Focus

One-line fix. The image upload widget already does this correctly in
`useImageUploadWidget.ts:86`. Without this line, the file uploads but
the dropdown doesn't update to show the selection.

Fixes #8800

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8814-fix-set-audio-widget-value-after-file-upload-3056d73d365081a0af90d4e096eb4975)
by [Unito](https://www.unito.io)
2026-02-11 17:00:28 -08:00
Johnpaul Chiwetelu
4fc1d2ef5b feat: No Explicit Any (#8601)
## Summary
- Add `typescript/no-explicit-any` rule to `.oxlintrc.json` to enforce
no explicit `any` types
- Fix all 40 instances of explicit `any` throughout the codebase
- Improve type safety with proper TypeScript types

## Changes Made

### Configuration
- Added `typescript/no-explicit-any` rule to `.oxlintrc.json`

### Type Fixes
- Replaced `any` with `unknown` for truly unknown types
- Updated generic type parameters to use `unknown` defaults instead of
`any`
- Fixed method `this` parameters to avoid variance issues
- Updated component props to match new generic types
- Fixed test mocks to use proper type assertions

### Key Files Modified
- `src/types/treeExplorerTypes.ts`: Updated TreeExplorerNode interface
generics
- `src/platform/settings/types.ts`: Fixed SettingParams generic default
- `src/lib/litegraph/src/LGraph.ts`: Fixed ParamsArray type constraint
- `src/extensions/core/electronAdapter.ts`: Fixed onChange callbacks
- `src/views/GraphView.vue`: Added proper type imports
- Multiple test files: Fixed type assertions and mocks

## Test Plan
- [x] All lint checks pass (`pnpm lint`)
- [x] TypeScript compilation succeeds (`pnpm typecheck`)
- [x] Pre-commit hooks pass
- [x] No regression in functionality

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8601-feat-add-typescript-no-explicit-any-rule-and-fix-all-instances-2fd6d73d365081fd9beef75d5a6daf5b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-12 00:13:48 +01:00
Benjamin Lu
92b7437d86 fix: scope manager button red dot to conflicts (#8810)
## Summary

Scope the top-menu Manager button red dot to manager conflict state
only, so release-update notifications do not appear on the Manager
button.

## Changes

- **What**:
- In `TopMenuSection`, remove release-store coupling and use only
`useConflictAcknowledgment().shouldShowRedDot` for the Manager button
indicator.
- Add a regression test in `TopMenuSection.test.ts` that keeps the
release red dot true while asserting the Manager button dot only appears
when the conflict red dot is true.

## Review Focus

- Confirm Manager button notification semantics are conflict-specific
and no longer mirror release notifications.
- Confirm the new test fails if release-store coupling is reintroduced.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8810-fix-scope-manager-button-red-dot-to-conflicts-3046d73d3650817887b9ca9c33919f48)
by [Unito](https://www.unito.io)
2026-02-11 15:00:25 -08:00
Simula_r
dd1fefe843 fix: credit display and top up and other UI display if personal membe… (#8784)
## Summary

Consolidate scattered role checks for credits, top-up, and subscribe
buttons into centralized workspace permissions (canTopUp,
canManageSubscription), ensuring "Add Credits" requires an active
subscription, subscribe buttons only appear when needed, and team
members see appropriately restricted billing UI.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8784-fix-credit-display-and-top-up-and-other-UI-display-if-personal-membe-3036d73d3650810fbc2de084f738943c)
by [Unito](https://www.unito.io)
2026-02-11 14:26:35 -08:00
Johnpaul Chiwetelu
adcb663b3e fix: link dragging offset on external monitors in Vue nodes mode (#8809)
## Summary

Fix link dragging offset when using Vue nodes mode on external monitors
with different DPI than the primary display.

## Changes

- **What**: Derive overlay canvas scale from actual canvas dimensions
(`canvas.width / canvas.clientWidth`) instead of
`window.devicePixelRatio`, fixing DPR mismatch. Map `LinkDirection.NONE`
to `'none'` in `convertDirection()` instead of falling through to
`'right'`.

## Before


https://github.com/user-attachments/assets/f5d04617-369f-4649-af60-11d31e27a75c



## After


https://github.com/user-attachments/assets/76434d2b-d485-43de-94f6-202a91f73edf


## Review Focus

The overlay canvas copies dimensions from the main canvas (which
includes DPR scaling from `resizeCanvas`). When the page loads on a
monitor whose DPR differs from what `resizeCanvas` used,
`window.devicePixelRatio` no longer matches the canvas's internal-to-CSS
ratio, causing all drawn link positions to be offset. The fix derives
scale directly from the canvas itself.

`LinkDirection.NONE = 0` is falsy, so it was caught by the `default`
case in `convertDirection()`, adding an unwanted directional curve to
moved input links.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8809-fix-link-dragging-offset-on-external-monitors-in-Vue-nodes-mode-3046d73d36508143b600f23f5fe07044)
by [Unito](https://www.unito.io)
2026-02-11 22:40:17 +01:00
AustinMroz
28b171168a New bottom button and badges (#8603)
- "Enter Subgraph" "Show advanced inputs" and a new "show node Errors"
button now use a combined button design at the bottom of the node.
- A new "Errors" tab is added to the right side panel
- After a failed queue, the label of an invalid widget is now red.
- Badges other than price are now displayed on the bottom of the node.
- Price badge will now truncate from the first space, prioritizing the
sizing of the node title
- An indicator for the node resize handle is now displayed while mousing
over the node.

<img width="669" height="233" alt="image"
src="https://github.com/user-attachments/assets/53b3b59c-830b-474d-8f20-07f557124af7"
/>


![resize](https://github.com/user-attachments/assets/e2473b5b-fe4d-4f1e-b1c3-57c23d2a0349)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-10 23:29:45 -08:00
Alexander Brown
69062c6da1 deps: Update vite (#8509)
## Summary

Update from beta.8 to beta.12

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8509-deps-Update-vite-2f96d73d3650814c96dbe14cdfb02151)
by [Unito](https://www.unito.io)
2026-02-10 22:42:26 -08:00
Alexander Brown
a7c2115166 feat: add WidgetValueStore for centralized widget value management (#8594)
## Summary

Implements Phase 1 of the **Vue-owns-truth** pattern for widget values.
Widget values are now canonical in a Pinia store; `widget.value`
delegates to the store while preserving full backward compatibility.

## Changes

- **New store**: `src/stores/widgetValueStore.ts` - centralized widget
value storage with `get/set/remove/removeNode` API
- **BaseWidget integration**: `widget.value` getter/setter now delegates
to store when widget is associated with a node
- **LGraphNode wiring**: `addCustomWidget()` automatically calls
`widget.setNodeId(this.id)` to wire widgets to their nodes
- **Test fixes**: Added Pinia setup to test files that use widgets

## Why

This foundation enables:
- Vue components to reactively bind to widget values via `computed(() =>
store.get(...))`
- Future Yjs/CRDT backing for real-time collaboration
- Cleaner separation between Vue state and LiteGraph rendering

## Backward Compatibility

| Extension Pattern | Status |
|-------------------|--------|
| `widget.value = x` |  Works unchanged |
| `node.widgets[i].value` |  Works unchanged |
| `widget.callback` |  Still fires |
| `node.onWidgetChanged` |  Still fires |

## Testing

-  4252 unit tests pass
-  Build succeeds

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8594-feat-add-WidgetValueStore-for-centralized-widget-value-management-2fc6d73d36508160886fcb9f3ebd941e)
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-10 19:37:17 -08:00
Csongor Czezar
d044bed9b2 feat: use object info display_name as fallback before node name (#7622)
## Description
Implements fallback to object info display_name before using internal
node name when display_name is unavailable.

## Related Issue
Related to backend PR: comfyanonymous/ComfyUI#11340

## Changes
- Modified `getNodeDefs()` to use object info `display_name` before
falling back to `name`
- Added unit tests for display name fallback behavior

## Testing
- All existing tests pass
- Added 4 new unit tests covering various display_name scenarios

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7622-W-I-P-feat-use-object-info-display_name-as-fallback-before-node-name-2cd6d73d365081deb22fe5ed00e6dc2e)
by [Unito](https://www.unito.io)
2026-02-10 22:18:48 -05:00
Comfy Org PR Bot
d873c8048f 1.40.0 (#8797)
Minor version increment to 1.40.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8797-1-40-0-3046d73d36508109b4b7ed3f6ca4530e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-10 19:06:48 -08:00
Comfy Org PR Bot
475d7035f7 1.39.12 (#8790)
Patch version increment to 1.39.12

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8790-1-39-12-3046d73d3650812faaf5dfaf71f6a02a)
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-10 18:52:06 -08:00
guill
eb6bf91e20 fix(download): Use content-disposition filename (#8785)
When we download an output, we now check if there's a filename defined
in the content-disposition and use that if there is.

## Summary
This has been primarily an issue on Comfy Cloud where assets are
content-addressed. Before now,
the downloaded files have retained the hash as the filename. With this
change, downloaded files
will use the user-supplied filename instead.

## Changes

- **What**: Use content-disposition filename when downloading assets

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8785-fix-download-Use-content-disposition-filename-3046d73d365081ec952ef3c1930e773d)
by [Unito](https://www.unito.io)
2026-02-10 18:50:42 -08:00
Csongor Czezar
422227d2fc fix: viewport overflow in manager (#7775)
### **Summary**
Fixes viewport overflow in version selector dropdown when Manager dialog
is positioned near edges

**Changes**
Removed popover arrow for visual consistency across all positions
Implemented dialog boundary detection to constrain popover within
Manager viewport

**Testing**
All existing unit tests pass (17/17)
Visually tested across different screen positions


![after-fix-viewport-all-positions](https://github.com/user-attachments/assets/287952f1-eda3-4388-9d6a-8f4316acea7f)

![before-fix-viewport-low](https://github.com/user-attachments/assets/b88dc61d-896b-48af-870f-2b5d52a11a98)

![before-fix-viewport-high](https://github.com/user-attachments/assets/7a39c845-0593-480e-843e-d5da30b48661)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7775-fix-viewport-overflow-in-manager-2d76d73d365081a88c1df2a103c5925e)
by [Unito](https://www.unito.io)
2026-02-10 21:29:44 -05:00
Terry Jia
10e9bc2f8d fix: extract WidgetCallbackOptions interface and add curly braces (#8791)
## Summary

improve for https://github.com/Comfy-Org/ComfyUI_frontend/pull/8774

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8791-fix-extract-WidgetCallbackOptions-interface-and-add-curly-braces-3046d73d365081c49e37c0a2596f1958)
by [Unito](https://www.unito.io)
2026-02-10 17:47:37 -08:00
Terry Jia
f7b835e6a5 fix: disable control after generate during partial execution (#8774)
## Summary
Passes an isPartialExecution flag through widget
beforeQueued/afterQueued callbacks so control-after-generate widgets
skip value modifications (randomize, increment, decrement) when the user
queues selected output nodes via partial execution.

requested by @christian-byrne in notion

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/3e723087-8849-457b-9f95-b8b5fceab0ed


after


https://github.com/user-attachments/assets/d9816667-51e0-4538-a012-9c84d0944019

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8774-fix-disable-control-after-generate-during-partial-execution-3036d73d365081688ca3d6b0506d69ca)
by [Unito](https://www.unito.io)
2026-02-10 20:13:03 -05:00
Johnpaul Chiwetelu
7f30d6b6a5 feat: add visual indicator for list output slots (#8766)
## Summary

Add rounded square dot shape and "(Iterative)" tooltip for list-type
output slots in Vue nodes, matching litegraph's visual indicator.

## Changes

- **What**: `SlotConnectionDot.vue` renders `rounded-[1px]` instead of
`rounded-full` when slot shape is `RenderShape.GRID`. `OutputSlot.vue`
appends "(Iterative)" to the tooltip for these slots.

<img width="807" height="542" alt="Screenshot 2026-02-10 at 03 38 42"
src="https://github.com/user-attachments/assets/137b60c5-ac3b-457f-a52d-58f5f28a59ea"
/>


## Review Focus
- i18n key added for the iterative suffix

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8766-feat-add-visual-indicator-for-list-output-slots-3036d73d3650813aad85ce094d29c42b)
by [Unito](https://www.unito.io)
2026-02-11 01:49:58 +01:00
Benjamin Lu
da56c9e554 feat: integrate Impact telemetry with checkout attribution for subscriptions (#8688)
Implement Impact telemetry and checkout attribution through cloud
subscription checkout flows.

This PR adds Impact.com tracking support and carries attribution context
from landing-page visits into subscription checkout requests so
conversion attribution can be validated end-to-end.

- Register a new `ImpactTelemetryProvider` during cloud telemetry
initialization.
- Initialize the Impact queue/runtime (`ire`) and load the Universal
Tracking Tag script once.
- Invoke `ire('identify', ...)` on page views with dynamic `customerId`
and SHA-1 `customerEmail` (or empty strings when unknown).
- Expand checkout attribution capture to include `im_ref`, UTM fields,
and Google click IDs, with local persistence across navigation.
- Attempt `ire('generateClickId')` with a timeout and fall back to
URL/local attribution when unavailable.
- Include attribution payloads in checkout creation requests for both:
  - `/customers/cloud-subscription-checkout`
  - `/customers/cloud-subscription-checkout/{tier}`
- Extend begin-checkout telemetry metadata typing to include attribution
fields.
- Add focused unit coverage for provider behavior, attribution
persistence/fallback logic, and checkout request payloads.

Tradeoffs / constraints:
- Attribution collection is treated as best-effort in tiered checkout
flow to avoid blocking purchases.
- Backend checkout handlers must accept and process the additional JSON
attribution fields.

## Screenshots

<img width="908" height="208" alt="image"
src="https://github.com/user-attachments/assets/03c16d60-ffda-40c9-9bd6-8914d841be50"/>
<img width="1144" height="460" alt="image"
src="https://github.com/user-attachments/assets/74b97fde-ce0a-43e6-838e-9a4aba484488"/>
<img width="1432" height="320" alt="image"
src="https://github.com/user-attachments/assets/30c22a9f-7bd8-409f-b0ef-e4d02343780a"/>
<img width="341" height="135" alt="image"
src="https://github.com/user-attachments/assets/f6d918ae-5f80-45e0-855a-601abea61dec"/>
2026-02-10 16:40:51 -08:00
Alexander Brown
79063edf54 Remove comfy logo splash screen. (#8786)
## Summary

```



```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8786-Remove-comfy-logo-splash-screen-3046d73d3650816d92c3ff04afeb8cf6)
by [Unito](https://www.unito.io)
2026-02-10 16:32:18 -08:00
Johnpaul Chiwetelu
d4c40f5255 fix: right-click context menu disabled when selection toolbox is off (#8781)
## Summary

- Move `NodeContextMenu` from `SelectionToolbox.vue` to
`GraphCanvas.vue` so the right-click context menu renders independently
of the `Comfy.Canvas.SelectionToolbox` setting

- Fixes #8417

## Test plan

- [x] Disable selection toolbox in settings, right-click a node —
context menu appears
- [x] Enable selection toolbox, right-click a node — context menu still
appears
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8781-fix-right-click-context-menu-disabled-when-selection-toolbox-is-off-3036d73d36508168a9add58e060b7e93)
by [Unito](https://www.unito.io)
2026-02-11 00:35:52 +01:00
Johnpaul Chiwetelu
1e1d5c8308 fix: stop suppressing link rendering during node resize (#8780)
## Summary

Stop link flickering when resizing nodes by removing the
`pendingSlotSync` flag assertion from `scheduleSlotLayoutSync`.

## Changes

- **What**: Remove `layoutStore.setPendingSlotSync(true)` from
`scheduleSlotLayoutSync()` in `useSlotElementTracking.ts`. This call was
introduced in #8367 for graph reconfiguration but was also triggered on
every node resize, causing all links to disappear for one frame per
resize tick. The reconfigure path in `app.ts`
(`addAfterConfigureHandler`) still sets the flag explicitly, so
undo/redo link suppression is unaffected.

## Review Focus

The `pendingSlotSync` flag is still managed correctly for graph
reconfiguration: `app.ts:748` sets it before configure, and the
`finally` block flushes it synchronously. The
`flushScheduledSlotLayoutSync` early-return (pendingNodes empty but
graph has nodes) continues to handle late-mounting Vue components during
reconfigure.

## Before

https://github.com/user-attachments/assets/28cfe4d8-f3f0-46f1-a717-5cb81a28dd75



## After




https://github.com/user-attachments/assets/9445fd00-91f8-4d1e-90ac-86d138d29842

Fixes #8696

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8780-fix-stop-suppressing-link-rendering-during-node-resize-3036d73d365081029820ccfd57425a07)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-11 00:03:40 +01:00
Johnpaul Chiwetelu
e411a104f4 feat: scroll to specific setting when opening settings dialog (#8761)
## Summary

- Adds `settingId` parameter to `showSettingsDialog` that auto-navigates
to the correct category tab, scrolls to the setting, and briefly
highlights it with a CSS pulse animation
- Adds `data-setting-id` attributes to setting items for stable DOM
targeting
- Adds "Don't show this again" checkbox with "Re-enable in Settings"
deep-link to the missing nodes dialog
- Adds "Re-enable in Settings" deep-link to missing models and blueprint
overwrite "Don't show this again" checkboxes

- Fixes #3437

## Test plan

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] Unit tests pass (59/59 including 5 new tests for `useSettingUI`)



https://github.com/user-attachments/assets/a9e80aea-7b69-4686-b030-55a2e0570ff0



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8761-feat-scroll-to-specific-setting-when-opening-settings-dialog-3036d73d365081d18d9afe9f9ed41ebc)
by [Unito](https://www.unito.io)
2026-02-10 23:00:46 +01:00
Terry Jia
19a724710c fix: address review nits in load3d (#8779)
## Summary
- Refactor getModelUrl to use const instead of let
- add missing language key

improve for https://github.com/Comfy-Org/ComfyUI_frontend/pull/8765

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8779-fix-address-review-nits-in-load3d-3036d73d36508183af11c5e9bc545650)
by [Unito](https://www.unito.io)
2026-02-10 16:09:54 -05:00
Terry Jia
9ecbb3af27 Feat/3d dropdown (#8765)
## Summary
Add mesh_upload and upload_subfolder to combo input schema so
WidgetSelect detects mesh uploads generically instead of hardcoding node
type checks. Inject these flags in load3dLazy.ts so they are available
before THREE.js loads.

Also unify SUPPORTED_EXTENSIONS_ACCEPT across load3d and dropdown, pass
uploadSubfolder prop through to WidgetSelectDropdown for correct upload
path, and update error message to list all supported extensions.

replacement for https://github.com/Comfy-Org/ComfyUI_frontend/pull/7975

(We should include thumbnail but not yet, will do it later)

## Screenshots (if applicable)


https://github.com/user-attachments/assets/2cb4b1da-af4f-439b-9786-3ac780c2480d

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8765-Feat-3d-dropdown-3036d73d365081d8a10ee19d3ed7d295)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Kelly Yang <124ykl@gmail.com>
2026-02-10 15:36:57 -05:00
AustinMroz
581452d312 Austin/fix move subgraph input (#8777)
Previously, moving a subgraph input link and re-attaching to the same
input slot would result in an invalid link


![broken-link](https://github.com/user-attachments/assets/085a0a6f-281d-4e06-be58-e5bdc873f1d5)

This occurred because:
- A new link is created to which overwrites the target `input.link`
- The previous link is then disconnected, which clears `input.link`

This is solved by instead returning early if the target is the same as
the existing link.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8777-Austin-fix-move-subgraph-input-3036d73d365081318de3cccb926f7fe7)
by [Unito](https://www.unito.io)
2026-02-10 12:31:09 -08:00
Simula_r
9dde4e7bc7 feat: sort workspaces (#8770)
## Summary

Sort workspaces so that the personal workspace appears first, followed
by the rest in ascending order (oldest first) by created_at / joined_at.

## Changes

- **What**: teamWorkspaceStore.ts, teamWorkspaceStore.test.ts
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->
2026-02-10 10:11:35 -08:00
Johnpaul Chiwetelu
0288ea5b39 feat: add setMany to settingStore for batch setting updates (#8767)
## Summary
- Adds `setMany()` method to `settingStore` for updating multiple
settings in a single API call via the existing `storeSettings` endpoint
- Extracts shared setting-apply logic (`applySettingLocally`) to reduce
duplication between `set()` and `setMany()`
- Migrates all call sites where multiple settings were updated
sequentially to use `setMany()`

## Call sites updated
- `releaseStore.ts` — `handleSkipRelease`, `handleShowChangelog`,
`handleWhatsNewSeen` (3 settings each)
- `keybindingService.ts` — `persistUserKeybindings` (2 settings)
- `coreSettings.ts` — `NavigationMode.onChange` (2 settings)

## Test plan
- [x] Unit tests for `setMany` (batch update, skip unchanged, no-op when
unchanged)
- [x] Updated `releaseStore.test.ts` assertions to verify `setMany`
usage
- [x] Updated `useCoreCommands.test.ts` mock to include `setMany`
- [x] All existing tests pass
- [x] `pnpm typecheck`, `pnpm lint`, `pnpm format` pass

Fixes #1079

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8767-feat-add-setMany-to-settingStore-for-batch-setting-updates-3036d73d36508161b8b6d298e1be1b7a)
by [Unito](https://www.unito.io)
2026-02-10 13:47:53 +01:00
Comfy Org PR Bot
061e96e488 1.39.11 (#8763)
Patch version increment to 1.39.11

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8763-1-39-11-3036d73d365081458389fe558cd921ee)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-02-09 21:28:11 -08:00
Alexander Brown
ff9642d0cb feat: deduplicate subgraph node IDs on workflow load (experimental) (#8762)
## Summary

Add `ensureGlobalIdUniqueness` to reassign duplicate node IDs across
subgraphs when loading workflows, gated behind an experimental setting.

## Changes

- **What**: Shared `LGraphState` between root graph and subgraphs so ID
counters are global. Added `ensureGlobalIdUniqueness()` method that
detects and remaps colliding node IDs in subgraphs, preserving root
graph IDs as canonical and patching link references. Gated behind
`Comfy.Graph.DeduplicateSubgraphNodeIds` (experimental, default
`false`).
- **Dependencies**: None

## Review Focus

- Shared state override on `Subgraph` (getter delegates to root, setter
is no-op) — verify no existing code sets `subgraph.state` directly.
- `Math.max` state merging in `configure()` prevents ID counter
regression when loading subgraph definitions.
- Feature flag wiring: static property on `LGraph`, synced from settings
via `useLitegraphSettings`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8762-feat-deduplicate-subgraph-node-IDs-on-workflow-load-experimental-3036d73d36508184b6cee5876dc4d935)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-09 18:01:58 -08:00
AustinMroz
a6620a4ddc Fix edge cases in subgraph removal logic (#8758)
#8187 made removal of subgraphs cleanup the subgraph definition for the
removed graph and call onRemove handlers. However, it missed some edge
cases and broke subgraph conversion of selections containing subgraphs
which this PR tries to address.
- Deeply nested subgraphs are now also cleaned
- Adding a subgraphNode to the graph also ensures nested subgraphs are
added to subgraph definitions

Reminder: under this change, nodes can continue to exist after their
onRemoved handler has been called
- It may be better to instead perform the "garbage collection" of
subgraphs outside of the graph removal step to better handle edge cases
like subgraph conversion where a subgraph may continue to persist after
a parent subgraphNode has been removed from a graph.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8758-Fix-edge-cases-in-subgraph-removal-logic-3026d73d36508177b34ffdd2e0a114fe)
by [Unito](https://www.unito.io)
2026-02-09 13:55:23 -08:00
Benjamin Lu
9209badd37 feat: add KSampler live previews to assets sidebar jobs (#8723)
## Summary
Show live KSampler previews on active job cards/list items in the Assets
sidebar, while preserving existing fallback behavior.

## Changes
- **What**:
- Added a prompt-scoped job preview store (`jobPreviewStore`) gated by
`Comfy.Execution.PreviewMethod`.
- Wired `b_preview_with_metadata` handling to map previews by
`promptId`.
- Extended queue job view model with `livePreviewUrl` and consumed it in
both sidebar list and grid active job UIs.
  - Cleared prompt previews on execution reset.
- Added ref-counted shared blob URL lifecycle utility (`objectUrlUtil`)
and updated preview stores to retain/release shared URLs so each preview
event creates one object URL.
- Added/updated unit coverage in `useJobList.test.ts` for preview
enable/disable mapping.

## Review Focus
- Object URL lifecycle correctness across node previews and job previews
(retain/release behavior).
- Preview gating behavior when `Comfy.Execution.PreviewMethod` is
`none`.
- Active job UI fallback behavior (`livePreviewUrl` -> `iconImageUrl`).

## Screenshots (if applicable)
<img width="808" height="614" alt="image"
src="https://github.com/user-attachments/assets/37c66eb2-8c28-4eb4-bb86-5679cb77d740"
/>
<img width="775" height="345" alt="image"
src="https://github.com/user-attachments/assets/aa420642-b0d4-4ae6-b94a-e7934b5df9d6"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8723-feat-add-KSampler-live-previews-to-assets-sidebar-jobs-3006d73d365081aeb81dd8279bf99f94)
by [Unito](https://www.unito.io)
2026-02-09 10:49:27 -08:00
Benjamin Lu
815be49112 fix: keep begin_checkout user_id reactive in subscription flows (#8726)
## Summary

Use reactive `userId` reads for `begin_checkout` telemetry so delayed
auth state updates are reflected at event time instead of using a stale
snapshot.

## Changes

- **What**: switched subscription checkout telemetry paths to
`storeToRefs(useFirebaseAuthStore())` and read `userId.value` when
dispatching `trackBeginCheckout`.
- **What**: added regression tests that mutate `userId` after setup /
after checkout starts and assert telemetry uses the updated ID.

## Review Focus

- Verify `PricingTable` and `performSubscriptionCheckout` still emit
exactly one `begin_checkout` event per action, with `checkout_type:
change` and `checkout_type: new` in their respective paths.
- Verify the new tests would fail with stale store destructuring
(manually validated during development).

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8726-fix-keep-begin_checkout-user_id-reactive-in-subscription-flows-3006d73d365081888c84c0335ab52e09)
by [Unito](https://www.unito.io)
2026-02-09 02:01:23 -08:00
Hunter
adbfb83767 feat: wire renewal_date from cloud billing status (#8754)
## Summary

Wire `renewal_date` from the cloud `/billing/status` response into the
workspace subscription UI so users can see when their subscription
renews.

## Problem

The workspace billing adapter hardcoded `renewalDate: null` because the
cloud billing status endpoint didn't return a renewal date. The
`SubscriptionPanelContentWorkspace` component already has UI for
displaying it — it just had no data.

Personal Workspace (existing `cloud-subscription-status`):
<img width="181" height="112" alt="Screenshot 2026-02-08 at 7 54 53 PM"
src="https://github.com/user-attachments/assets/a96ba2cd-10d0-442a-ae72-dbc663a9e52b"
/>

Current missing data from `/billing/status`:
<img width="240" height="124" alt="Screenshot 2026-02-08 at 7 55 38 PM"
src="https://github.com/user-attachments/assets/a3f51ce3-6663-43e1-97ed-d012a6a8a5ba"
/>

## Solution

- Add `renewal_date?: string` to `BillingStatusResponse` interface
- Use `status.renewal_date ?? null` instead of hardcoded `null` in
`useWorkspaceBilling`

### Related
- Cloud PR: Comfy-Org/cloud#2370 (adds `renewal_date` to the endpoint)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8754-feat-wire-renewal_date-from-cloud-billing-status-3026d73d365081c7ae51d79ef0633a1d)
by [Unito](https://www.unito.io)
2026-02-08 23:49:20 -08:00
Terry Jia
3238ad3d32 fix: re-mount DOM widget elements after leaving Linear mode (#8753)
## Summary

LinearView renders its own WidgetDOM instances which steal the
widget.element via replaceChildren. When LinearView unmounts
(v-if="linearMode") the element is removed from the DOM entirely.
The canvas-side WidgetDOM stays mounted but its container is now empty,
so DOM widgets (e.g. Three.js scenes) disappear.

Watch canvasStore.linearMode and reclaim the element when switching back
from Linear to Canvas mode.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/78cea2bc-c4b3-4b21-bdb3-a521bb0d3062


after


https://github.com/user-attachments/assets/8f92c44d-9514-4001-bbdb-bc4c80468ed7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8753-fix-re-mount-DOM-widget-elements-after-leaving-Linear-mode-3026d73d3650810b8968eff13dc84e9a)
by [Unito](https://www.unito.io)
2026-02-08 22:51:57 -05:00
Comfy Org PR Bot
be515d6fcc 1.39.10 (#8752)
Patch version increment to 1.39.10

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8752-1-39-10-3026d73d3650816ebe1edb895feb37a1)
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-08 17:30:05 -08:00
Christian Byrne
b583c92c64 fix: only show Failed Tests section when there are actual failures (#8575)
## Description

The Playwright test comment was showing a " Failed Tests" section
header even when there were only flaky tests (no actual failures). This
was confusing because the red X suggested failure when tests actually
passed.

**Before:** Shows " Failed Tests" section for flaky-only runs
**After:** Only shows " Failed Tests" section when there are actual
failures; flaky tests are treated as passing

## Related Issue

Fixes the misleading comment behavior seen in PR #8573



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8575-fix-only-show-Failed-Tests-section-when-there-are-actual-failures-2fc6d73d36508167889cc252e4e06f2e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-08 13:14:38 -08:00
Christian Byrne
c0a209226d fix: handle RIFF padding for odd-sized WEBP chunks (#8527)
## Summary

Fix WEBP workflow loading failures for files with odd-sized chunks
before the EXIF chunk.

## Problem

WEBP files use the RIFF container format which [requires odd-sized
chunks to be padded with a single
byte](https://developers.google.com/speed/webp/docs/riff_container#riff_file_format):

> If Chunk Size is odd, a single padding byte -- which MUST be 0 to
conform with RIFF -- is added.

The `getWebpMetadata` function was not accounting for this padding,
causing it to miss the EXIF chunk in files with odd-sized preceding
chunks. This resulted in "Unable to find workflow in [filename].webp"
errors for valid WEBP files.

## Solution

Add the padding byte to the offset calculation:
```typescript
offset += 8 + chunk_length + (chunk_length % 2)
```

## Testing

- Tested with the sample image provided in the issue which previously
failed to load

Fixes #8076

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8527-fix-handle-RIFF-padding-for-odd-sized-WEBP-chunks-2fa6d73d3650815fb849fb6a4e767162)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-08 12:20:31 -08:00
Christian Byrne
c91d811d00 feat(cloud): add asset widget support for PrimitiveNode model selection (#8598)
Add cloud asset widget creation in `_createWidget()` using
`isAssetBrowserEligible()`
- Extract shared `createAssetWidget` factory to
`src/platform/assets/utils/`
- Refactor `useComboWidget.ts` to use the shared factory
- Add `_finalizeWidget()` helper to DRY up widget sizing/callback setup
- Pass target node's `comfyClass` and input name to Asset Browser for
correct model filtering
- Check `Comfy.Assets.UseAssetAPI` setting (matches `useComboWidget.ts`
behavior)
- Sync existing target widget value to asset widget
- Add toast notifications for asset validation errors
- Add i18n translations for invalidAsset and invalidFilename errors

Supersedes #8461 (clean rebase, no merge commits)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8598-feat-cloud-add-asset-widget-support-for-PrimitiveNode-model-selection-2fd6d73d365081a8afa7c2e91762f11c)
by [Unito](https://www.unito.io)


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

## Summary by CodeRabbit

* **New Features**
* Introduced asset widget integration for cloud-based model selection,
enabling users to browse and select assets through an improved
interface.
* Added comprehensive asset validation with enhanced error messages for
invalid assets and filenames.

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

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: guill <jacob.e.segal@gmail.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Kelly Yang <124ykl@gmail.com>
Co-authored-by: sno <snomiao@gmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-08 12:18:13 -08:00
Alexander Brown
e625b0351c feat: migrate from @iconify/tailwind to @iconify/tailwind4 (#8724)
## Summary

Migrate from `@iconify/tailwind` (Tailwind 3 JS plugin) to
`@iconify/tailwind4` (native Tailwind 4 CSS plugin), moving all config
into CSS directives.

## Changes

- **What**: Replace `addDynamicIconSelectors()` JS plugin with `@plugin
"@iconify/tailwind4"` CSS directive. Move `boxShadow` theme extension
into `@theme` block. Delete both `tailwind.config.ts` files and the
runtime `iconCollection.ts` module.
- **Dependencies**: `@iconify/tailwind` removed, `@iconify/tailwind4`
added

## Review Focus

- `from-folder` path resolution in monorepo context (paths relative to
project root)
- SVG auto-cleanup behavior of `from-folder` vs the previous manual
`iconCollection.ts` loader
- Removal of `@config` directive and both tailwind config files — all
config now in CSS

## Files

| File | Change |
|------|--------|
| `pnpm-workspace.yaml` | Swap catalog entry |
| `packages/design-system/package.json` | Swap dep, remove
`tailwind-config` export |
| `packages/design-system/src/css/style.css` | Add `@plugin`,
`--shadow-interface` theme token, remove `@config` |
| `packages/design-system/tailwind.config.ts` | Deleted |
| `packages/design-system/src/iconCollection.ts` | Deleted |
| `tailwind.config.ts` | Deleted |
| `tsconfig.json`, `components.json` | Remove stale references |
| `knip.config.ts` | Ignore `@iconify-json/lucide` (CSS-consumed, not
JS-imported) |
| Docs | Updated `CONTRIBUTING.md` and `icons/README.md` |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8724-feat-migrate-from-iconify-tailwind-to-iconify-tailwind4-3006d73d36508144a9b3e7ae73448f98)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-07 22:35:34 -08:00
Benjamin Lu
a56f2d3883 fix: stabilize flaky workflows save-as browser assertions (#8735)
## Summary

Stabilize flaky workflows sidebar browser tests by waiting for eventual
UI state after `Save As`/overwrite operations.

## Changes

- **What**: Replace immediate assertions with retrying
`expect.poll(...)` in `browser_tests/tests/sidebar/workflows.spec.ts`
for:
  - `Can save workflow as`
  - `Can overwrite other workflows with save as`

## Review Focus

Verify the polling assertions are scoped to the intended eventual UI
state and do not hide real regressions.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8735-fix-stabilize-flaky-workflows-save-as-browser-assertions-3016d73d3650814abad1d767c0910ef6)
by [Unito](https://www.unito.io)
2026-02-07 22:35:14 -08:00
305 changed files with 12195 additions and 3003 deletions

View File

@@ -96,6 +96,7 @@
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"typescript/no-explicit-any": "error",
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},

View File

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

View File

@@ -59,7 +59,7 @@
"function-no-unknown": [
true,
{
"ignoreFunctions": ["theme", "v-bind"]
"ignoreFunctions": ["theme", "v-bind", "from-folder", "from-json"]
}
]
},

View File

@@ -201,7 +201,7 @@ The project supports three types of icons, all with automatic imports (no manual
2. **Iconify Icons** - 200,000+ icons from various libraries: `<i class="icon-[lucide--settings]" />`, `<i class="icon-[mdi--folder]" />`
3. **Custom Icons** - Your own SVG icons: `<i-comfy:workflow />`
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Custom icons are stored in `packages/design-system/src/icons/` and processed by `packages/design-system/src/iconCollection.ts` with automatic validation.
Icons are powered by the unplugin-icons system, which automatically discovers and imports icons as Vue components. Tailwind CSS icon classes (`icon-[comfy--template]`) are provided by `@iconify/tailwind4`, configured in `packages/design-system/src/css/style.css`. Custom icons are stored in `packages/design-system/src/icons/` and loaded via `from-folder` at build time.
For detailed instructions and code examples, see [packages/design-system/src/icons/README.md](packages/design-system/src/icons/README.md).

View File

@@ -0,0 +1,599 @@
{
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
"revision": 0,
"last_node_id": 5,
"last_link_id": 5,
"nodes": [
{
"id": 5,
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
"pos": [788, 433.5],
"size": [210, 108],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [5]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
},
{
"id": 2,
"type": "PreviewAny",
"pos": [1135, 429],
"size": [250, 145.5],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 5
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": [null, null, false]
},
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [456, 450],
"size": [225, 121.5],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [4]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Outer\n"]
}
],
"links": [
[4, 1, 0, 5, 0, "STRING"],
[5, 5, 0, 2, 0, "STRING"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 6,
"lastLinkId": 9,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [351, 432.5, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1315, 432.5, 120, 60]
},
"inputs": [
{
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
"name": "string_a",
"type": "STRING",
"linkIds": [1],
"localized_name": "string_a",
"pos": [451, 452.5]
}
],
"outputs": [
{
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
"name": "STRING",
"type": "STRING",
"linkIds": [9],
"localized_name": "STRING",
"pos": [1335, 452.5]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "StringConcatenate",
"pos": [815, 373],
"size": [347, 231],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 1
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
"pos": [955, 775],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [9]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
},
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [313, 685],
"size": [325, 109],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [2]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 1\n"]
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "STRING"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "STRING"
},
{
"id": 6,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 9,
"lastLinkId": 12,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [680, 774, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1320, 774, 120, 60]
},
"inputs": [
{
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
"name": "string_a",
"type": "STRING",
"linkIds": [4],
"localized_name": "string_a",
"pos": [780, 794]
}
],
"outputs": [
{
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
"name": "STRING",
"type": "STRING",
"linkIds": [12],
"localized_name": "STRING",
"pos": [1340, 794]
}
],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "StringConcatenate",
"pos": [860, 719],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [11]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "PrimitiveStringMultiline",
"pos": [401, 973],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 2\n"]
},
{
"id": 9,
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"pos": [1046, 985],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 11
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [12]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 5,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 6,
"origin_slot": 0,
"target_id": 5,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": 5,
"origin_slot": 0,
"target_id": 9,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 12,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 8,
"lastLinkId": 10,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [262, 1222, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1330, 1222, 120, 60]
},
"inputs": [
{
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
"name": "string_a",
"type": "STRING",
"linkIds": [9],
"localized_name": "string_a",
"pos": [362, 1242]
}
],
"outputs": [
{
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
"name": "STRING",
"type": "STRING",
"linkIds": [10],
"localized_name": "STRING",
"pos": [1350, 1242]
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "StringConcatenate",
"pos": [870, 1038],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 9
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 8
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [10]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 8,
"type": "PrimitiveStringMultiline",
"pos": [442, 1296],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [8]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 3\n"]
}
],
"groups": [],
"links": [
{
"id": 8,
"origin_id": 8,
"origin_slot": 0,
"target_id": 7,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [-7, 144]
},
"frontendVersion": "1.38.13"
},
"version": 0.4
}

View File

@@ -23,9 +23,7 @@ export class SettingDialog extends BaseDialog {
* @param value - The value to set
*/
async setStringSetting(id: string, value: string) {
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
await settingInputDiv.locator('input').fill(value)
}
@@ -34,16 +32,31 @@ export class SettingDialog extends BaseDialog {
* @param id - The id of the setting
*/
async toggleBooleanSetting(id: string) {
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
await settingInputDiv.locator('input').click()
}
get searchBox() {
return this.root.getByPlaceholder(/Search/)
}
get categories() {
return this.root.locator('nav').getByRole('button')
}
category(name: string) {
return this.root.locator('nav').getByRole('button', { name })
}
get contentArea() {
return this.root.getByRole('main')
}
async goToAboutPanel() {
await this.page.getByTestId(TestIds.dialogs.settingsTabAbout).click()
await this.page
.getByTestId(TestIds.dialogs.about)
.waitFor({ state: 'visible' })
const aboutButton = this.root.locator('nav').getByRole('button', {
name: 'About'
})
await aboutButton.click()
await this.page.waitForSelector('.about-container')
}
}

View File

@@ -226,9 +226,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await expect(bottomPanel.shortcuts.manageButton).toBeVisible()
await bottomPanel.shortcuts.manageButton.click()
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
await expect(
comfyPage.page.getByRole('option', { name: 'Keybinding' })
).toBeVisible()
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
})
})

View File

@@ -244,9 +244,13 @@ test.describe('Missing models warning', () => {
test.describe('Settings', () => {
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,')
const settingsContent = comfyPage.page.locator('.settings-content')
await expect(settingsContent).toBeVisible()
const isUsableHeight = await settingsContent.evaluate(
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await expect(settingsDialog).toBeVisible()
const contentArea = settingsDialog.locator('main')
await expect(contentArea).toBeVisible()
const isUsableHeight = await contentArea.evaluate(
(el) => el.clientHeight > 30
)
expect(isUsableHeight).toBeTruthy()
@@ -256,7 +260,9 @@ test.describe('Settings', () => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
const settingsLocator = comfyPage.page.locator('.settings-container')
const settingsLocator = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await expect(settingsLocator).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(settingsLocator).not.toBeVisible()
@@ -275,10 +281,15 @@ test.describe('Settings', () => {
test('Should persist keybinding setting', async ({ comfyPage }) => {
// Open the settings dialog
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('.settings-container')
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]')
// Open the keybinding tab
await comfyPage.page.getByLabel('Keybinding').click()
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await settingsDialog
.locator('nav [role="button"]', { hasText: 'Keybinding' })
.click()
await comfyPage.page.waitForSelector(
'[placeholder="Search Keybindings..."]'
)
@@ -298,7 +309,10 @@ test.describe('Settings', () => {
await input.press('Alt+n')
const requestPromise = comfyPage.page.waitForRequest(
'**/api/settings/Comfy.Keybinding.NewBindings'
(req) =>
req.url().includes('/api/settings') &&
!req.url().includes('/api/settings/') &&
req.method() === 'POST'
)
// Save keybinding

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -123,17 +123,14 @@ test.describe('Workflows sidebar', () => {
test('Can save workflow as', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'workflow3.json'
])
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', 'workflow3.json'])
await comfyPage.menu.topbar.saveWorkflowAs('workflow4.json')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'workflow3.json',
'workflow4.json'
])
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', 'workflow3.json', 'workflow4.json'])
})
test('Exported workflow does not contain localized slot names', async ({
@@ -220,24 +217,22 @@ test.describe('Workflows sidebar', () => {
await topbar.saveWorkflow('workflow1.json')
await topbar.saveWorkflowAs('workflow2.json')
await comfyPage.nextFrame()
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow1.json',
'workflow2.json'
])
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
'workflow2.json'
)
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow1.json', 'workflow2.json'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow2.json')
await topbar.saveWorkflowAs('workflow1.json')
await comfyPage.confirmDialog.click('overwrite')
// The old workflow1.json should be deleted and the new one should be saved.
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow2.json',
'workflow1.json'
])
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
'workflow1.json'
)
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow2.json', 'workflow1.json'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow1.json')
})
test('Does not report warning when switching between opened workflows', async ({

View File

@@ -0,0 +1,100 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test('All node IDs are globally unique after loading', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
// TODO: Extract allGraphs accessor (root + subgraphs) into LGraph
// TODO: Extract allNodeIds accessor into LGraph
const allGraphs = [graph, ...graph.subgraphs.values()]
const allIds = allGraphs
.flatMap((g) => g._nodes)
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
return { allIds, uniqueCount: new Set(allIds).size }
})
expect(result.uniqueCount).toBe(result.allIds.length)
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
})
test('Root graph node IDs are preserved as canonical', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const rootIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
.sort((a, b) => a - b)
})
expect(rootIds).toEqual([1, 2, 5])
})
test('All links reference valid nodes in their graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const invalidLinks = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const labeledGraphs: [string, typeof graph][] = [
['root', graph],
...[...graph.subgraphs.entries()].map(
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
)
]
const isNonNegative = (id: number | string) =>
typeof id === 'number' && id >= 0
return labeledGraphs.flatMap(([label, g]) =>
[...g._links.values()].flatMap((link) =>
[
isNonNegative(link.origin_id) &&
!g._nodes_by_id[link.origin_id] &&
`${label}: origin_id ${link.origin_id} not found`,
isNonNegative(link.target_id) &&
!g._nodes_by_id[link.target_id] &&
`${label}: target_id ${link.target_id} not found`
].filter(Boolean)
)
)
})
expect(invalidLinks).toEqual([])
})
test('Subgraph navigation works after ID remapping', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
const isInSubgraph = () =>
comfyPage.page.evaluate(
() => window.app!.canvas.graph?.isRootGraph === false
)
expect(await isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await isInSubgraph()).toBe(false)
})
})

View File

@@ -61,7 +61,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
@@ -77,7 +80,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
@@ -820,7 +826,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('.settings-container', {
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
state: 'visible'
})
@@ -830,7 +836,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
// Dialog should be closed
await expect(
comfyPage.page.locator('.settings-container')
comfyPage.page.locator('[data-testid="settings-dialog"]')
).not.toBeVisible()
// Should still be in subgraph

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -22,7 +22,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
name: 'TestSettingsExtension',
settings: [
{
// Extensions can register arbitrary setting IDs
id: 'TestHiddenSetting' as TestSettingId,
name: 'Test Hidden Setting',
type: 'hidden',
@@ -30,7 +29,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
category: ['Test', 'Hidden']
},
{
// Extensions can register arbitrary setting IDs
id: 'TestDeprecatedSetting' as TestSettingId,
name: 'Test Deprecated Setting',
type: 'text',
@@ -39,7 +37,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
category: ['Test', 'Deprecated']
},
{
// Extensions can register arbitrary setting IDs
id: 'TestVisibleSetting' as TestSettingId,
name: 'Test Visible Setting',
type: 'text',
@@ -52,238 +49,143 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
})
test('can open settings dialog and use search box', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Find the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await expect(searchBox).toBeVisible()
// Verify search box has the correct placeholder
await expect(searchBox).toHaveAttribute(
await expect(dialog.searchBox).toHaveAttribute(
'placeholder',
expect.stringContaining('Search')
)
})
test('search box is functional and accepts input', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Find and interact with the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Comfy')
// Verify the input was accepted
await expect(searchBox).toHaveValue('Comfy')
await dialog.searchBox.fill('Comfy')
await expect(dialog.searchBox).toHaveValue('Comfy')
})
test('search box clears properly', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Find and interact with the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('test')
await expect(searchBox).toHaveValue('test')
await dialog.searchBox.fill('test')
await expect(dialog.searchBox).toHaveValue('test')
// Clear the search box
await searchBox.clear()
await expect(searchBox).toHaveValue('')
await dialog.searchBox.clear()
await expect(dialog.searchBox).toHaveValue('')
})
test('settings categories are visible in sidebar', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Check that the sidebar has categories
const categories = comfyPage.page.locator(
'.settings-sidebar .p-listbox-option'
)
expect(await categories.count()).toBeGreaterThan(0)
// Check that at least one category is visible
await expect(categories.first()).toBeVisible()
expect(await dialog.categories.count()).toBeGreaterThan(0)
})
test('can select different categories in sidebar', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Click on a specific category (Appearance) to verify category switching
const appearanceCategory = comfyPage.page.getByRole('option', {
name: 'Appearance'
})
await appearanceCategory.click()
const categoryCount = await dialog.categories.count()
// Verify the category is selected
await expect(appearanceCategory).toHaveClass(/p-listbox-option-selected/)
})
if (categoryCount > 1) {
await dialog.categories.nth(1).click()
test('settings content area is visible', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Check that the content area is visible
const contentArea = comfyPage.page.locator('.settings-content')
await expect(contentArea).toBeVisible()
// Check that tab panels are visible
const tabPanels = comfyPage.page.locator('.settings-tab-panels')
await expect(tabPanels).toBeVisible()
await expect(dialog.categories.nth(1)).toHaveClass(
/bg-interface-menu-component-surface-selected/
)
}
})
test('search functionality affects UI state', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Find the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
// Type in search box
await searchBox.fill('graph')
// Verify that the search input is handled
await expect(searchBox).toHaveValue('graph')
await dialog.searchBox.fill('graph')
await expect(dialog.searchBox).toHaveValue('graph')
})
test('settings dialog can be closed', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Close with escape key
await comfyPage.page.keyboard.press('Escape')
// Verify dialog is closed
await expect(settingsDialog).not.toBeVisible()
await expect(dialog.root).not.toBeVisible()
})
test('search box has proper debouncing behavior', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Type rapidly in search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('a')
await searchBox.fill('ab')
await searchBox.fill('abc')
await searchBox.fill('abcd')
await dialog.searchBox.fill('a')
await dialog.searchBox.fill('ab')
await dialog.searchBox.fill('abc')
await dialog.searchBox.fill('abcd')
// Verify final value
await expect(searchBox).toHaveValue('abcd')
await expect(dialog.searchBox).toHaveValue('abcd')
})
test('search excludes hidden settings from results', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await dialog.searchBox.fill('Test')
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should show visible setting but not hidden setting
await expect(settingsContent).toContainText('Test Visible Setting')
await expect(settingsContent).not.toContainText('Test Hidden Setting')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
})
test('search excludes deprecated settings from results', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await dialog.searchBox.fill('Test')
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should show visible setting but not deprecated setting
await expect(settingsContent).toContainText('Test Visible Setting')
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
await expect(dialog.contentArea).not.toContainText(
'Test Deprecated Setting'
)
})
test('search shows visible settings but excludes hidden and deprecated', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await dialog.searchBox.fill('Test')
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should only show the visible setting
await expect(settingsContent).toContainText('Test Visible Setting')
// Should not show hidden or deprecated settings
await expect(settingsContent).not.toContainText('Test Hidden Setting')
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
await expect(dialog.contentArea).not.toContainText(
'Test Deprecated Setting'
)
})
test('search by setting name excludes hidden and deprecated', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const dialog = comfyPage.settingDialog
await dialog.open()
const searchBox = comfyPage.page.locator('.settings-search-box input')
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
await dialog.searchBox.clear()
await dialog.searchBox.fill('Hidden')
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
// Search specifically for hidden setting by name
await searchBox.clear()
await searchBox.fill('Hidden')
await dialog.searchBox.clear()
await dialog.searchBox.fill('Deprecated')
await expect(dialog.contentArea).not.toContainText(
'Test Deprecated Setting'
)
// Should not show the hidden setting even when searching by name
await expect(settingsContent).not.toContainText('Test Hidden Setting')
// Search specifically for deprecated setting by name
await searchBox.clear()
await searchBox.fill('Deprecated')
// Should not show the deprecated setting even when searching by name
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
// Search for visible setting by name - should work
await searchBox.clear()
await searchBox.fill('Visible')
// Should show the visible setting
await expect(settingsContent).toContainText('Test Visible Setting')
await dialog.searchBox.clear()
await dialog.searchBox.fill('Visible')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

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

After

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

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

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -3,7 +3,6 @@
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/assets/css/style.css",
"baseColor": "stone",
"cssVariables": true,

7
global.d.ts vendored
View File

@@ -5,6 +5,11 @@ declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean
interface ImpactQueueFunction {
(...args: unknown[]): void
a?: unknown[][]
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
@@ -37,6 +42,8 @@ interface Window {
session_number?: string
}
dataLayer?: Array<Record<string, unknown>>
ire_o?: string
ire?: ImpactQueueFunction
}
interface Navigator {

View File

@@ -35,18 +35,6 @@
background-size: cover;
background-repeat: no-repeat;
}
#vue-app:has(#loading-logo) {
display: contents;
color: var(--fg-color);
& #loading-logo {
place-self: center;
font-size: clamp(2px, 1vw, 6px);
line-height: 1;
overflow: hidden;
max-width: 100vw;
border-radius: 20ch;
}
}
.visually-hidden {
position: absolute;
width: 1px;
@@ -65,36 +53,6 @@
<body class="litegraph grid">
<div id="vue-app">
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
<svg
width="520"
height="520"
viewBox="0 0 520 520"
fill="none"
xmlns="http://www.w3.org/2000/svg"
id="loading-logo"
>
<mask
id="mask0_227_285"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="520"
height="520"
>
<path
d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z"
fill="#EEFF30"
/>
</mask>
<g mask="url(#mask0_227_285)">
<rect y="0.751831" width="520" height="520" fill="#172DD7" />
<path
d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z"
fill="#F0FF41"
/>
</g>
</svg>
</div>
<script type="module" src="src/main.ts"></script>
</body>

View File

@@ -20,10 +20,6 @@ const config: KnipConfig = {
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
'packages/design-system': {
entry: ['src/**/*.ts'],
project: ['src/**/*.{js,ts}', '*.{js,ts,mts}']
},
'packages/registry-types': {
project: ['src/**/*.{js,ts}']
}
@@ -31,6 +27,7 @@ const config: KnipConfig = {
ignoreBinaries: ['python3', 'gh'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.39.9",
"version": "1.40.2",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -193,7 +193,7 @@
},
"pnpm": {
"overrides": {
"vite": "^8.0.0-beta.8"
"vite": "catalog:"
}
}
}

View File

@@ -4,7 +4,6 @@
"description": "Shared design system for ComfyUI Frontend",
"type": "module",
"exports": {
"./tailwind-config": "./tailwind.config.ts",
"./css/*": "./src/css/*"
},
"scripts": {
@@ -12,7 +11,7 @@
},
"dependencies": {
"@iconify-json/lucide": "catalog:",
"@iconify/tailwind": "catalog:"
"@iconify/tailwind4": "catalog:"
},
"devDependencies": {
"tailwindcss": "catalog:",

View File

@@ -7,11 +7,16 @@
@plugin 'tailwindcss-primeui';
@config '../../tailwind.config.ts';
@plugin "@iconify/tailwind4" {
scale: 1.2;
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
}
@custom-variant touch (@media (hover: none));
@theme {
--shadow-interface: var(--interface-panel-box-shadow);
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);

View File

@@ -1,100 +0,0 @@
import { existsSync, readFileSync, readdirSync } from 'fs'
import { join } from 'path'
import { dirname } from 'path'
import { fileURLToPath } from 'url'
const fileName = fileURLToPath(import.meta.url)
const dirName = dirname(fileName)
const customIconsPath = join(dirName, 'icons')
// Iconify collection structure
interface IconifyIcon {
body: string
width?: number
height?: number
}
interface IconifyCollection {
prefix: string
icons: Record<string, IconifyIcon>
width?: number
height?: number
}
// Create an Iconify collection for custom icons
export const iconCollection: IconifyCollection = {
prefix: 'comfy',
icons: {},
width: 16,
height: 16
}
/**
* Validates that an SVG file contains valid SVG content
*/
function validateSvgContent(content: string, filename: string): void {
if (!content.trim()) {
throw new Error(`Empty SVG file: ${filename}`)
}
if (!content.includes('<svg')) {
throw new Error(`Invalid SVG file (missing <svg> tag): ${filename}`)
}
// Basic XML structure validation
const openTags = (content.match(/<svg[^>]*>/g) || []).length
const closeTags = (content.match(/<\/svg>/g) || []).length
if (openTags !== closeTags) {
throw new Error(`Malformed SVG file (mismatched svg tags): ${filename}`)
}
}
/**
* Loads custom SVG icons from the icons directory
*/
function loadCustomIcons(): void {
if (!existsSync(customIconsPath)) {
console.warn(`Custom icons directory not found: ${customIconsPath}`)
return
}
try {
const files = readdirSync(customIconsPath)
const svgFiles = files.filter((file) => file.endsWith('.svg'))
if (svgFiles.length === 0) {
console.warn('No SVG files found in custom icons directory')
return
}
svgFiles.forEach((file) => {
const name = file.replace('.svg', '')
const filePath = join(customIconsPath, file)
try {
const content = readFileSync(filePath, 'utf-8')
validateSvgContent(content, file)
iconCollection.icons[name] = {
body: content
}
} catch (error) {
console.error(
`Failed to load custom icon ${file}:`,
error instanceof Error ? error.message : error
)
// Continue loading other icons instead of failing the entire build
}
})
} catch (error) {
console.error(
'Failed to read custom icons directory:',
error instanceof Error ? error.message : error
)
// Don't throw here - allow build to continue without custom icons
}
}
// Load icons when this module is imported
loadCustomIcons()

View File

@@ -251,26 +251,25 @@ Icons are automatically imported using `unplugin-icons` - no manual imports need
The icon system has two layers:
1. **Build-time Processing** (`packages/design-system/src/iconCollection.ts`):
- Scans `packages/design-system/src/icons/` for SVG files
- Validates SVG content and structure
- Creates Iconify collection for Tailwind CSS
- Provides error handling for malformed files
1. **Tailwind CSS Plugin** (`@iconify/tailwind4`):
- Configured via `@plugin` directive in `packages/design-system/src/css/style.css`
- Uses `from-folder(comfy, ...)` to load SVGs from `packages/design-system/src/icons/`
- Auto-cleans and optimizes SVGs at build time
2. **Vite Runtime** (`vite.config.mts`):
- Enables direct SVG import as Vue components
- Supports dynamic icon loading
```typescript
// Build script creates Iconify collection
export const iconCollection: IconifyCollection = {
prefix: 'comfy',
icons: {
workflow: { body: '<svg>...</svg>' },
node: { body: '<svg>...</svg>' }
}
```css
/* CSS configuration for Tailwind icon classes */
@plugin "@iconify/tailwind4" {
prefix: 'icon';
scale: 1.2;
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
}
```
```typescript
// Vite configuration for component-based usage
Icons({
compiler: 'vue3',

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9">
<path d="M1.82148 8.68376C1.61587 8.68376 1.44996 8.60733 1.34177 8.46284C1.23057 8.31438 1.20157 8.10711 1.26219 7.89434L1.50561 7.03961C1.52502 6.97155 1.51151 6.89831 1.46918 6.8417C1.42684 6.7852 1.3606 6.75194 1.29025 6.75194H0.590376C0.384656 6.75194 0.21875 6.67562 0.110614 6.53113C-0.000591531 6.38256 -0.0295831 6.17529 0.0310774 5.96252L0.867308 3.03952L0.959638 2.71838C1.08375 2.28258 1.53638 1.9284 1.96878 1.9284H2.80622C2.90615 1.9284 2.99406 1.86177 3.02157 1.76508L3.29852 0.79284C3.4225 0.357484 3.87514 0.0033043 4.30753 0.0033043L6.09854 0.000112775L7.40967 0C7.61533 0 7.78124 0.0763259 7.88937 0.220813C8.00058 0.369269 8.02957 0.576538 7.96895 0.78931L7.59405 2.10572C7.4701 2.54096 7.01746 2.89503 6.58507 2.89503L4.79008 2.89844H3.95292C3.8531 2.89844 3.7653 2.96496 3.73762 3.06155L3.03961 5.49964C3.02008 5.56781 3.03359 5.64127 3.07604 5.69787C3.11837 5.75437 3.18461 5.78763 3.2549 5.78763C3.25507 5.78763 4.44105 5.78532 4.44105 5.78532H5.7483C5.95396 5.78532 6.11986 5.86164 6.228 6.00613C6.33921 6.1547 6.3682 6.36197 6.30754 6.57474L5.93263 7.89092C5.80869 8.32628 5.35605 8.68034 4.92366 8.68034L3.12872 8.68376H1.82148Z" fill="#8A8A8A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,24 +0,0 @@
import lucide from '@iconify-json/lucide/icons.json' with { type: 'json' }
import { addDynamicIconSelectors } from '@iconify/tailwind'
import { iconCollection } from './src/iconCollection'
export default {
theme: {
extend: {
boxShadow: {
interface: 'var(--interface-panel-box-shadow)'
}
}
},
plugins: [
addDynamicIconSelectors({
iconSets: {
comfy: iconCollection,
lucide
},
scale: 1.2,
prefix: 'icon'
})
]
}

663
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ catalog:
'@eslint/js': ^9.39.1
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind': ^1.1.3
'@iconify/tailwind4': ^1.2.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
'@lobehub/i18n-cli': ^1.25.1
'@nx/eslint': 22.2.6
@@ -92,7 +92,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vite: ^8.0.0-beta.8
vite: 8.0.0-beta.13
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0

View File

@@ -283,34 +283,27 @@ else
done
unset IFS
# Determine overall status
# Determine overall status (flaky tests are treated as passing)
if [ $total_failed -gt 0 ]; then
status_icon="❌"
status_text="Failed"
elif [ $total_flaky -gt 0 ]; then
status_icon="⚠️"
status_text="Passed with flaky tests"
elif [ $total_tests -gt 0 ]; then
status_icon="✅"
status_text="Passed"
else
status_icon="🕵🏻"
status_text="No test results"
fi
# Generate concise completion comment
comment="$COMMENT_MARKER
## 🎭 Playwright Tests: $status_icon **$status_text**"
# Add summary counts if we have test data
if [ $total_tests -gt 0 ]; then
comment="$comment
**Results:** $total_passed passed, $total_failed failed, $total_flaky flaky, $total_skipped skipped (Total: $total_tests)"
# Build flaky indicator if any (small subtext, no warning icon)
flaky_note=""
if [ $total_flaky -gt 0 ]; then
flaky_note=" · $total_flaky flaky"
fi
# Generate compact single-line comment
comment="$COMMENT_MARKER
**Playwright:** $status_icon $total_passed passed, $total_failed failed$flaky_note"
# Extract and display failed tests from all browsers
if [ $total_failed -gt 0 ] || [ $total_flaky -gt 0 ]; then
# Extract and display failed tests from all browsers (flaky tests are treated as passing)
if [ $total_failed -gt 0 ]; then
comment="$comment
### ❌ Failed Tests"

View File

@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { downloadFile } from '@/base/common/downloadUtil'
import {
downloadFile,
extractFilenameFromContentDisposition
} from '@/base/common/downloadUtil'
let mockIsCloud = false
@@ -155,10 +158,14 @@ describe('downloadUtil', () => {
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
@@ -195,5 +202,147 @@ describe('downloadUtil', () => {
expect(createObjectURLSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('uses filename from Content-Disposition header in cloud mode', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(headersMock.get).toHaveBeenCalledWith('Content-Disposition')
expect(mockLink.download).toBe('user-friendly.png')
})
it('uses RFC 5987 filename from Content-Disposition header', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi
.fn()
.mockReturnValue(
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(mockLink.download).toBe('中文.png')
})
it('falls back to provided filename when Content-Disposition is missing', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl, 'my-fallback.png')
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(mockLink.download).toBe('my-fallback.png')
})
})
describe('extractFilenameFromContentDisposition', () => {
it('returns null for null header', () => {
expect(extractFilenameFromContentDisposition(null)).toBeNull()
})
it('returns null for empty header', () => {
expect(extractFilenameFromContentDisposition('')).toBeNull()
})
it('extracts filename from simple quoted format', () => {
const header = 'attachment; filename="test-file.png"'
expect(extractFilenameFromContentDisposition(header)).toBe(
'test-file.png'
)
})
it('extracts filename from unquoted format', () => {
const header = 'attachment; filename=test-file.png'
expect(extractFilenameFromContentDisposition(header)).toBe(
'test-file.png'
)
})
it('extracts filename from RFC 5987 format', () => {
const header = "attachment; filename*=UTF-8''test%20file.png"
expect(extractFilenameFromContentDisposition(header)).toBe(
'test file.png'
)
})
it('prefers RFC 5987 format over simple format', () => {
const header =
'attachment; filename="fallback.png"; filename*=UTF-8\'\'preferred.png'
expect(extractFilenameFromContentDisposition(header)).toBe(
'preferred.png'
)
})
it('handles unicode characters in RFC 5987 format', () => {
const header =
"attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.png"
expect(extractFilenameFromContentDisposition(header)).toBe('中文文件.png')
})
it('falls back to simple format when RFC 5987 decoding fails', () => {
const header =
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%invalid'
expect(extractFilenameFromContentDisposition(header)).toBe('fallback.png')
})
it('handles header with only attachment disposition', () => {
const header = 'attachment'
expect(extractFilenameFromContentDisposition(header)).toBeNull()
})
it('handles case-insensitive filename parameter', () => {
const header = 'attachment; FILENAME="test.png"'
expect(extractFilenameFromContentDisposition(header)).toBe('test.png')
})
})
})

View File

@@ -75,14 +75,57 @@ const extractFilenameFromUrl = (url: string): string | null => {
}
}
/**
* Extract filename from Content-Disposition header
* Handles both simple format: attachment; filename="name.png"
* And RFC 5987 format: attachment; filename="fallback.png"; filename*=UTF-8''encoded%20name.png
* @param header - The Content-Disposition header value
* @returns The extracted filename or null if not found
*/
export function extractFilenameFromContentDisposition(
header: string | null
): string | null {
if (!header) return null
// Try RFC 5987 extended format first (filename*=UTF-8''...)
const extendedMatch = header.match(/filename\*=UTF-8''([^;]+)/i)
if (extendedMatch?.[1]) {
try {
return decodeURIComponent(extendedMatch[1])
} catch {
// Fall through to simple format
}
}
// Try simple quoted format: filename="..."
const quotedMatch = header.match(/filename="([^"]+)"/i)
if (quotedMatch?.[1]) {
return quotedMatch[1]
}
// Try unquoted format: filename=...
const unquotedMatch = header.match(/filename=([^;\s]+)/i)
if (unquotedMatch?.[1]) {
return unquotedMatch[1]
}
return null
}
const downloadViaBlobFetch = async (
href: string,
filename: string
fallbackFilename: string
): Promise<void> => {
const response = await fetch(href)
if (!response.ok) {
throw new Error(`Failed to fetch ${href}: ${response.status}`)
}
// Try to get filename from Content-Disposition header (set by backend)
const contentDisposition = response.headers.get('Content-Disposition')
const headerFilename =
extractFilenameFromContentDisposition(contentDisposition)
const blob = await response.blob()
downloadBlob(filename, blob)
downloadBlob(headerFilename ?? fallbackFilename, blob)
}

View File

@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
import type { Component } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -19,7 +19,11 @@ import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const mockData = vi.hoisted(() => ({ isLoggedIn: false, isDesktop: false }))
const mockData = vi.hoisted(() => ({
isLoggedIn: false,
isDesktop: false,
setShowConflictRedDot: (_value: boolean) => {}
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => {
@@ -36,6 +40,36 @@ vi.mock('@/platform/distribution/types', () => ({
return mockData.isDesktop
}
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: () => ({
shouldShowRedDot: computed(() => true)
})
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => {
const shouldShowConflictRedDot = ref(false)
mockData.setShowConflictRedDot = (value: boolean) => {
shouldShowConflictRedDot.value = value
}
return {
useConflictAcknowledgment: () => ({
shouldShowRedDot: shouldShowConflictRedDot
})
}
}
)
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: computed(() => true),
openManager: vi.fn()
})
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
currentUser: null,
@@ -114,6 +148,7 @@ describe('TopMenuSection', () => {
localStorage.clear()
mockData.isDesktop = false
mockData.isLoggedIn = false
mockData.setShowConflictRedDot(false)
})
describe('authentication state', () => {
@@ -330,4 +365,16 @@ describe('TopMenuSection', () => {
const model = menu.props('model') as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
it('shows manager red dot only for manager conflicts', async () => {
const wrapper = createWrapper()
// Release red dot is mocked as true globally for this test file.
expect(wrapper.find('span.bg-red-500').exists()).toBe(false)
mockData.setShowConflictRedDot(true)
await nextTick()
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
})
})

View File

@@ -145,7 +145,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -173,8 +172,6 @@ const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
@@ -236,10 +233,8 @@ const queueContextMenuItems = computed<MenuItem[]>(() => [
}
])
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
return shouldShowConflictRedDot.value
})
// Right side panel toggle

View File

@@ -89,12 +89,12 @@ import { useI18n } from 'vue-i18n'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import Button from '@/components/ui/button/Button.vue'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { BottomPanelExtension } from '@/types/extensionTypes'
const bottomPanelStore = useBottomPanelStore()
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
const { t } = useI18n()
const isShortcutsTabActive = computed(() => {
@@ -115,7 +115,7 @@ const getTabDisplayTitle = (tab: BottomPanelExtension): string => {
}
const openKeybindingSettings = async () => {
dialogService.showSettingsDialog('keybinding')
settingsDialog.show('keybinding')
}
const closeBottomPanel = () => {

View File

@@ -12,9 +12,9 @@
nodeContent: ({ context }) => ({
class: 'group/tree-node',
onClick: (e: MouseEvent) =>
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
onNodeContentClick(e, context.node as RenderedTreeExplorerNode<T>),
onContextmenu: (e: MouseEvent) =>
handleContextMenu(e, context.node as RenderedTreeExplorerNode)
handleContextMenu(e, context.node as RenderedTreeExplorerNode<T>)
}),
nodeToggleButton: () => ({
onClick: (e: MouseEvent) => {
@@ -36,15 +36,11 @@
</Tree>
<ContextMenu ref="menu" :model="menuItems" />
</template>
<script setup lang="ts">
defineOptions({
inheritAttrs: false
})
<script setup lang="ts" generic="T">
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import Tree from 'primevue/tree'
import { computed, provide, ref } from 'vue'
import { computed, provide, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
@@ -60,6 +56,10 @@ import type {
} from '@/types/treeExplorerTypes'
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
defineOptions({
inheritAttrs: false
})
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
required: true
})
@@ -69,13 +69,13 @@ const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
const storeSelectionKeys = selectionKeys.value !== undefined
const props = defineProps<{
root: TreeExplorerNode
root: TreeExplorerNode<T>
class?: string
}>()
const emit = defineEmits<{
(e: 'nodeClick', node: RenderedTreeExplorerNode, event: MouseEvent): void
(e: 'nodeDelete', node: RenderedTreeExplorerNode): void
(e: 'contextMenu', node: RenderedTreeExplorerNode, event: MouseEvent): void
(e: 'nodeClick', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
(e: 'nodeDelete', node: RenderedTreeExplorerNode<T>): void
(e: 'contextMenu', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
}>()
const {
@@ -83,19 +83,19 @@ const {
getAddFolderMenuItem,
handleFolderCreation,
addFolderCommand
} = useTreeFolderOperations(
/* expandNode */ (node: TreeExplorerNode) => {
} = useTreeFolderOperations<T>(
/* expandNode */ (node: TreeExplorerNode<T>) => {
expandedKeys.value[node.key] = true
}
)
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
const renderedRoot = computed<RenderedTreeExplorerNode<T>>(() => {
const renderedRoot = fillNodeInfo(props.root)
return newFolderNode.value
? combineTrees(renderedRoot, newFolderNode.value)
: renderedRoot
})
const getTreeNodeIcon = (node: TreeExplorerNode) => {
const getTreeNodeIcon = (node: TreeExplorerNode<T>) => {
if (node.getIcon) {
const icon = node.getIcon()
if (icon) {
@@ -111,7 +111,9 @@ const getTreeNodeIcon = (node: TreeExplorerNode) => {
const isExpanded = expandedKeys.value?.[node.key] ?? false
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
}
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
const fillNodeInfo = (
node: TreeExplorerNode<T>
): RenderedTreeExplorerNode<T> => {
const children = node.children?.map(fillNodeInfo) ?? []
const totalLeaves = node.leaf
? 1
@@ -128,7 +130,7 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
}
const onNodeContentClick = async (
e: MouseEvent,
node: RenderedTreeExplorerNode
node: RenderedTreeExplorerNode<T>
) => {
if (!storeSelectionKeys) {
selectionKeys.value = {}
@@ -139,20 +141,22 @@ const onNodeContentClick = async (
emit('nodeClick', node, e)
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
const menuTargetNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
const extraMenuItems = computed(() => {
return menuTargetNode.value?.contextMenuItems
? typeof menuTargetNode.value.contextMenuItems === 'function'
? menuTargetNode.value.contextMenuItems(menuTargetNode.value)
: menuTargetNode.value.contextMenuItems
const node = menuTargetNode.value
return node?.contextMenuItems
? typeof node.contextMenuItems === 'function'
? node.contextMenuItems(node)
: node.contextMenuItems
: []
})
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
const renameEditingNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
const errorHandling = useErrorHandling()
const handleNodeLabelEdit = async (
node: RenderedTreeExplorerNode,
n: RenderedTreeExplorerNode,
newName: string
) => {
const node = n as RenderedTreeExplorerNode<T>
await errorHandling.wrapWithErrorHandlingAsync(
async () => {
if (node.key === newFolderNode.value?.key) {
@@ -170,35 +174,36 @@ const handleNodeLabelEdit = async (
provide(InjectKeyHandleEditLabelFunction, handleNodeLabelEdit)
const { t } = useI18n()
const renameCommand = (node: RenderedTreeExplorerNode) => {
const renameCommand = (node: RenderedTreeExplorerNode<T>) => {
renameEditingNode.value = node
}
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
const deleteCommand = async (node: RenderedTreeExplorerNode<T>) => {
await node.handleDelete?.()
emit('nodeDelete', node)
}
const menuItems = computed<MenuItem[]>(() =>
[
getAddFolderMenuItem(menuTargetNode.value),
const menuItems = computed<MenuItem[]>(() => {
const node = menuTargetNode.value
return [
getAddFolderMenuItem(node),
{
label: t('g.rename'),
icon: 'pi pi-file-edit',
command: () => {
if (menuTargetNode.value) {
renameCommand(menuTargetNode.value)
if (node) {
renameCommand(node)
}
},
visible: menuTargetNode.value?.handleRename !== undefined
visible: node?.handleRename !== undefined
},
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: async () => {
if (menuTargetNode.value) {
await deleteCommand(menuTargetNode.value)
if (node) {
await deleteCommand(node)
}
},
visible: menuTargetNode.value?.handleDelete !== undefined,
visible: node?.handleDelete !== undefined,
isAsync: true // The delete command can be async
},
...extraMenuItems.value
@@ -210,9 +215,12 @@ const menuItems = computed<MenuItem[]>(() =>
})
: undefined
}))
)
})
const handleContextMenu = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
const handleContextMenu = (
e: MouseEvent,
node: RenderedTreeExplorerNode<T>
) => {
menuTargetNode.value = node
emit('contextMenu', node, e)
if (menuItems.value.filter((item) => item.visible).length > 0) {
@@ -224,15 +232,13 @@ const wrapCommandWithErrorHandler = (
command: (event: MenuItemCommandEvent) => void,
{ isAsync = false }: { isAsync: boolean }
) => {
const node = menuTargetNode.value
return isAsync
? errorHandling.wrapWithErrorHandlingAsync(
command as (event: MenuItemCommandEvent) => Promise<void>,
menuTargetNode.value?.handleError
)
: errorHandling.wrapWithErrorHandling(
command,
menuTargetNode.value?.handleError
node?.handleError
)
: errorHandling.wrapWithErrorHandling(command, node?.handleError)
}
defineExpose({

View File

@@ -36,7 +36,7 @@
</div>
</template>
<script setup lang="ts">
<script setup lang="ts" generic="T">
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import Badge from 'primevue/badge'
import { computed, inject, ref } from 'vue'
@@ -53,17 +53,17 @@ import type {
} from '@/types/treeExplorerTypes'
const props = defineProps<{
node: RenderedTreeExplorerNode
node: RenderedTreeExplorerNode<T>
}>()
const emit = defineEmits<{
(
e: 'itemDropped',
node: RenderedTreeExplorerNode,
data: RenderedTreeExplorerNode
node: RenderedTreeExplorerNode<T>,
data: RenderedTreeExplorerNode<T>
): void
(e: 'dragStart', node: RenderedTreeExplorerNode): void
(e: 'dragEnd', node: RenderedTreeExplorerNode): void
(e: 'dragStart', node: RenderedTreeExplorerNode<T>): void
(e: 'dragEnd', node: RenderedTreeExplorerNode<T>): void
}>()
const nodeBadgeText = computed<string>(() => {
@@ -80,7 +80,7 @@ const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
const isEditing = computed<boolean>(() => props.node.isEditingLabel ?? false)
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
const handleRename = (newName: string) => {
handleEditLabel?.(props.node, newName)
handleEditLabel?.(props.node as RenderedTreeExplorerNode, newName)
}
const container = ref<HTMLElement | null>(null)
@@ -117,9 +117,13 @@ if (props.node.droppable) {
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
await props.node.handleDrop?.(dndData)
await props.node.handleDrop?.(dndData as TreeExplorerDragAndDropData<T>)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
emit(
'itemDropped',
props.node,
dndData.data as RenderedTreeExplorerNode<T>
)
}
},
onDragEnter: (event) => {

View File

@@ -1,7 +1,7 @@
<template>
<BaseModalLayout
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
class="workflow-template-selector-dialog"
size="md"
>
<template #leftPanelHeaderTitle>
<i class="icon-[comfy--template]" />
@@ -854,19 +854,3 @@ onBeforeUnmount(() => {
cardRefs.value = [] // Release DOM refs
})
</script>
<style>
/* Ensure the workflow template selector dialog fits within provided dialog */
.workflow-template-selector-dialog.base-widget-layout {
width: 100% !important;
max-width: 1400px;
height: 100% !important;
aspect-ratio: auto !important;
}
@media (min-width: 1600px) {
.workflow-template-selector-dialog.base-widget-layout {
max-width: 1600px;
}
}
</style>

View File

@@ -4,12 +4,7 @@
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
:class="[
'global-dialog',
item.key === 'global-settings' && teamWorkspacesEnabled
? 'settings-dialog-workspace'
: ''
]"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"

View File

@@ -18,17 +18,35 @@
<div class="flex justify-end gap-4">
<div
v-if="type === 'overwriteBlueprint'"
class="flex justify-start gap-4"
class="flex flex-col justify-start gap-1"
>
<Checkbox
v-model="doNotAskAgain"
class="flex justify-start gap-4"
input-id="doNotAskAgain"
binary
/>
<label for="doNotAskAgain" severity="secondary">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
<div class="flex gap-4">
<input
id="doNotAskAgain"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="text-sm text-muted-foreground ml-8"
>
<template #link>
<Button
variant="textonly"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openBlueprintOverwriteSetting"
>
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<Button
@@ -92,13 +110,13 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import type { ConfirmationDialogType } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -114,6 +132,11 @@ const { t } = useI18n()
const onCancel = () => useDialogStore().closeDialog()
function openBlueprintOverwriteSetting() {
useDialogStore().closeDialog()
useSettingsDialog().show(undefined, 'Comfy.Workflow.WarnBlueprintOverwrite')
}
const doNotAskAgain = ref(false)
const onDeny = () => {

View File

@@ -5,11 +5,34 @@
:title="t('missingModelsDialog.missingModels')"
:message="t('missingModelsDialog.missingModelsMessage')"
/>
<div class="mb-4 flex gap-1">
<Checkbox v-model="doNotAskAgain" binary input-id="doNotAskAgain" />
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
<div class="mb-4 flex flex-col gap-1">
<div class="flex gap-1">
<input
id="doNotAskAgain"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="text-sm text-muted-foreground ml-6"
>
<template #link>
<Button
variant="textonly"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openShowMissingModelsSetting"
>
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<ListBox :options="missingModels" class="comfy-missing-models">
<template #option="{ option }">
@@ -31,16 +54,18 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import ListBox from 'primevue/listbox'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
// TODO: Read this from server internal API rather than hardcoding here
// as some installations may wish to use custom sources
@@ -78,6 +103,11 @@ const { t } = useI18n()
const doNotAskAgain = ref(false)
function openShowMissingModelsSetting() {
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
}
const modelDownloads = ref<Record<string, ModelInfo>>({})
const missingModels = computed(() => {
return props.missingModels.map((model) => {

View File

@@ -1,55 +1,86 @@
<template>
<!-- Cloud mode: Learn More + Got It buttons -->
<div
v-if="isCloud"
class="flex w-full items-center justify-between gap-2 py-2 px-4"
>
<Button
variant="textonly"
size="sm"
as="a"
href="https://www.comfy.org/cloud"
target="_blank"
rel="noopener noreferrer"
>
<i class="icon-[lucide--info]"></i>
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
</Button>
<Button variant="secondary" size="md" @click="handleGotItClick">{{
$t('missingNodes.cloud.gotIt')
}}</Button>
</div>
<div class="flex w-full flex-col gap-2 py-2 px-4">
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
<div class="flex items-center gap-1">
<input
id="doNotAskAgainNodes"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgainNodes">{{
$t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="text-sm text-muted-foreground ml-6"
>
<template #link>
<Button
variant="textonly"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openShowMissingNodesSetting"
>
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
<Button variant="textonly" @click="openManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton
v-if="showInstallAllButton"
type="secondary"
size="md"
:disabled="
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
"
:is-loading="isLoading"
:node-packs="missingNodePacks"
:label="
isLoading
? $t('manager.gettingInfo')
: $t('manager.installAllMissingNodes')
"
/>
<!-- Cloud mode: Learn More + Got It buttons -->
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
<Button
variant="textonly"
size="sm"
as="a"
href="https://www.comfy.org/cloud"
target="_blank"
rel="noopener noreferrer"
>
<i class="icon-[lucide--info]"></i>
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
</Button>
<Button variant="secondary" size="md" @click="handleGotItClick">{{
$t('missingNodes.cloud.gotIt')
}}</Button>
</div>
<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
<Button variant="textonly" @click="openManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton
v-if="showInstallAllButton"
type="secondary"
size="md"
:disabled="
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
"
:is-loading="isLoading"
:node-packs="missingNodePacks"
:label="
isLoading
? $t('manager.gettingInfo')
: $t('manager.installAllMissingNodes')
"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -60,10 +91,21 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
const dialogStore = useDialogStore()
const { t } = useI18n()
const doNotAskAgain = ref(false)
watch(doNotAskAgain, (value) => {
void useSettingStore().set('Comfy.Workflow.ShowMissingNodesWarning', !value)
})
const handleGotItClick = () => {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
}
function openShowMissingNodesSetting() {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingNodesWarning')
}
const { missingNodePacks, isLoading, error } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const managerState = useManagerState()

View File

@@ -162,7 +162,7 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
@@ -173,7 +173,7 @@ const { isInsufficientCredits = false } = defineProps<{
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
@@ -266,7 +266,7 @@ async function handleBuy() {
: isSubscriptionEnabled()
? 'subscription'
: 'credits'
dialogService.showSettingsDialog(settingsPanel)
settingsDialog.show(settingsPanel)
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -161,7 +161,7 @@ import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
@@ -172,7 +172,7 @@ const { isInsufficientCredits = false } = defineProps<{
const { t } = useI18n()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
@@ -266,7 +266,7 @@ async function handleBuy() {
})
await fetchBalance()
handleClose(false)
dialogService.showSettingsDialog('workspace')
settingsDialog.show('workspace')
} else if (response.status === 'pending') {
billingOperationStore.startOperation(response.billing_op_id, 'topup')
} else {

View File

@@ -1,9 +1,5 @@
<template>
<PanelTemplate
value="About"
class="about-container"
data-testid="about-panel"
>
<div class="about-container flex flex-col gap-2" data-testid="about-panel">
<h2 class="mb-2 text-2xl font-bold">
{{ $t('g.about') }}
</h2>
@@ -32,7 +28,7 @@
v-if="systemStatsStore.systemStats"
:stats="systemStatsStore.systemStats"
/>
</PanelTemplate>
</div>
</template>
<script setup lang="ts">
@@ -43,8 +39,6 @@ import SystemStatsPanel from '@/components/common/SystemStatsPanel.vue'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import PanelTemplate from './PanelTemplate.vue'
const systemStatsStore = useSystemStatsStore()
const aboutPanelStore = useAboutPanelStore()
</script>

View File

@@ -1,13 +1,9 @@
<template>
<PanelTemplate value="Keybinding" class="keybinding-panel">
<template #header>
<SearchBox
v-model="filters['global'].value"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
"
/>
</template>
<div class="keybinding-panel flex flex-col gap-2">
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
/>
<DataTable
v-model:selection="selectedCommandData"
@@ -135,7 +131,7 @@
<i class="pi pi-replay" />
{{ $t('g.resetAll') }}
</Button>
</PanelTemplate>
</div>
</template>
<script setup lang="ts">
@@ -159,7 +155,6 @@ import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import PanelTemplate from './PanelTemplate.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
const filters = ref({

View File

@@ -1,5 +1,5 @@
<template>
<TabPanel value="Credits" class="credits-container h-full">
<div class="credits-container h-full">
<!-- Legacy Design -->
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">
@@ -102,7 +102,7 @@
</Button>
</div>
</div>
</TabPanel>
</div>
</template>
<script setup lang="ts">
@@ -110,7 +110,6 @@ import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Divider from 'primevue/divider'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'

View File

@@ -6,14 +6,15 @@
<!-- Section Header -->
<div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span
v-if="uiConfig.showMembersList"
class="text-base font-semibold text-base-foreground"
>
<span class="text-base font-semibold text-base-foreground">
<template v-if="activeView === 'active'">
{{
$t('workspacePanel.members.membersCount', {
count: members.length
count:
isSingleSeatPlan || isPersonalWorkspace
? 1
: members.length,
maxSeats: maxSeats
})
}}
</template>
@@ -27,7 +28,10 @@
</template>
</span>
</div>
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
<div
v-if="uiConfig.showSearch && !isSingleSeatPlan"
class="flex items-start gap-2"
>
<SearchBox
v-model="searchQuery"
:placeholder="$t('g.search')"
@@ -45,14 +49,16 @@
:class="
cn(
'grid w-full items-center py-2',
activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
isSingleSeatPlan
? 'grid-cols-1 py-0'
: activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
)
"
>
<!-- Tab buttons in first column -->
<div class="flex items-center gap-2">
<div v-if="!isSingleSeatPlan" class="flex items-center gap-2">
<Button
:variant="
activeView === 'active' ? 'secondary' : 'muted-textonly'
@@ -101,17 +107,19 @@
<div />
</template>
<template v-else>
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
<template v-if="!isSingleSeatPlan">
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
</template>
</template>
</div>
@@ -166,7 +174,7 @@
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols,
isSingleSeatPlan ? 'grid-cols-1' : uiConfig.membersGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
@@ -206,14 +214,14 @@
</div>
<!-- Join date -->
<span
v-if="uiConfig.showDateColumn"
v-if="uiConfig.showDateColumn && !isSingleSeatPlan"
class="text-sm text-muted-foreground text-right"
>
{{ formatDate(member.joinDate) }}
</span>
<!-- Remove member action (OWNER only, can't remove yourself) -->
<div
v-if="permissions.canRemoveMembers"
v-if="permissions.canRemoveMembers && !isSingleSeatPlan"
class="flex items-center justify-end"
>
<Button
@@ -237,8 +245,29 @@
</template>
</template>
<!-- Upsell Banner -->
<div
v-if="isSingleSeatPlan"
class="flex items-center gap-2 rounded-xl border bg-secondary-background border-border-default px-4 py-3 mt-4 justify-center"
>
<p class="m-0 text-sm text-foreground">
{{
isActiveSubscription
? $t('workspacePanel.members.upsellBannerUpgrade')
: $t('workspacePanel.members.upsellBannerSubscribe')
}}
</p>
<Button
variant="muted-textonly"
class="cursor-pointer underline text-sm"
@click="showSubscriptionDialog()"
>
{{ $t('workspacePanel.members.viewPlans') }}
</Button>
</div>
<!-- Pending Invites -->
<template v-else>
<template v-if="activeView === 'pending'">
<div
v-for="(invite, index) in filteredPendingInvites"
:key="invite.id"
@@ -342,6 +371,8 @@ import SearchBox from '@/components/common/SearchBox.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
PendingInvite,
@@ -367,6 +398,27 @@ const {
} = storeToRefs(workspaceStore)
const { copyInviteLink } = workspaceStore
const { permissions, uiConfig } = useWorkspaceUI()
const {
isActiveSubscription,
subscription,
showSubscriptionDialog,
getMaxSeats
} = useBillingContext()
const maxSeats = computed(() => {
if (isPersonalWorkspace.value) return 1
const tier = subscription.value?.tier
if (!tier) return 1
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return 1
return getMaxSeats(tierKey)
})
const isSingleSeatPlan = computed(() => {
if (isPersonalWorkspace.value) return false
if (!isActiveSubscription.value) return true
return maxSeats.value <= 1
})
const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active')

View File

@@ -1,21 +0,0 @@
<template>
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
<div class="flex h-full w-full flex-col gap-2">
<slot name="header" />
<ScrollPanel class="h-0 grow pr-2">
<slot />
</ScrollPanel>
<slot name="footer" />
</div>
</TabPanel>
</template>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import TabPanel from 'primevue/tabpanel'
const props = defineProps<{
value: string
class?: string
}>()
</script>

View File

@@ -1,5 +1,5 @@
<template>
<TabPanel value="User" class="user-settings-container h-full">
<div class="user-settings-container h-full">
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">{{ $t('userSettings.title') }}</h2>
<Divider class="mb-3" />
@@ -95,13 +95,12 @@
</Button>
</div>
</div>
</TabPanel>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import TabPanel from 'primevue/tabpanel'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -1,11 +0,0 @@
<template>
<TabPanel value="Workspace" class="h-full">
<WorkspacePanelContent />
</TabPanel>
</template>
<script setup lang="ts">
import TabPanel from 'primevue/tabpanel'
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex h-full w-full flex-col">
<div class="pb-8 flex items-center gap-4">
<header class="mb-8 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 !text-3xl"
:workspace-name="workspaceName"
@@ -8,44 +8,38 @@
<h1 class="text-3xl text-base-foreground">
{{ workspaceName }}
</h1>
</div>
<Tabs unstyled :value="activeTab" @update:value="setActiveTab">
</header>
<TabsRoot v-model="activeTab">
<div class="flex w-full items-center">
<TabList unstyled class="flex w-full gap-2">
<Tab
<TabsList class="flex items-center gap-2 pb-1">
<TabsTrigger
value="plan"
:class="
cn(
buttonVariants({
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'plan' && 'text-base-foreground no-underline'
tabTriggerBase,
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{ $t('workspacePanel.tabs.planCredits') }}
</Tab>
<Tab
</TabsTrigger>
<TabsTrigger
value="members"
:class="
cn(
buttonVariants({
variant: activeTab === 'members' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'members' && 'text-base-foreground no-underline',
'ml-2'
tabTriggerBase,
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{
$t('workspacePanel.tabs.membersCount', {
count: isInPersonalWorkspace ? 1 : members.length
count: members.length
})
}}
</Tab>
</TabList>
</TabsTrigger>
</TabsList>
<Button
v-if="permissions.canInviteMembers"
v-tooltip="
@@ -55,20 +49,22 @@
"
variant="secondary"
size="lg"
:disabled="isInviteLimitReached"
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
:disabled="!isSingleSeatPlan && isInviteLimitReached"
:class="
!isSingleSeatPlan &&
isInviteLimitReached &&
'opacity-50 cursor-not-allowed'
"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
{{ $t('workspacePanel.invite') }}
<i class="pi pi-plus ml-1 text-sm" />
<i class="pi pi-plus text-sm" />
</Button>
<template v-if="permissions.canAccessWorkspaceMenu">
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
class="ml-2"
variant="secondary"
size="lg"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="menu?.toggle($event)"
>
@@ -76,17 +72,21 @@
</Button>
<Menu ref="menu" :model="menuItems" :popup="true">
<template #item="{ item }">
<div
<button
v-tooltip="
item.disabled && deleteTooltip
? { value: deleteTooltip, showDelay: 0 }
: null
"
:class="[
'flex items-center gap-2 px-3 py-2',
item.class,
item.disabled ? 'pointer-events-auto' : 'cursor-pointer'
]"
type="button"
:disabled="!!item.disabled"
:class="
cn(
'flex w-full items-center gap-2 px-3 py-2 bg-transparent border-none cursor-pointer',
item.class,
item.disabled && 'pointer-events-auto cursor-not-allowed'
)
"
@click="
item.command?.({
originalEvent: $event,
@@ -96,44 +96,47 @@
>
<i :class="item.icon" />
<span>{{ item.label }}</span>
</div>
</button>
</template>
</Menu>
</template>
</div>
<TabPanels unstyled>
<TabPanel value="plan">
<SubscriptionPanelContentWorkspace />
</TabPanel>
<TabPanel value="members">
<MembersPanelContent :key="workspaceRole" />
</TabPanel>
</TabPanels>
</Tabs>
<TabsContent value="plan" class="mt-4">
<SubscriptionPanelContentWorkspace />
</TabsContent>
<TabsContent value="members" class="mt-4">
<MembersPanelContent :key="workspaceRole" />
</TabsContent>
</TabsRoot>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue'
import { buttonVariants } from '@/components/ui/button/button.variants'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { cn } from '@/utils/tailwindUtil'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
const tabTriggerBase =
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
const tabTriggerActive =
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
const tabTriggerInactive =
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
@@ -144,19 +147,26 @@ const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showEditWorkspaceDialog
} = useDialogService()
const { isActiveSubscription, subscription, getMaxSeats } = useBillingContext()
const isSingleSeatPlan = computed(() => {
if (!isActiveSubscription.value) return true
const tier = subscription.value?.tier
if (!tier) return true
const tierKey = TIER_TO_KEY[tier]
if (!tierKey) return true
return getMaxSeats(tierKey) <= 1
})
const workspaceStore = useTeamWorkspaceStore()
const {
workspaceName,
members,
isInviteLimitReached,
isWorkspaceSubscribed,
isInPersonalWorkspace
} = storeToRefs(workspaceStore)
const { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
useWorkspaceUI()
const { workspaceRole, permissions, uiConfig } = useWorkspaceUI()
const activeTab = ref(defaultTab)
const menu = ref<InstanceType<typeof Menu> | null>(null)
@@ -187,11 +197,16 @@ const deleteTooltip = computed(() => {
})
const inviteTooltip = computed(() => {
if (isSingleSeatPlan.value) return null
if (!isInviteLimitReached.value) return null
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
if (isSingleSeatPlan.value) {
showInviteMemberUpsellDialog()
return
}
if (isInviteLimitReached.value) return
showInviteMemberDialog()
}
@@ -231,7 +246,6 @@ const menuItems = computed(() => {
})
onMounted(() => {
setActiveTab(defaultTab)
fetchMembers()
fetchPendingInvites()
})

View File

@@ -1,19 +0,0 @@
<template>
<div class="flex items-center gap-2">
<WorkspaceProfilePic
class="size-6 text-xs"
:workspace-name="workspaceName"
/>
<span>{{ workspaceName }}</span>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
</script>

View File

@@ -70,31 +70,17 @@
@click="onSelectLink"
/>
<div
class="absolute right-4 top-2 cursor-pointer"
class="absolute right-3 top-2.5 cursor-pointer"
@click="onCopyLink"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<g clip-path="url(#clip0_2127_14348)">
<path
d="M2.66634 10.6666C1.93301 10.6666 1.33301 10.0666 1.33301 9.33325V2.66659C1.33301 1.93325 1.93301 1.33325 2.66634 1.33325H9.33301C10.0663 1.33325 10.6663 1.93325 10.6663 2.66659M6.66634 5.33325H13.333C14.0694 5.33325 14.6663 5.93021 14.6663 6.66658V13.3333C14.6663 14.0696 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0696 5.33301 13.3333V6.66658C5.33301 5.93021 5.92996 5.33325 6.66634 5.33325Z"
stroke="white"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_2127_14348">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
<i
:class="
cn(
'pi size-4',
justCopied ? 'pi-check text-green-500' : 'pi-copy'
)
"
/>
</div>
</div>
</div>
@@ -118,6 +104,7 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -130,6 +117,7 @@ const loading = ref(false)
const email = ref('')
const step = ref<'email' | 'link'>('email')
const generatedLink = ref('')
const justCopied = ref(false)
const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@@ -161,6 +149,10 @@ async function onCreateLink() {
async function onCopyLink() {
try {
await navigator.clipboard.writeText(generatedLink.value)
justCopied.value = true
setTimeout(() => {
justCopied.value = false
}, 759)
toast.add({
severity: 'success',
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),

View File

@@ -0,0 +1,68 @@
<template>
<div
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.titleSingleSeat')
: $t('workspacePanel.inviteUpsellDialog.titleNotSubscribed')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onDismiss"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.messageSingleSeat')
: $t('workspacePanel.inviteUpsellDialog.messageNotSubscribed')
}}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onDismiss">
{{ $t('g.cancel') }}
</Button>
<Button variant="primary" size="lg" @click="onUpgrade">
{{
isActiveSubscription
? $t('workspacePanel.inviteUpsellDialog.upgradeToCreator')
: $t('workspacePanel.inviteUpsellDialog.viewPlans')
}}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
function onDismiss() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
}
function onUpgrade() {
dialogStore.closeDialog({ key: 'invite-member-upsell' })
showSubscriptionDialog()
}
</script>

View File

@@ -1,32 +0,0 @@
<template>
<div>
<h2 :class="cn(flags.teamWorkspacesEnabled ? 'px-6' : 'px-4')">
<i class="pi pi-cog" />
<span>{{ $t('g.settings') }}</span>
<Tag
v-if="isStaging"
value="staging"
severity="warn"
class="ml-2 text-xs"
/>
</h2>
</div>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
import { isStaging } from '@/config/staging'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { cn } from '@comfyorg/tailwind-utils'
const { flags } = useFeatureFlags()
</script>
<style scoped>
.pi-cog {
font-size: 1.25rem;
margin-right: 0.5rem;
}
.version-tag {
margin-left: 0.5rem;
}
</style>

View File

@@ -94,6 +94,7 @@
<template v-if="comfyAppReady">
<TitleEditor />
<SelectionToolbox v-if="selectionToolboxEnabled" />
<NodeContextMenu />
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
<DomWidgets v-if="!shouldRenderVueNodes" />
</template>
@@ -121,6 +122,7 @@ import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'

View File

@@ -42,7 +42,6 @@
</Panel>
</Transition>
</div>
<NodeContextMenu />
</template>
<script setup lang="ts">
@@ -69,7 +68,6 @@ import { useExtensionService } from '@/services/extensionService'
import { useCommandStore } from '@/stores/commandStore'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import NodeContextMenu from './NodeContextMenu.vue'
import FrameNodes from './selectionToolbox/FrameNodes.vue'
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'

View File

@@ -18,7 +18,7 @@
</template>
<script setup lang="ts">
import { useElementBounding, useEventListener } from '@vueuse/core'
import { useElementBounding, useEventListener, whenever } from '@vueuse/core'
import type { CSSProperties } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
@@ -180,6 +180,8 @@ watch(
mountElementIfVisible()
}
)
whenever(() => !canvasStore.linearMode, mountElementIfVisible)
</script>
<style scoped>

View File

@@ -14,11 +14,13 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import TabError from './TabError.vue'
import TabInfo from './info/TabInfo.vue'
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
import TabNodes from './parameters/TabNodes.vue'
@@ -33,6 +35,7 @@ import {
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
@@ -87,10 +90,25 @@ function closePanel() {
type RightSidePanelTabList = Array<{
label: () => string
value: RightSidePanelTab
icon?: string
}>
//FIXME all errors if nothing selected?
const selectedNodeErrors = computed(() =>
selectedNodes.value
.map((node) => executionStore.getNodeErrors(`${node.id}`))
.filter((nodeError) => !!nodeError)
)
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []
if (selectedNodeErrors.value.length) {
list.push({
label: () => t('g.error'),
value: 'error',
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
})
}
list.push({
label: () =>
@@ -271,6 +289,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
:value="tab.value"
>
{{ tab.label() }}
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
</Tab>
</TabList>
</nav>
@@ -288,6 +307,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
:node="selectedSingleNode"
/>
<template v-else>
<TabError v-if="activeTab === 'error'" :errors="selectedNodeErrors" />
<TabSubgraphInputs
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
:node="selectedSingleNode as SubgraphNode"

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { NodeError } from '@/schemas/apiSchema'
const { t } = useI18n()
defineProps<{
errors: NodeError[]
}>()
const { copyToClipboard } = useCopyToClipboard()
</script>
<template>
<div class="m-4">
<Button class="w-full" @click="copyToClipboard(JSON.stringify(errors))">
{{ t('g.copy') }}
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div
v-for="(error, index) in errors.flatMap((ne) => ne.errors)"
:key="index"
class="px-2"
>
<h3 class="text-error" v-text="error.message" />
<div class="text-muted-foreground" v-text="error.details" />
</div>
</template>

View File

@@ -83,10 +83,7 @@ const favoriteNode = computed(() =>
)
const widgetValue = computed({
get: () => {
widget.vueTrack?.()
return widget.value
},
get: () => widget.value,
set: (newValue: string | number | boolean | object) => {
emit('update:widgetValue', newValue)
}

View File

@@ -11,7 +11,7 @@ import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { cn } from '@/utils/tailwindUtil'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
@@ -20,7 +20,7 @@ import LayoutField from './LayoutField.vue'
const { t } = useI18n()
const settingStore = useSettingStore()
const dialogService = useDialogService()
const settingsDialog = useSettingsDialog()
// NODES settings
const showAdvancedParameters = computed({
@@ -92,7 +92,7 @@ function updateGridSpacingFromInput(value: number | null | undefined) {
}
function openFullSettings() {
dialogService.showSettingsDialog()
settingsDialog.show()
}
</script>

View File

@@ -108,15 +108,14 @@ import ToggleSwitch from 'primevue/toggleswitch'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingPanelType } from '@/platform/settings/types'
import { useTelemetry } from '@/platform/telemetry'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
@@ -129,7 +128,7 @@ const commandStore = useCommandStore()
const menuItemStore = useMenuItemStore()
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
const dialogStore = useDialogStore()
const settingsDialog = useSettingsDialog()
const managerState = useManagerState()
const settingStore = useSettingStore()
@@ -166,15 +165,8 @@ const translateMenuItem = (item: MenuItem): MenuItem => {
}
}
const showSettings = (defaultPanel?: string) => {
dialogStore.showDialog({
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent,
props: {
defaultPanel
}
})
const showSettings = (defaultPanel?: SettingPanelType) => {
settingsDialog.show(defaultPanel)
}
const showManageExtensions = async () => {

View File

@@ -50,7 +50,8 @@
<template #before-label="{ node: treeNode }">
<span
v-if="
treeNode.data?.isModified || !treeNode.data?.isPersisted
(treeNode.data as ComfyWorkflow)?.isModified ||
!(treeNode.data as ComfyWorkflow)?.isPersisted
"
>*</span
>

View File

@@ -215,7 +215,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
}
)
const treeExplorerRef = ref<InstanceType<typeof TreeExplorer> | null>(null)
interface TreeExplorerExposed {
addFolderCommand: (targetNodeKey: string) => void
}
const treeExplorerRef = ref<TreeExplorerExposed | null>(null)
defineExpose({
addNewBookmarkFolder: () => treeExplorerRef.value?.addFolderCommand('root')
})

View File

@@ -63,7 +63,7 @@ onUnmounted(() => {
})
const expandedKeys = inject(InjectKeyExpandedKeys)
const handleItemDrop = (node: RenderedTreeExplorerNode) => {
const handleItemDrop = (node: RenderedTreeExplorerNode<ComfyNodeDefImpl>) => {
if (!expandedKeys) return
expandedKeys.value[node.key] = true
}

View File

@@ -0,0 +1,42 @@
<template>
<Toast group="invite-accepted" position="top-right">
<template #message="slotProps">
<div class="flex items-center gap-2 justify-between w-full">
<div class="flex flex-col justify-start">
<div class="text-base">
{{ slotProps.message.summary }}
</div>
<div class="mt-1 text-sm text-foreground">
{{ slotProps.message.detail.text }} <br />
{{ slotProps.message.detail.workspaceName }}
</div>
</div>
<Button
size="md"
variant="inverted"
@click="viewWorkspace(slotProps.message.detail.workspaceId)"
>
{{ t('workspace.viewWorkspace') }}
</Button>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Toast from 'primevue/toast'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
const { t } = useI18n()
const toast = useToast()
const { switchWithConfirmation } = useWorkspaceSwitch()
function viewWorkspace(workspaceId: string) {
void switchWithConfirmation(workspaceId)
toast.removeGroup('invite-accepted')
}
</script>

View File

@@ -31,6 +31,15 @@ vi.mock('pinia')
const mockShowSettingsDialog = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
// Mock the settings dialog composable
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: mockShowSettingsDialog,
hide: vi.fn(),
showAbout: vi.fn()
}))
}))
// Mock window.open
const originalWindowOpen = window.open
beforeEach(() => {
@@ -64,7 +73,6 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
// Mock the dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showSettingsDialog: mockShowSettingsDialog,
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
}))
}))

View File

@@ -152,6 +152,7 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -165,6 +166,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
@@ -198,7 +200,7 @@ const canUpgrade = computed(() => {
})
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
settingsDialog.show('user')
emit('close')
}
@@ -209,9 +211,9 @@ const handleOpenPlansAndPricing = () => {
const handleOpenPlanAndCreditsSettings = () => {
if (isCloud) {
dialogService.showSettingsDialog('subscription')
settingsDialog.show('subscription')
} else {
dialogService.showSettingsDialog('credits')
settingsDialog.show('credits')
}
emit('close')

View File

@@ -55,63 +55,61 @@
/>
</Popover>
<!-- Credits Section (PERSONAL and OWNER only) -->
<template v-if="showCreditsSection">
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
displayedCredits
}}</span>
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
/>
<!-- Subscribed: Show Add Credits button -->
<Button
v-if="isActiveSubscription && isWorkspaceSubscribed"
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Unsubscribed: Show Subscribe button -->
<SubscribeButton
v-else-if="isPersonalWorkspace"
:fluid="false"
:label="
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
"
size="sm"
variant="gradient"
/>
<!-- Non-personal workspace: Show pricing table -->
<Button
v-else
variant="primary"
size="sm"
@click="handleOpenPlansAndPricing"
>
{{
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
}}
</Button>
</div>
<!-- Credits Section -->
<Divider class="mx-0 my-2" />
</template>
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
displayedCredits
}}</span>
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
/>
<!-- Add Credits (subscribed + personal or workspace owner only) -->
<Button
v-if="isActiveSubscription && permissions.canTopUp"
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Subscribe/Resubscribe (only when not subscribed or cancelled) -->
<SubscribeButton
v-if="showSubscribeAction && isPersonalWorkspace"
:fluid="false"
:label="
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
"
size="sm"
variant="gradient"
/>
<Button
v-if="showSubscribeAction && !isPersonalWorkspace"
variant="primary"
size="sm"
@click="handleOpenPlansAndPricing"
>
{{
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
}}
</Button>
</div>
<Divider class="mx-0 my-2" />
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
<div
@@ -222,16 +220,16 @@ import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
const workspaceStore = useTeamWorkspaceStore()
const {
initState,
workspaceName,
isInPersonalWorkspace: isPersonalWorkspace,
isWorkspaceSubscribed
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { workspaceRole } = useWorkspaceUI()
const { permissions } = useWorkspaceUI()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
const emit = defineEmits<{
@@ -242,6 +240,7 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
useBillingContext()
@@ -275,22 +274,24 @@ const canUpgrade = computed(() => {
})
const showPlansAndPricing = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
() => permissions.value.canManageSubscription
)
const showManagePlan = computed(
() => showPlansAndPricing.value && isActiveSubscription.value
() => permissions.value.canManageSubscription && isActiveSubscription.value
)
const showCreditsSection = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
const showSubscribeAction = computed(
() =>
permissions.value.canManageSubscription &&
(!isActiveSubscription.value || isCancelled.value)
)
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
settingsDialog.show('user')
emit('close')
}
const handleOpenWorkspaceSettings = () => {
dialogService.showSettingsDialog('workspace')
settingsDialog.show('workspace')
emit('close')
}
@@ -301,9 +302,9 @@ const handleOpenPlansAndPricing = () => {
const handleOpenPlanAndCreditsSettings = () => {
if (isCloud) {
dialogService.showSettingsDialog('workspace')
settingsDialog.show('workspace')
} else {
dialogService.showSettingsDialog('credits')
settingsDialog.show('credits')
}
emit('close')

View File

@@ -36,7 +36,7 @@ defineProps<{
:side-offset="5"
:collision-padding="10"
v-bind="$attrs"
class="rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
class="z-1700 rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
>
<slot>
<div class="flex flex-col p-1">

View File

@@ -1,6 +1,6 @@
<template>
<div
class="base-widget-layout rounded-2xl overflow-hidden relative"
:class="cn('rounded-2xl overflow-hidden relative', sizeClasses)"
@keydown.esc.capture="handleEscape"
>
<div
@@ -141,14 +141,31 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { OnCloseKey } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const { contentTitle, rightPanelTitle } = defineProps<{
const SIZE_CLASSES = {
sm: 'h-[80vh] w-[90vw] max-w-[960px]',
md: 'h-[80vh] w-[90vw] max-w-[1400px]',
lg: 'h-[80vh] w-[90vw] max-w-[1280px] aspect-[20/13] min-[1450px]:max-w-[1724px]',
full: 'h-full w-full max-w-[1400px] 2xl:max-w-[1600px]'
} as const
type ModalSize = keyof typeof SIZE_CLASSES
const {
contentTitle,
rightPanelTitle,
size = 'lg'
} = defineProps<{
contentTitle: string
rightPanelTitle?: string
size?: ModalSize
}>()
const sizeClasses = computed(() => SIZE_CLASSES[size])
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
default: false
})
@@ -215,17 +232,3 @@ function handleEscape(event: KeyboardEvent) {
}
}
</script>
<style scoped>
.base-widget-layout {
height: 80vh;
width: 90vw;
max-width: 1280px;
aspect-ratio: 20/13;
}
@media (min-width: 1450px) {
.base-widget-layout {
max-width: 1724px;
}
}
</style>

View File

@@ -1,5 +1,6 @@
import type { ComputedRef, Ref } from 'vue'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
Plan,
PreviewSubscribeResponse,
@@ -73,4 +74,5 @@ export interface BillingState {
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
getMaxSeats: (tierKey: TierKey) => number
}

View File

@@ -1,33 +1,49 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext'
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] }
})
)
vi.mock('@vueuse/core', async (importOriginal) => {
const original = await importOriginal()
return {
...(original as Record<string, unknown>),
createSharedComposable: (fn: (...args: unknown[]) => unknown) => fn
}
})
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
teamWorkspacesEnabled: true
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
const isInPersonalWorkspace = { value: true }
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
return {
useTeamWorkspaceStore: () => ({
isInPersonalWorkspace: isInPersonalWorkspace.value,
activeWorkspace: activeWorkspace.value,
updateActiveWorkspace: vi.fn(),
_setPersonalWorkspace: (value: boolean) => {
isInPersonalWorkspace.value = value
activeWorkspace.value = value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
}
})
}
})
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get isInPersonalWorkspace() {
return mockIsPersonal.value
},
get activeWorkspace() {
return mockIsPersonal.value
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
},
updateActiveWorkspace: vi.fn()
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
@@ -61,20 +77,18 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
})
}))
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => {
const plans = { value: [] }
const currentPlanSlug = { value: null }
return {
useBillingPlans: () => ({
plans,
currentPlanSlug,
isLoading: { value: false },
error: { value: null },
fetchPlans: vi.fn().mockResolvedValue(undefined),
getPlanBySlug: vi.fn().mockReturnValue(null)
})
}
})
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
useBillingPlans: () => ({
get plans() {
return mockPlans
},
currentPlanSlug: { value: null },
isLoading: { value: false },
error: { value: null },
fetchPlans: vi.fn().mockResolvedValue(undefined),
getPlanBySlug: vi.fn().mockReturnValue(null)
})
}))
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
@@ -89,10 +103,7 @@ vi.mock('@/platform/workspace/api/workspaceApi', () => ({
currency: 'usd'
}),
subscribe: vi.fn().mockResolvedValue({ status: 'subscribed' }),
previewSubscribe: vi.fn().mockResolvedValue({ allowed: true }),
getPaymentPortalUrl: vi
.fn()
.mockResolvedValue({ url: 'https://example.com/billing' })
previewSubscribe: vi.fn().mockResolvedValue({ allowed: true })
}
}))
@@ -100,39 +111,40 @@ describe('useBillingContext', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
})
it('returns workspace type for personal workspace when feature flag enabled', () => {
it('returns legacy type for personal workspace', () => {
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
expect(type.value).toBe('legacy')
})
it('provides subscription info from workspace billing', async () => {
const { subscription, initialize } = useBillingContext()
await initialize()
it('provides subscription info from legacy billing', () => {
const { subscription } = useBillingContext()
expect(subscription.value).toEqual({
isActive: true,
tier: 'PRO',
duration: 'MONTHLY',
planSlug: null,
renewalDate: null,
renewalDate: 'Jan 1, 2025',
endDate: null,
isCancelled: false,
hasFunds: true
})
})
it('provides balance info from workspace billing', async () => {
const { balance, initialize } = useBillingContext()
await initialize()
it('provides balance info from legacy billing', () => {
const { balance } = useBillingContext()
expect(balance.value).toEqual({
amountMicros: 10000000,
amountMicros: 5000000,
currency: 'usd',
effectiveBalanceMicros: undefined,
prepaidBalanceMicros: undefined,
cloudCreditBalanceMicros: undefined
effectiveBalanceMicros: 5000000,
prepaidBalanceMicros: 0,
cloudCreditBalanceMicros: 0
})
})
@@ -153,7 +165,7 @@ describe('useBillingContext', () => {
it('exposes subscribe action', async () => {
const { subscribe } = useBillingContext()
await expect(subscribe('pro-monthly')).resolves.toBeDefined()
await expect(subscribe('pro-monthly')).resolves.toBeUndefined()
})
it('exposes manageSubscription action', async () => {
@@ -175,4 +187,51 @@ describe('useBillingContext', () => {
const { showSubscriptionDialog } = useBillingContext()
expect(() => showSubscriptionDialog()).not.toThrow()
})
describe('getMaxSeats', () => {
it('returns 1 for personal workspaces regardless of tier', () => {
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('standard')).toBe(1)
expect(getMaxSeats('creator')).toBe(1)
expect(getMaxSeats('pro')).toBe(1)
expect(getMaxSeats('founder')).toBe(1)
})
it('falls back to hardcoded values when no API plans available', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('standard')).toBe(1)
expect(getMaxSeats('creator')).toBe(5)
expect(getMaxSeats('pro')).toBe(20)
expect(getMaxSeats('founder')).toBe(1)
})
it('prefers API max_seats when plans are loaded', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockPlans.value = [
{
slug: 'pro-monthly',
tier: 'PRO',
duration: 'MONTHLY',
price_cents: 10000,
credits_cents: 2110000,
max_seats: 50,
availability: { available: true },
seat_summary: {
seat_count: 1,
total_cost_cents: 10000,
total_credits_cents: 2110000
}
}
]
const { getMaxSeats } = useBillingContext()
expect(getMaxSeats('pro')).toBe(50)
// Tiers without API plans still fall back to hardcoded values
expect(getMaxSeats('creator')).toBe(5)
})
})
})

View File

@@ -2,6 +2,11 @@ import { computed, ref, shallowRef, toValue, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
KEY_TO_TIER,
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
@@ -83,11 +88,13 @@ function useBillingContextInternal(): BillingContext {
/**
* Determines which billing type to use:
* - If team workspaces feature is disabled: always use legacy (/customers)
* - If team workspaces feature is enabled: always use workspace (/billing)
* - If team workspaces feature is enabled:
* - Personal workspace: use legacy (/customers)
* - Team workspace: use workspace (/billing)
*/
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
return 'workspace'
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
})
const activeContext = computed(() =>
@@ -113,13 +120,23 @@ function useBillingContextInternal(): BillingContext {
toValue(activeContext.value.isActiveSubscription)
)
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
const apiTier = KEY_TO_TIER[tierKey]
const plan = plans.value.find(
(p) => p.tier === apiTier && p.duration === 'MONTHLY'
)
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
}
// Sync subscription info to workspace store for display in workspace switcher
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
// This ensures the delete button is enabled after cancellation, even before the period ends
watch(
subscription,
(sub) => {
if (!sub) return
if (!sub || store.isInPersonalWorkspace) return
store.updateActiveWorkspace({
isSubscribed: sub.isActive && !sub.isCancelled,
@@ -221,6 +238,7 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
getMaxSeats,
initialize,
fetchStatus,

View File

@@ -47,7 +47,7 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
tier: status.subscription_tier ?? null,
duration: status.subscription_duration ?? null,
planSlug: status.plan_slug ?? null,
renewalDate: null, // Workspace billing uses cancel_at for end date
renewalDate: status.renewal_date ?? null,
endDate: status.cancel_at ?? null,
isCancelled: status.subscription_status === 'canceled',
hasFunds: status.has_funds

View File

@@ -1,49 +1,76 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
setActivePinia(createTestingPinia())
function createTestGraph() {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('input', 'INT')
node.addWidget('number', 'testnum', 2, () => undefined, {})
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const onReactivityUpdate = vi.fn()
watch(vueNodeData, onReactivityUpdate)
return [node, graph, onReactivityUpdate] as const
}
import { useWidgetValueStore } from '@/stores/widgetValueStore'
describe('Node Reactivity', () => {
it('should trigger on callback', async () => {
const [node, , onReactivityUpdate] = createTestGraph()
node.widgets![0].callback!(2)
await nextTick()
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('should remain reactive after a connection is made', async () => {
const [node, graph, onReactivityUpdate] = createTestGraph()
function createTestGraph() {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('input', 'INT')
node.addWidget('number', 'testnum', 2, () => undefined, {})
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
return { node, graph, vueNodeData }
}
it('widget values are reactive through the store', async () => {
const { node } = createTestGraph()
const store = useWidgetValueStore()
const widget = node.widgets![0]
// Verify widget is a BaseWidget with correct value and node assignment
expect(widget).toBeInstanceOf(BaseWidget)
expect(widget.value).toBe(2)
expect((widget as BaseWidget).node.id).toBe(node.id)
// Initial value should be in store after setNodeId was called
expect(store.getWidget(node.id, 'testnum')?.value).toBe(2)
const onValueChange = vi.fn()
const widgetValue = computed(
() => store.getWidget(node.id, 'testnum')?.value
)
watch(widgetValue, onValueChange)
widget.value = 42
await nextTick()
expect(widgetValue.value).toBe(42)
expect(onValueChange).toHaveBeenCalledTimes(1)
})
it('widget values remain reactive after a connection is made', async () => {
const { node, graph } = createTestGraph()
const store = useWidgetValueStore()
const onValueChange = vi.fn()
graph.trigger('node:slot-links:changed', {
nodeId: '1',
nodeId: String(node.id),
slotType: NodeSlotType.INPUT
})
await nextTick()
onReactivityUpdate.mockClear()
node.widgets![0].callback!(2)
const widgetValue = computed(
() => store.getWidget(node.id, 'testnum')?.value
)
watch(widgetValue, onValueChange)
node.widgets![0].value = 99
await nextTick()
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(widgetValue.value).toBe(99)
})
})

View File

@@ -3,7 +3,7 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import { customRef, reactive, shallowReactive } from 'vue'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
@@ -11,10 +11,7 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
@@ -41,19 +38,37 @@ export interface WidgetSlotMetadata {
linked: boolean
}
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
nodeId?: NodeId
name: string
type: string
value: WidgetValue
borderStyle?: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Whether widget has custom layout size computation */
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
label?: string
/** Node type (for subgraph promoted widgets) */
nodeType?: string
options?: IWidgetOptions
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
*/
options?: {
canvasOnly?: boolean
advanced?: boolean
hidden?: boolean
read_only?: boolean
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
}
@@ -95,23 +110,6 @@ export interface GraphNodeManager {
cleanup(): void
}
function widgetWithVueTrack(
widget: IBaseWidget
): asserts widget is IBaseWidget & { vueTrack: () => void } {
if (widget.vueTrack) return
customRef((track, trigger) => {
widget.callback = useChainCallback(widget.callback, trigger)
widget.vueTrack = track
return { get() {}, set() {} }
})
}
function useReactiveWidgetValue(widget: IBaseWidget) {
widgetWithVueTrack(widget)
widget.vueTrack()
return widget.value
}
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
@@ -133,26 +131,18 @@ function getNodeType(node: LGraphNode, widget: IBaseWidget) {
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
*/
interface SharedWidgetEnhancements {
/** Reactive widget value that updates when the widget changes */
value: WidgetValue
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Input specification from node definition */
spec?: InputSpec
/** Node type (for subgraph promoted widgets) */
nodeType?: string
/** Border style for promoted/advanced widgets */
borderStyle?: string
/** Widget label */
label?: string
/** Widget options */
options?: IWidgetOptions
}
/**
* Extracts common widget enhancements shared across different rendering contexts.
* This function centralizes the logic for extracting metadata and reactive values
* from widgets, ensuring consistency between Nodes 2.0 and Right Side Panel.
* This function centralizes the logic for extracting metadata from widgets.
* Note: Value and metadata (label, options, hidden, etc.) are accessed via widgetValueStore.
*/
export function getSharedWidgetEnhancements(
node: LGraphNode,
@@ -161,17 +151,9 @@ export function getSharedWidgetEnhancements(
const nodeDefStore = useNodeDefStore()
return {
value: useReactiveWidgetValue(widget),
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
nodeType: getNodeType(node, widget),
borderStyle: widget.promoted
? 'ring ring-component-node-widget-promoted'
: widget.advanced
? 'ring ring-component-node-widget-advanced'
: undefined,
label: widget.label,
options: widget.options as IWidgetOptions
nodeType: getNodeType(node, widget)
}
}
@@ -212,7 +194,7 @@ function safeWidgetMapper(
): (widget: IBaseWidget) => SafeWidgetData {
return function (widget) {
try {
// Get shared enhancements used by both Nodes 2.0 and Right Side Panel
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const slotInfo = slotMetadata.get(widget.name)
@@ -228,20 +210,41 @@ function safeWidgetMapper(
node.widgets?.forEach((w) => w.triggerDraw?.())
}
// Extract only render-critical options (canvasOnly, advanced, read_only)
const options = widget.options
? {
canvasOnly: widget.options.canvasOnly,
advanced: widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
: undefined
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const localId = isProxyWidget(widget)
? widget._overlay?.nodeId
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const name = isProxyWidget(widget)
? widget._overlay.widgetName
: widget.name
return {
name: widget.name,
nodeId,
name,
type: widget.type,
...sharedEnhancements,
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget),
options,
slotMetadata: slotInfo
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
type: widget.type || 'text'
}
}
}

View File

@@ -36,7 +36,9 @@ export const usePriceBadge = () => {
return badges
}
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
function isCreditsBadge(
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
): boolean {
const badgeInstance = typeof badge === 'function' ? badge() : badge
return badgeInstance.icon?.image === componentIconSvg
}
@@ -61,6 +63,7 @@ export const usePriceBadge = () => {
}
return {
getCreditsBadge,
isCreditsBadge,
updateSubgraphCredits
}
}

View File

@@ -134,6 +134,25 @@ vi.mock('@/stores/executionStore', () => ({
}
}))
let jobPreviewStoreMock: {
previewsByPromptId: Record<string, string>
isPreviewEnabled: boolean
}
const ensureJobPreviewStore = () => {
if (!jobPreviewStoreMock) {
jobPreviewStoreMock = reactive({
previewsByPromptId: {} as Record<string, string>,
isPreviewEnabled: true
})
}
return jobPreviewStoreMock
}
vi.mock('@/stores/jobPreviewStore', () => ({
useJobPreviewStore: () => {
return ensureJobPreviewStore()
}
}))
let workflowStoreMock: {
activeWorkflow: null | { activeState?: { id?: string } }
}
@@ -186,6 +205,10 @@ const resetStores = () => {
executionStore.activePromptId = null
executionStore.executingNode = null
const jobPreviewStore = ensureJobPreviewStore()
jobPreviewStore.previewsByPromptId = {}
jobPreviewStore.isPreviewEnabled = true
const workflowStore = ensureWorkflowStore()
workflowStore.activeWorkflow = null
@@ -437,6 +460,44 @@ describe('useJobList', () => {
expect(otherJob.computeHours).toBeCloseTo(1)
})
it('assigns preview urls for running jobs when previews enabled', async () => {
queueStoreMock.runningTasks = [
createTask({
promptId: 'live-preview',
queueIndex: 1,
mockState: 'running'
})
]
jobPreviewStoreMock.previewsByPromptId = {
'live-preview': 'blob:preview-url'
}
jobPreviewStoreMock.isPreviewEnabled = true
const { jobItems } = initComposable()
await flush()
expect(jobItems.value[0].iconImageUrl).toBe('blob:preview-url')
})
it('omits preview urls when previews are disabled', async () => {
queueStoreMock.runningTasks = [
createTask({
promptId: 'disabled-preview',
queueIndex: 1,
mockState: 'running'
})
]
jobPreviewStoreMock.previewsByPromptId = {
'disabled-preview': 'blob:preview-url'
}
jobPreviewStoreMock.isPreviewEnabled = false
const { jobItems } = initComposable()
await flush()
expect(jobItems.value[0].iconImageUrl).toBeUndefined()
})
it('derives current node name from execution store fallbacks', async () => {
const instance = initComposable()
await flush()

View File

@@ -7,6 +7,7 @@ import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import { useQueueStore } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'
@@ -96,6 +97,7 @@ export function useJobList() {
const { t, locale } = useI18n()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const jobPreviewStore = useJobPreviewStore()
const workflowStore = useWorkflowStore()
const seenPendingIds = ref<Set<string>>(new Set())
@@ -256,6 +258,11 @@ export function useJobList() {
String(task.promptId ?? '') ===
String(executionStore.activePromptId ?? '')
const showAddedHint = shouldShowAddedHint(task, state)
const promptKey = taskIdToKey(task.promptId)
const promptPreviewUrl =
state === 'running' && jobPreviewStore.isPreviewEnabled && promptKey
? jobPreviewStore.previewsByPromptId[promptKey]
: undefined
const display = buildJobDisplay(task, state, {
t,
@@ -275,7 +282,7 @@ export function useJobList() {
meta: display.secondary,
state,
iconName: display.iconName,
iconImageUrl: display.iconImageUrl,
iconImageUrl: promptPreviewUrl ?? display.iconImageUrl,
showClear: display.showClear,
taskRef: task,
progressTotalPercent:

View File

@@ -1,5 +1,5 @@
import type { MenuItem } from 'primevue/menuitem'
import { ref } from 'vue'
import { shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -8,12 +8,14 @@ import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
* Use this to handle folder operations in a tree.
* @param expandNode - The function to expand a node.
*/
export function useTreeFolderOperations(
expandNode: (node: RenderedTreeExplorerNode) => void
export function useTreeFolderOperations<T>(
expandNode: (node: RenderedTreeExplorerNode<T>) => void
) {
const { t } = useI18n()
const newFolderNode = ref<RenderedTreeExplorerNode | null>(null)
const addFolderTargetNode = ref<RenderedTreeExplorerNode | null>(null)
const newFolderNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
const addFolderTargetNode = shallowRef<RenderedTreeExplorerNode<T> | null>(
null
)
// Generate a unique temporary key for the new folder
const generateTempKey = (parentKey: string) => {
@@ -37,7 +39,7 @@ export function useTreeFolderOperations(
* The command to add a folder to a node via the context menu
* @param targetNode - The node where the folder will be added under
*/
const addFolderCommand = (targetNode: RenderedTreeExplorerNode) => {
const addFolderCommand = (targetNode: RenderedTreeExplorerNode<T>) => {
expandNode(targetNode)
newFolderNode.value = {
key: generateTempKey(targetNode.key),
@@ -49,13 +51,13 @@ export function useTreeFolderOperations(
totalLeaves: 0,
badgeText: '',
isEditingLabel: true
}
} as RenderedTreeExplorerNode<T>
addFolderTargetNode.value = targetNode
}
// Generate the "Add Folder" menu item
const getAddFolderMenuItem = (
targetNode: RenderedTreeExplorerNode | null
targetNode: RenderedTreeExplorerNode<T> | null
): MenuItem => {
return {
label: t('g.newFolder'),

View File

@@ -197,6 +197,7 @@ describe('useCoreCommands', () => {
addSetting: vi.fn(),
load: vi.fn(),
set: vi.fn(),
setMany: vi.fn(),
exists: vi.fn(),
getDefaultValue: vi.fn(),
isReady: true,

View File

@@ -35,6 +35,7 @@ import {
} from '@/renderer/core/canvas/canvasStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyCommand } from '@/stores/commandStore'
@@ -73,6 +74,7 @@ export function useCoreCommands(): ComfyCommand[] {
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const colorPaletteStore = useColorPaletteStore()
const firebaseAuthActions = useFirebaseAuthActions()
@@ -582,7 +584,7 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: '1.3.7',
category: 'view-controls' as const,
function: () => {
void dialogService.showSettingsDialog()
settingsDialog.show()
}
},
{
@@ -831,7 +833,7 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'About ComfyUI',
versionAdded: '1.6.4',
function: () => {
void dialogService.showSettingsDialog('about')
settingsDialog.showAbout()
}
},
{

View File

@@ -61,7 +61,7 @@ export function useFeatureFlags() {
get onboardingSurveyEnabled() {
return (
remoteConfig.value.onboarding_survey_enabled ??
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true)
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
)
},
get linearToggleEnabled() {

View File

@@ -219,14 +219,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
return modelPath
}
const [subfolder, filename] = Load3dUtils.splitFilePath(modelPath)
return api.apiURL(
Load3dUtils.getResourceURL(
subfolder,
filename,
isPreview.value ? 'output' : 'input'
)
)
const trimmed = modelPath.trim()
const hasOutputSuffix = trimmed.endsWith('[output]')
const cleanPath = hasOutputSuffix
? trimmed.replace(/\s*\[output\]$/, '')
: trimmed
const type = hasOutputSuffix || isPreview.value ? 'output' : 'input'
const [subfolder, filename] = Load3dUtils.splitFilePath(cleanPath)
return api.apiURL(Load3dUtils.getResourceURL(subfolder, filename, type))
} catch (error) {
console.error('Failed to construct model URL:', error)
return null

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