Compare commits

...

16 Commits

Author SHA1 Message Date
jaeone94
ad4d42a7b0 test: add e2e tests for subgraph promoted widget panel regressions
Add Playwright tests that expose three regressions in the subgraph
promoted widget panel introduced by the proxy-widget-v2 refactor:

- Linked promoted widgets (backed by subgraph input slots) show an
  enabled hide toggle in SubgraphEditor instead of a disabled link icon
- Linked promoted widgets expose Hide/Show input in the WidgetActions
  three-dot menu on the Parameters tab
- Widget labels in SubgraphEditor display raw widget.name instead of
  the renamed label from the subgraph input slot

Also adds data-testid attributes to SubgraphEditor, SubgraphNodeWidget,
and RightSidePanel for stable test selectors.
2026-03-25 15:02:16 +09:00
Terry Jia
fe1fae4de1 fix: disable preload error toast triggered by third-party plugin failures (#10445)
## Summary
The preload error toast fires whenever any custom node extension fails
to load via dynamic `import()`. In practice, this is almost always
caused by third-party plugin bugs rather than ComfyUI core issues.
Common triggers include:

- Bare module specifiers (e.g., `import from "vue"`) that the browser
cannot resolve without an import map
- Incorrect relative paths to `scripts/app.js` due to nested web
directory structures
  - Missing dependencies on other extensions (e.g., `clipspace.js`)

Since many users have multiple custom nodes installed, the toast
frequently appears on startup — sometimes multiple times — with a
generic message that offers no actionable guidance. This creates
unnecessary alarm and support burden.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10445-fix-disable-preload-error-toast-triggered-by-third-party-plugin-failures-32d6d73d365081f281efcd6fe90642a5)
by [Unito](https://www.unito.io)
2026-03-24 13:34:34 -04:00
Johnpaul Chiwetelu
38aad02fb9 test: add e2e tests for all default keybindings (#10440)
## Summary
- Add 21 Playwright E2E tests covering all previously untested default
keybindings
- Add `isReadOnly()` and `getOffset()` helpers to `CanvasHelper`

### Tests added
| Group | Keybindings | Count |
|-------|------------|-------|
| Sidebar toggles | `w`, `n`, `m`, `a` | 4 |
| Canvas controls | `Alt+=`, `Alt+-`, `.`, `h`, `v` | 5 |
| Node state | `Alt+c`, `Ctrl+m` | 2 |
| Mode/panel toggles | `Alt+m`, `Alt+Shift+m`, `` Ctrl+` `` | 3 |
| Queue/execution | `Ctrl+Enter`, `Ctrl+Shift+Enter`, `Ctrl+Alt+Enter` |
3 |
| File operations | `Ctrl+s`, `Ctrl+o` | 2 |
| Graph operations | `Ctrl+Shift+e`, `r` | 2 |

## Test plan
- [x] `pnpm exec playwright test defaultKeybindings.spec.ts` — 21/21
pass
- [x] `pnpm typecheck:browser` — clean
- [x] `pnpm lint` — clean

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10440-test-add-e2e-tests-for-all-default-keybindings-32d6d73d36508124a510d4742f9fbdbb)
by [Unito](https://www.unito.io)
2026-03-24 16:12:59 +01:00
pythongosssss
7c4234cf93 feat: App mode - add execution status messages (#10369)
## Summary

Adds custom status messages that are shown under the previews in order
to provide additional progress feedback to the user

Nodes matching the words:

Save, Preview -> Saving
Load, Loader -> Loading
Encode -> Encoding
Decode -> Decoding
Compile, Conditioning, Merge, -> Processing
Upscale, Resize -> Resizing
ToVideo -> Generating video

Specific nodes:
KSampler, KSamplerAdvanced, SamplerCustom, SamplerCustomAdvanced ->
Generating
Video Slice, GetVideoComponents, CreateVideo -> Processing video
TrainLoraNode -> Training


## Changes

- **What**: 
- add specific node lookups for non-easily matchable patterns
- add regex based matching for common patterns
- show on both latent preview & skeleton preview 
- allow app mode workflow authors to override status with custom
property `Execution Message` (no UI for doing this)

## Review Focus

This is purely pattern/lookup based, in future we could update the
backend node schema to allow nodes to define their own status key.

## Screenshots (if applicable)

<img width="757" height="461" alt="image"
src="https://github.com/user-attachments/assets/2b32cc54-c4e7-4aeb-912d-b39ac8428be7"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10369-feat-App-mode-add-execution-status-messages-32a6d73d3650814e8ca2da5eb33f3b65)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-24 07:58:35 -07:00
jaeone94
4bfc730a0e fix: restore workflow tabs on browser restart (#10336)
## Problem

Since PR #8520 (`feat(persistence): fix QuotaExceededError and
cross-workspace draft leakage`), all workflow tabs are lost when the
browser is closed and reopened.

PR #8520 moved tab pointers (`ActivePath`, `OpenPaths`) from
`localStorage` to `sessionStorage` for per-tab isolation. However,
`sessionStorage` is cleared when the browser closes, so the open tab
list is lost on restart. The draft data itself survives in
`localStorage` — only the pointers to which tabs were open are lost.

Reported in
[Comfy-Org/ComfyUI#12984](https://github.com/Comfy-Org/ComfyUI/issues/12984).
Confirmed via binary search: v1.40.9 (last good) → v1.40.10 (first bad).

## Changes

Dual-write tab pointers to both storage layers:

- **sessionStorage** (scoped by `clientId`) — used for in-session
refresh, preserves per-tab isolation
- **localStorage** (scoped by `workspaceId`) — fallback for browser
restart when sessionStorage is empty

Also adds:
- `storageAvailable` guard on write functions for consistency with
`writeIndex`/`writePayload`
- `isValidPointer` validation on localStorage reads to reject stale or
malformed data

## Benefits

- Workflow tabs survive browser restart (restores V1 behavior)
- Per-tab isolation is preserved for in-session use (sessionStorage is
still preferred when available)

## Trade-offs

- On browser restart, the restored tabs come from whichever browser tab
wrote last to localStorage. If Tab A had workflows 1,2,3 and Tab B had
4,5 — the user gets whichever tab wrote most recently. This is the same
limitation V1 had with `Comfy.OpenWorkflowsPaths` in localStorage.
- Previously (post-#8520), opening a new browser tab would only restore
the single most recent draft. With this fix, a new tab restores the full
set of open tabs from the last session. This may be surprising for
multi-tab users who expect a clean slate in new tabs.

## Test plan

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] All 121 persistence tests pass
- [x] Manual: open multiple workflow tabs → close browser → reopen →
tabs restored
- [x] Manual: open two browser tabs with different workflows → refresh
each → correct tabs in each

Fixes Comfy-Org/ComfyUI#12984

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10336-fix-restore-workflow-tabs-on-browser-restart-3296d73d365081b7a7d3e91427d08d17)
by [Unito](https://www.unito.io)
<!-- QA_REPORT_SECTION -->
---
## 🔍 Automated QA Report

| | |
|---|---|
| **Status** |  Complete |
| **Report** |
[sno-qa-10336.comfy-qa.pages.dev](https://sno-qa-10336.comfy-qa.pages.dev/)
|
| **CI Run** | [View
workflow](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/23373697656)
|

Before/after video recordings with **Behavior Changes** and **Timeline
Comparison** tables.
2026-03-24 17:30:36 +09:00
Yourz
001916edf6 refactor: clean up essentials node organization logic (#10433)
## Summary

Refactor essentials tab node organization to eliminate duplicated logic
and restrict essentials to core nodes only.

## Changes

- **What**: 
- Extract `resolveEssentialsCategory` to centralize category resolution
(was duplicated between filter and pathExtractor).
- Add `isCoreNode` guard so third-party nodes never appear in
essentials.
- Replace `indexOf`-based sorting with precomputed rank maps
(`ESSENTIALS_CATEGORY_RANK`, `ESSENTIALS_NODE_RANK`).

<img width="589" height="769" alt="image"
src="https://github.com/user-attachments/assets/66f41f35-aef5-4e12-97d5-0f33baf0ac45"
/>


## Review Focus

- The `isCoreNode` guard in `resolveEssentialsCategory` — ensures only
core nodes can appear in essentials even if a custom node sets
`essentials_category`.
- Rank map precomputation vs previous `indexOf` — functionally
equivalent but O(1) lookup.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10433-refactor-clean-up-essentials-node-organization-logic-32d6d73d36508193a4d1f7f9c18fcef7)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-24 16:22:09 +08:00
jaeone94
66daa6d645 refactor: error system cleanup — store separation, DDD fix, test improvements (#10302)
## Summary

Refactors the error system to improve separation of concerns, fix DDD
layer violations, and address code quality issues.

- Extract `missingNodesErrorStore` from `executionErrorStore`, removing
the delegation pattern that coupled missing-node logic into the
execution error store
- Extract `useNodeErrorFlagSync` composable for node error flag
reconciliation (previously inlined)
- Extract `useErrorClearingHooks` composable with explicit callback
cleanup on node removal
- Extract `useErrorActions` composable to deduplicate telemetry+command
patterns across error card components
- Move `getCnrIdFromNode`/`getCnrIdFromProperties` to
`platform/nodeReplacement` layer (DDD fix)
- Move `missingNodesErrorStore` to `platform/nodeReplacement` (DDD
alignment)
- Add unmount cancellation guard to `useErrorReport` async `onMounted`
- Return watch stop handle from `useNodeErrorFlagSync`
- Add `asyncResolvedIds` eviction on `missingNodesError` reset
- Add `console.warn` to silent catch blocks and empty array guard
- Hoist `useCommandStore` to setup scope, fix floating promises
- Add `data-testid` to error groups, image/video error spans, copy
button
- Update E2E tests to use scoped locators and testids
- Add unit tests for `onNodeRemoved` restoration and double-install
guard

Fixes #9875, Fixes #10027, Fixes #10033, Fixes #10085

## Test plan

- [x] Existing unit tests pass with updated imports and mocks
- [x] New unit tests for `useErrorClearingHooks` (callback restoration,
double-install guard)
- [x] E2E tests updated to use scoped locators and `data-testid`
- [ ] Manual: verify error tab shows runtime errors and missing nodes
correctly
- [ ] Manual: verify "Find on GitHub", "Copy", and "Get Help" buttons
work in error cards

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10302-refactor-error-system-cleanup-store-separation-DDD-fix-test-improvements-3286d73d365081838279d045b8dd957a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-24 16:43:22 +09:00
jaeone94
32a53eeaee fix: sync advanced inputs button color with node header (#10427)
## Summary
- The "Show Advanced Inputs" footer button was missing `headerColor`
style binding, causing it to not sync with the node header color (unlike
the "Enter Subgraph" button which already had it)
- Extracted the repeated `{ backgroundColor: headerColor }` inline style
(4 occurrences) into a `headerColorStyle` computed

## Screenshots 
before 
<img width="211" height="286" alt="스크린샷 2026-03-24 154312"
src="https://github.com/user-attachments/assets/edfd9480-04fa-4cd4-813d-a95adffbe2d3"
/>

after 
<img width="261" height="333" alt="스크린샷 2026-03-24 154622"
src="https://github.com/user-attachments/assets/eab28717-889e-4a6b-8775-bfc08fa727ff"
/>

## Test plan
- [x] Set a custom color on a node with advanced inputs and verify the
footer button matches the header color
- [x] Verify subgraph enter button still syncs correctly
- [x] Verify dual-tab layouts (error + advanced, error + subgraph) both
show correct colors

### Why no E2E test
Node header color is applied as an inline style via `headerColor` prop,
which is already passed and tested through the existing subgraph enter
button path. This change simply extends the same binding to the advanced
inputs buttons — no new data flow or interaction is introduced, so a
screenshot-based E2E test would add maintenance cost without meaningful
regression coverage.
2026-03-24 16:00:55 +09:00
Jin Yi
7c6ab19484 fix: manager progress toast and install button UX issues (#10423)
## Changes

- Reset `isRestartCompleted` in `closeToast()` so the "Apply Changes"
button appears correctly on subsequent installs instead of skipping to
the success message
- Add `@click.stop` on `PackInstallButton` to prevent click from
bubbling up to card selection, which was unintentionally opening the
right info panel

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10423-fix-manager-progress-toast-and-install-button-UX-issues-32d6d73d365081eba2c6d5b9b35ab26e)
by [Unito](https://www.unito.io)
2026-03-23 21:57:16 -07:00
Dante
8f9fe3b21e refactor: rebuild SingleSelect and MultiSelect with Reka UI (#9742)
## Summary
- Rebuild `SingleSelect` with Reka UI Select primitives (SelectRoot,
SelectTrigger, SelectContent, SelectItem)
- Rebuild `MultiSelect` with Reka UI Popover + Listbox (PopoverRoot,
ListboxRoot with `multiple`)
- Remove PrimeVue dependency from both components
- Preserve existing API contract for all consumers

## TODO
- [x] Re-evaluate MultiSelect implementation (ComboboxRoot with
`multiple` may be cleaner than Popover+Listbox)
- [x] E2E verification in actual app (AssetFilterBar,
WorkflowTemplateSelectorDialog, etc.)

## Test plan
- [x] Storybook visual verification for all SingleSelect/MultiSelect
stories
- [x] Keyboard navigation (arrow keys, Enter, Escape, typeahead)
- [x] Multi-selection with badge count
- [x] Search filtering in MultiSelect
- [x] Clear all functionality
- [x] Disabled state
- [x] Invalid state (SingleSelect)
- [x] Loading state (SingleSelect)

## screenshot
<img width="519" height="475" alt="스크린샷 2026-03-20 오전 12 12 37"
src="https://github.com/user-attachments/assets/ffc7f0b0-c88c-486b-a253-73a4da73c1de"
/>
<img width="842" height="554" alt="스크린샷 2026-03-20 오전 12 23 51"
src="https://github.com/user-attachments/assets/410551d4-c843-4898-b305-13a6ad6978ca"
/>

## video


https://github.com/user-attachments/assets/2fc3a9b9-2671-4c2c-9f54-4f83598afb53



Fixes #9700

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9742-refactor-rebuild-SingleSelect-and-MultiSelect-with-Reka-UI-3206d73d36508113bee2cf160c8f2d50)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-24 04:39:20 +00:00
Dante
4411d9a417 refactor: extract shared click-vs-drag guard utility (#10357)
## Summary

Extract duplicated click-vs-drag detection logic into a shared
`useClickDragGuard` composable and `exceedsClickThreshold` pure utility
function.

## Changes

- **What**: New `useClickDragGuard(threshold)` composable in
`src/composables/useClickDragGuard.ts` that stores pointer start
position and checks squared distance against a threshold. Also exports
`exceedsClickThreshold` for non-Vue contexts.
- Migrated `DropZone.vue`, `useNodePointerInteractions.ts`, and
`Load3d.ts` to use the shared utility
- `CanvasPointer.ts` left as-is (LiteGraph internal)
- All consumers now use squared-distance comparison (no `Math.sqrt` or
per-axis `Math.abs`)

## Review Focus

- The composable uses plain `let` state instead of `ref` since
reactivity is not needed for the start position
- `Load3d.ts` uses the pure `exceedsClickThreshold` function directly
since it is a class, not a Vue component
- Threshold values preserved per-consumer: DropZone=5,
useNodePointerInteractions=3, Load3d=5

Fixes #10356

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10357-refactor-extract-shared-click-vs-drag-guard-utility-32a6d73d3650816e83f5cb89872fb184)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-24 12:27:42 +09:00
Jin Yi
98d56bdada fix: improve settings dialog UX (#10396)
## Summary

Reduce settings dialog size and autofocus search input for better
usability.

## Changes

- **What**: Reduce dialog size from `md` to `sm` (max-width 1400px →
960px); autofocus search input on open

## Review Focus

User feedback indicated the settings dialog was too wide and search
required an extra click to focus.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10396-fix-improve-settings-dialog-UX-32c6d73d365081e29eceed55afde1967)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-23 18:45:40 -07:00
Christian Byrne
b4bb6d9d13 feat: add grid view mode for multi-image batches in ImagePreview (#9241)
## Summary

Add grid view mode for multi-image batches in ImagePreview (Nodes 2.0),
replicating the Nodes 1.0 grid UX where all output images are visible as
clickable thumbnails.

## Changes

- **What**: Multi-image batches now default to a grid view showing all
thumbnails. Clicking a thumbnail switches to gallery mode for that
image. A persistent back-to-grid button sits next to navigation dots,
and hover action bars provide gallery toggle, download, and remove.
Replaced PrimeVue `Skeleton` with shadcn `Skeleton`. Added `viewGrid`,
`viewGallery`, `imageCount`, `galleryThumbnail` i18n keys.

## Review Focus

- Grid column count strategy: fixed breakpoints (2 cols ≤4, 3 cols ≤9, 4
cols 10+) vs CSS auto-fill
- Default view mode: grid for multi-image, gallery for single — matches
Nodes 1.0 behavior
- `object-contain` on thumbnails to avoid cropping (with `aspect-square`
containers for uniform cells)

Fixes #9162

<!-- Pipeline-Ticket: f8f8effa-adff-4ede-b1d3-3c4f04b9c4a0 -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9241-feat-add-grid-view-mode-for-multi-image-batches-in-ImagePreview-3136d73d36508166895ed6c635150434)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Alexander Brown <448862+DrJKL@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-24 09:49:44 +09:00
Matt Miller
6a9fb4e1d5 fix: show clear error dialog for 403 whitelist failures (#10402)
## Summary
- When the cloud backend returns a 403 (user not whitelisted), the
frontend showed a generic "Prompt Execution Error" dialog with a cryptic
message
- Now catches 403 responses specifically and shows an "Access
Restricted" dialog with the backend's actual error message
- Adds `status` field to `PromptExecutionError` so error handlers can
distinguish HTTP status codes

## Changes
- `api.ts`: Added optional `status` to `PromptExecutionError`, pass
`res.status` from `queuePrompt`
- `app.ts`: New `else if` branch in the prompt error handler for `status
=== 403` — shows "Access Restricted" with the backend message

## Backwards compatible
- **Old backend** (`"not authorized"`): Shows "Access Restricted: not
authorized"
- **New backend**
([cloud#2941](https://github.com/Comfy-Org/cloud/pull/2941), `"your
account is not whitelisted for this feature"`): Shows "Access
Restricted: your account is not whitelisted for this feature"
- No behavior change for non-403 errors

## Related
- Backend fix: Comfy-Org/cloud#2941
- Notion: COM-16179

## Test plan
- [ ] Submit a prompt as a non-whitelisted user → should see "Access
Restricted" dialog with clear message
- [ ] Submit a prompt as a whitelisted user → no change in behavior
- [ ] Submit a prompt that fails for other reasons (missing nodes, etc.)
→ existing error handling unchanged

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10402-fix-show-clear-error-dialog-for-403-whitelist-failures-32c6d73d365081eb9528d7feac4e8681)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Matt Miller <mattmiller@Matts-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:57:12 +00:00
Deep Mehta
2838edbb53 feat: send missing node data to ClickHouse (#10132)
## Summary
- When users open/import a workflow with missing nodes, we already track
this via Mixpanel
- This adds a parallel fire-and-forget POST to
`/api/internal/cloud_analytics` so the data also lands in **ClickHouse**
as `frontend:missing_nodes_detected` events
- Payload includes `missing_class_types[]`, `missing_count`, and
`source` (file_button/file_drop/template/unknown)

## Motivation
The frontend is where the **high-value** missing node signal lives —
most users see "missing nodes" and never submit. The backend only
catches the rare case where someone submits anyway. This change captures
both sides.

Companion cloud PR: https://github.com/Comfy-Org/cloud/pull/2886

## Changes
- `MixpanelTelemetryProvider.ts`: Added
`reportMissingNodesToClickHouse()` private method, called from
`trackWorkflowImported()` and `trackWorkflowOpened()`
- Only fires when `missing_node_count > 0`
- Fire-and-forget (`.catch(() => {})`) — no impact on user experience
- Uses existing `api.fetchApi()` which handles auth automatically

## Test plan
- [ ] Open a workflow with missing nodes → verify
`frontend:missing_nodes_detected` event appears in ClickHouse
- [ ] Open a workflow with no missing nodes → verify no event is sent
(check network tab)
- [ ] Verify Mixpanel tracking still works as before

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10132-feat-send-missing-node-data-to-ClickHouse-3266d73d365081559db5ed3efde33e95)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-23 15:39:04 -07:00
Arthur R Longbottom
657ae6a6c3 fix: subgraph promoted widget input label rename (#10195)
## Summary

Promoted primitive subgraph inputs (String, Int) render their link
anchor at the header position instead of the widget row. Renaming
subgraph input labels breaks the match entirely, causing connections to
detach from their widgets visually.

## Changes

- **What**: Fix widget-input slot positioning for promoted subgraph
inputs in both LiteGraph and Vue (Nodes 2.0) rendering modes
- `_arrangeWidgetInputSlots`: Removed Vue mode branch that skipped
setting `input.pos`. Promoted widget inputs aren't rendered as
`<InputSlot>` Vue components (NodeSlots filters them out), so
`input.pos` is the only position fallback
- `drawConnections`: Added pre-pass to arrange nodes with unpositioned
widget-input slots before link rendering. The background canvas renders
before the foreground canvas calls `arrange()`, so positions weren't set
on the first frame
- `SubgraphNode`: Sync `input.widget.name` with the display name on
label rename and initial setup. The `IWidgetLocator` name diverged from
`PromotedWidgetView.name` after rename, breaking all name-based
slot↔widget matching (`_arrangeWidgetInputSlots`, `getWidgetFromSlot`,
`getSlotFromWidget`)

## Review Focus

- The `_arrangeWidgetInputSlots` rewrite iterates `_concreteInputs`
directly instead of building a spread-copy map — simpler and avoids the
stale index issue
- `input.widget.name` is now kept in sync with the display name
(`input.label ?? subgraphInput.name`). This is a semantic shift from
using the raw internal name, but it's required for all name-based
matching to work after renames. The value is overwritten on deserialize
by `_setWidget` anyway
- The `_widget` fallback in `_arrangeWidgetInputSlots` is a safety net
for edge cases where the name still doesn't match (e.g., stale cache)

Fixes #9998

## Screenshots
<img width="847" height="476" alt="Screenshot 2026-03-17 at 3 05 32 PM"
src="https://github.com/user-attachments/assets/38f10563-f0bc-44dd-a1a5-f4a7832575d0"
/>
<img width="804" height="471" alt="Screenshot 2026-03-17 at 3 05 23 PM"
src="https://github.com/user-attachments/assets/3237a7ee-f3e5-4084-b330-371def3415bd"
/>
<img width="974" height="571" alt="Screenshot 2026-03-17 at 3 05 16 PM"
src="https://github.com/user-attachments/assets/cafdca46-8d9b-40e1-8561-02cbb25ee8f2"
/>
<img width="967" height="558" alt="Screenshot 2026-03-17 at 3 05 06 PM"
src="https://github.com/user-attachments/assets/fc03ce43-906c-474d-b3bc-ddf08eb37c75"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10195-fix-subgraph-promoted-widget-input-slot-positions-after-label-rename-3266d73d365081dfa623dd94dd87c718)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jaeone94 <jaeone.prt@gmail.com>
2026-03-23 14:33:03 -07:00
104 changed files with 4328 additions and 1422 deletions

View File

@@ -0,0 +1,407 @@
{
"id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7",
"revision": 0,
"last_node_id": 19,
"last_link_id": 24,
"nodes": [
{
"id": 14,
"type": "CLIPLoader",
"pos": [143.16716182216328, 290.16372862874033],
"size": [270, 117.3125],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"links": [21]
}
],
"properties": {
"Node name for S&R": "CLIPLoader"
},
"widgets_values": [null, "stable_diffusion", "default"]
},
{
"id": 18,
"type": "PreviewImage",
"pos": [1305.1455526601603, 472.17095792625025],
"size": [225, 48],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 24
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 19,
"type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"pos": [794.198171390827, 452.45433419677147],
"size": [225, 172],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"label": "renamed_clip",
"name": "clip",
"type": "CLIP",
"link": 21
},
{
"label": "renamed_seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 22
},
{
"label": "renamed_vae",
"name": "vae",
"type": "VAE",
"link": 23
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [24]
}
],
"title": "Input Test Subgraph",
"properties": {
"proxyWidgets": [
["12", "seed"],
["15", "text"]
]
},
"widgets_values": []
},
{
"id": 13,
"type": "PrimitiveInt",
"pos": [155.04048166054417, 773.3816055422594],
"size": [270, 82],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [22]
}
],
"title": "Seed Int",
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 17,
"type": "VAELoader",
"pos": [163.6043676075426, 543.9624492717659],
"size": [270, 82.65625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"links": [23]
}
],
"properties": {
"Node name for S&R": "VAELoader"
},
"widgets_values": ["pixel_space"]
}
],
"links": [
[21, 14, 0, 19, 0, "CLIP"],
[22, 13, 0, 19, 1, "INT"],
[23, 17, 0, 19, 2, "VAE"],
[24, 19, 0, 18, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 19,
"lastLinkId": 24,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Input Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [
358.8694807105848, 439.23932667242485, 123.14453125,
99.99999999999994
]
},
"outputNode": {
"id": -20,
"bounding": [1408.5510580294986, 463.2512895126797, 120, 60]
},
"inputs": [
{
"id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c",
"name": "clip",
"type": "CLIP",
"linkIds": [16],
"localized_name": "clip",
"label": "renamed_clip",
"pos": [462.0140119605848, 459.23932667242485]
},
{
"id": "2e4600ea-e1b1-42ca-b43a-e066fd080774",
"name": "seed",
"type": "INT",
"linkIds": [15],
"localized_name": "seed",
"label": "renamed_seed",
"pos": [462.0140119605848, 479.23932667242485]
},
{
"id": "86ed2da7-db02-454a-9362-70a3fa3e91bf",
"name": "vae",
"type": "VAE",
"linkIds": [19],
"localized_name": "vae",
"label": "renamed_vae",
"pos": [462.0140119605848, 499.23932667242485]
}
],
"outputs": [
{
"id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [20],
"localized_name": "IMAGE",
"pos": [1428.5510580294986, 483.2512895126797]
}
],
"widgets": [],
"nodes": [
{
"id": 12,
"type": "KSampler",
"pos": [769.2424728654022, 512.726159169824],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 17
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [18]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 16,
"type": "VAEDecode",
"pos": [1208.5510580294986, 469.21581253470083],
"size": [140, 46],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 18
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 19
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [20]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 15,
"type": "CLIPTextEncode",
"pos": [681.4596332342014, 243.17567172890932],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 16
},
{
"label": "renamed_from_sidepanel",
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [17]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 17,
"origin_id": 15,
"origin_slot": 0,
"target_id": 12,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 18,
"origin_id": 12,
"origin_slot": 0,
"target_id": 16,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 0,
"target_id": 15,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 12,
"target_slot": 4,
"type": "INT"
},
{
"id": 19,
"origin_id": -10,
"origin_slot": 2,
"target_id": 16,
"target_slot": 1,
"type": "VAE"
},
{
"id": 20,
"origin_id": 16,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6727925600199565,
"offset": [446.69747171876463, 99.95078257277316]
}
},
"version": 0.4
}

View File

@@ -91,6 +91,12 @@ export class CanvasHelper {
await this.page.mouse.move(10, 10)
}
async isReadOnly(): Promise<boolean> {
return this.page.evaluate(() => {
return window.app!.canvas.state.readOnly
})
}
async getScale(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.ds.scale

View File

@@ -28,10 +28,15 @@ export const TestIds = {
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
about: 'about-panel',
whatsNewSection: 'whats-new-section'
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -47,6 +52,13 @@ export const TestIds = {
propertiesPanel: {
root: 'properties-panel'
},
subgraphEditor: {
toggle: 'subgraph-editor-toggle',
shownSection: 'subgraph-editor-shown-section',
hiddenSection: 'subgraph-editor-hidden-section',
widgetToggle: 'subgraph-widget-toggle',
widgetLabel: 'subgraph-widget-label'
},
node: {
titleInput: 'node-title-input'
},
@@ -76,6 +88,10 @@ export const TestIds = {
},
user: {
currentUserIndicator: 'current-user-indicator'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
}
} as const
@@ -101,3 +117,4 @@ export type TestIdValue =
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -1,3 +1,5 @@
import type { Page } from '@playwright/test'
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
import { isSubgraph } from '../../src/utils/typeGuardUtil'
@@ -14,3 +16,30 @@ export function assertSubgraph(
)
}
}
/**
* Returns the widget-input slot Y position and the node title height
* for the promoted "text" input on the SubgraphNode.
*
* The slot Y should be at the widget row, not the header. A value near
* zero or negative indicates the slot is positioned at the header (the bug).
*/
export function getTextSlotPosition(page: Page, nodeId: string) {
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
for (const input of node.inputs) {
if (!input.widget || input.type !== 'STRING') continue
return {
hasPos: !!input.pos,
posY: input.pos?.[1] ?? null,
widgetName: input.widget.name,
titleHeight
}
}
return null
}, nodeId)
}

View File

@@ -0,0 +1,318 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
async function pressKeyAndExpectRequest(
comfyPage: ComfyPage,
key: string,
urlPattern: string,
method: string = 'POST'
) {
const requestPromise = comfyPage.page.waitForRequest(
(req) => req.url().includes(urlPattern) && req.method() === method,
{ timeout: 5000 }
)
await comfyPage.page.keyboard.press(key)
return requestPromise
}
test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test.describe('Sidebar Toggle Shortcuts', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
})
const sidebarTabs = [
{ key: 'KeyW', tabId: 'workflows', label: 'workflows' },
{ key: 'KeyN', tabId: 'node-library', label: 'node library' },
{ key: 'KeyM', tabId: 'model-library', label: 'model library' },
{ key: 'KeyA', tabId: 'assets', label: 'assets' }
] as const
for (const { key, tabId, label } of sidebarTabs) {
test(`'${key}' toggles ${label} sidebar`, async ({ comfyPage }) => {
const selectedButton = comfyPage.page.locator(
`.${tabId}-tab-button.side-bar-button-selected`
)
await expect(selectedButton).not.toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).not.toBeVisible()
})
}
})
test.describe('Canvas View Controls', () => {
test("'Alt+=' zooms in", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Equal')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeGreaterThan(initialScale)
})
test("'Alt+-' zooms out", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Minus')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeLessThan(initialScale)
})
test("'.' fits view to nodes", async ({ comfyPage }) => {
// Set scale very small so fit-view will zoom back to fit nodes
await comfyPage.canvasOps.setScale(0.1)
const scaleBefore = await comfyPage.canvasOps.getScale()
expect(scaleBefore).toBeCloseTo(0.1, 1)
// Click canvas to ensure focus is within graph-canvas-container
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
await comfyPage.canvas.press('Period')
await comfyPage.nextFrame()
const scaleAfter = await comfyPage.canvasOps.getScale()
expect(scaleAfter).toBeGreaterThan(scaleBefore)
})
test("'h' locks canvas", async ({ comfyPage }) => {
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
})
test("'v' unlocks canvas", async ({ comfyPage }) => {
// Lock first
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
})
})
test.describe('Node State Toggles', () => {
test("'Alt+c' collapses and expands selected nodes", async ({
comfyPage
}) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(true)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
})
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
// Normal mode is ALWAYS (0)
const getMode = () =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
}, node.id)
expect(await getMode()).toBe(0)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
// NEVER (2) = muted
expect(await getMode()).toBe(2)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
expect(await getMode()).toBe(0)
})
})
test.describe('Mode and Panel Toggles', () => {
test("'Alt+m' toggles app mode", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
// Set up linearData so app mode has something to show
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
// Toggle off with Alt+m
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
// Toggle on again
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test("'Alt+Shift+m' toggles minimap", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.workflow.loadWorkflow('default')
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).toBeVisible()
})
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
})
})
test.describe('Queue and Execution', () => {
test("'Ctrl+Enter' queues prompt", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Enter',
'/prompt',
'POST'
)
expect(request.url()).toContain('/prompt')
})
test("'Ctrl+Shift+Enter' queues prompt to front", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Shift+Enter',
'/prompt',
'POST'
)
const body = request.postDataJSON()
expect(body.front).toBe(true)
})
test("'Ctrl+Alt+Enter' interrupts execution", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Alt+Enter',
'/interrupt',
'POST'
)
expect(request.url()).toContain('/interrupt')
})
})
test.describe('File Operations', () => {
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.page.keyboard.press('Control+s')
await comfyPage.nextFrame()
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible({ timeout: 3000 })
// Dismiss the dialog
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
})
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
// Ctrl+o calls app.ui.loadFile() which clicks a hidden file input.
// Detect the file input click via an event listener.
await comfyPage.page.evaluate(() => {
window.TestCommand = false
const fileInputs =
document.querySelectorAll<HTMLInputElement>('input[type="file"]')
for (const input of fileInputs) {
input.addEventListener('click', () => {
window.TestCommand = true
})
}
})
await comfyPage.page.keyboard.press('Control+o')
await comfyPage.nextFrame()
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
})
})
test.describe('Graph Operations', () => {
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
// Select all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
await comfyPage.nextFrame()
// After conversion, node count should decrease
// (multiple nodes replaced by single subgraph node)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5000
})
.toBeLessThan(initialCount)
})
test("'r' refreshes node definitions", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'KeyR',
'/object_info',
'GET'
)
expect(request.url()).toContain('/object_info')
})
})
})

View File

@@ -28,7 +28,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
})
@@ -42,11 +42,13 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
// Click "See Errors" to open the errors tab and verify subgraph node content
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
const missingNodeCard = comfyPage.page.getByTestId(
@@ -75,7 +77,9 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
await expect(errorOverlay).toBeVisible()
// Click "See Errors" to open the right side panel errors tab
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
// Verify MissingNodeCard is rendered in the errors tab
@@ -165,17 +169,19 @@ test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
// Verify Find on GitHub button is present in the error card
const findOnGithubButton = comfyPage.page.getByRole('button', {
name: 'Find on GitHub'
})
const findOnGithubButton = comfyPage.page.getByTestId(
TestIds.dialogs.errorCardFindOnGithub
)
await expect(findOnGithubButton).toBeVisible()
// Verify Copy button is present in the error card
const copyButton = comfyPage.page.getByRole('button', { name: 'Copy' })
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
await expect(copyButton).toBeVisible()
})
})
@@ -204,7 +210,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -220,7 +226,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -231,13 +237,10 @@ test.describe('Missing models in Error Tab', () => {
'missing/model_metadata_widget_mismatch'
)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).not.toBeVisible()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
await expect(comfyPage.page.getByText(/Missing Models/)).not.toBeVisible()
})
// Flaky test after parallelization

View File

@@ -764,13 +764,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
})
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
let workflowB: string
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -829,6 +829,82 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
})
})
test.describe('Restore workflow tabs after browser restart', () => {
let workflowA: string
let workflowB: string
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage fallback pointers to be written
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
return true
}
}
return false
})
// Simulate browser restart: clear sessionStorage (lost on close)
// but keep localStorage (survives browser restart)
await comfyPage.page.evaluate(() => {
sessionStorage.clear()
})
await comfyPage.setup({ clearStorage: false })
})
test('Restores topbar workflow tabs after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
// Wait for both restored tabs to render (localStorage fallback is async)
await expect(
comfyPage.page.locator('.workflow-tabs .workflow-label', {
hasText: workflowA
})
).toBeVisible()
const tabs = await comfyPage.menu.topbar.getTabNames()
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
})
test('Restores sidebar workflows after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
const openWorkflows =
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowA, workflowB])
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowB)
})
})
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.EnableWorkflowViewRestore',

View File

@@ -0,0 +1,86 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
test.describe(
'Subgraph promoted widget-input slot position',
{ tag: '@subgraph' },
() => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Render a few frames so arrange() runs
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await getTextSlotPosition(comfyPage.page, '11')
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
// The slot Y position should be well below the title area.
// If it's near 0 or negative, the slot is stuck at the header (the bug).
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
// Verify initial position is correct
const before = await getTextSlotPosition(comfyPage.page, '11')
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
// Navigate into subgraph and rename the text input
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(i: { type: string }) => i.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
await comfyPage.page.fill(dialog, '')
await comfyPage.page.fill(dialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify slot position is still at the widget row after rename
const after = await getTextSlotPosition(comfyPage.page, '11')
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
// widget.name is the stable identity key — it does NOT change on rename.
// The display label is on input.label, read via PromotedWidgetView.label.
expect(after!.widgetName).not.toBe('my_custom_prompt')
})
}
)

View File

@@ -0,0 +1,57 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
test.describe(
'Subgraph promoted widget DOM position',
{ tag: '@subgraph' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
// Verify widget is inside the node body, not the header
const headerBox = await nodeLocator
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
// Widget top should be below the header bottom
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
})
}
)

View File

@@ -0,0 +1,117 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
const RENAMED_LABEL = 'my_seed'
/**
* Regression test for subgraph input slot rename propagation.
*
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
* update the promoted widget label shown on the parent SubgraphNode and
* keep the widget positioned in the node body (not the header).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
*/
test.describe(
'Subgraph input slot rename propagation',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
comfyPage
}) => {
const { page } = comfyPage
// 1. Load workflow with subgraph containing a promoted seed widget input
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNode).toBeVisible()
// 2. Verify the seed widget is visible on the parent node
const seedWidget = sgNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
// Verify widget is in the node body, not the header
const headerBox = await sgNode
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
// 3. Enter the subgraph and rename the seed slot.
// The subgraph IO rename uses canvas.prompt() which requires the
// litegraph context menu, so temporarily disable Vue nodes.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await sgNodeRef.navigateIntoSubgraph()
// Find the seed SubgraphInput slot
const seedSlotName = await page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph) return null
const inputs = (
graph as { inputs?: Array<{ name: string; type: string }> }
).inputs
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
})
expect(seedSlotName).not.toBeNull()
// 4. Right-click the seed input slot and rename it
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await page.waitForSelector(dialog, { state: 'visible' })
await page.fill(dialog, '')
await page.fill(dialog, RENAMED_LABEL)
await page.keyboard.press('Enter')
await page.waitForSelector(dialog, { state: 'hidden' })
// 5. Navigate back to parent graph and re-enable Vue nodes
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
// 6. Verify the widget label updated to the renamed value
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNodeAfter).toBeVisible()
const updatedLabel = await page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('19')
if (!node) return null
const w = node.widgets?.find((w: { name: string }) =>
w.name.includes('seed')
)
return w?.label || w?.name || null
})
expect(updatedLabel).toBe(RENAMED_LABEL)
// 7. Verify the widget is still in the body, not the header
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
await expect(seedWidgetAfter).toBeVisible()
const headerAfter = await sgNodeAfter
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetAfter = await seedWidgetAfter.boundingBox()
expect(headerAfter).not.toBeNull()
expect(widgetAfter).not.toBeNull()
expect(widgetAfter!.y).toBeGreaterThan(
headerAfter!.y + headerAfter!.height
)
})
}
)

View File

@@ -0,0 +1,146 @@
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
async function ensurePropertiesPanel(comfyPage: ComfyPage) {
const panel = comfyPage.menu.propertiesPanel.root
if (!(await panel.isVisible())) {
await comfyPage.actionbar.propertiesButton.click()
}
await expect(panel).toBeVisible()
return panel
}
async function selectSubgraphAndOpenEditor(
comfyPage: ComfyPage,
nodeTitle: string
) {
const subgraphNode = (
await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
)[0]
await subgraphNode.click('title')
await ensurePropertiesPanel(comfyPage)
const editorToggle = comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle)
await expect(editorToggle).toBeVisible()
await editorToggle.click()
const shownSection = comfyPage.page.getByTestId(
TestIds.subgraphEditor.shownSection
)
await expect(shownSection).toBeVisible()
return shownSection
}
async function collectWidgetLabels(shownSection: Locator) {
const labels = shownSection.getByTestId(TestIds.subgraphEditor.widgetLabel)
const count = await labels.count()
const texts: string[] = []
for (let i = 0; i < count; i++) {
const text = await labels.nth(i).textContent()
if (text) texts.push(text.trim())
}
return texts
}
test.describe(
'Subgraph promoted widget panel',
{ tag: ['@node', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test.describe('SubgraphEditor (Settings panel)', () => {
test('linked promoted widgets have hide toggle disabled', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
const shownSection = await selectSubgraphAndOpenEditor(
comfyPage,
'Sub 0'
)
const toggleButtons = shownSection.getByTestId(
TestIds.subgraphEditor.widgetToggle
)
const count = await toggleButtons.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
await expect(toggleButtons.nth(i)).toBeDisabled()
}
})
test('linked promoted widgets show link icon instead of eye icon', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
const shownSection = await selectSubgraphAndOpenEditor(
comfyPage,
'Sub 0'
)
const linkIcons = shownSection.locator('.icon-\\[lucide--link\\]')
await expect(linkIcons.first()).toBeVisible()
const eyeIcons = shownSection.locator('.icon-\\[lucide--eye\\]')
await expect(eyeIcons).toHaveCount(0)
})
test('widget labels display renamed values instead of raw names', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/test-values-input-subgraph'
)
const shownSection = await selectSubgraphAndOpenEditor(
comfyPage,
'Input Test Subgraph'
)
const allTexts = await collectWidgetLabels(shownSection)
expect(allTexts.length).toBeGreaterThan(0)
// The fixture has a widget with name="text" but
// label="renamed_from_sidepanel". The panel should show the
// renamed label, not the raw widget name.
expect(allTexts).toContain('renamed_from_sidepanel')
expect(allTexts).not.toContain('text')
})
})
test.describe('Parameters tab (WidgetActions menu)', () => {
test('linked promoted widget menu should not show Hide/Show input', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
const subgraphNode = (
await comfyPage.nodeOps.getNodeRefsByTitle('Sub 0')
)[0]
await subgraphNode.click('title')
const panel = await ensurePropertiesPanel(comfyPage)
const moreButtons = panel.locator('.icon-\\[lucide--more-vertical\\]')
await expect(moreButtons.first()).toBeVisible()
await moreButtons.first().click()
await expect(comfyPage.page.getByText('Hide input')).toHaveCount(0)
await expect(comfyPage.page.getByText('Show input')).toHaveCount(0)
await expect(comfyPage.page.getByText('Rename')).toBeVisible()
})
})
}
)

View File

@@ -206,6 +206,31 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(nav).toBeVisible() // Nav should be visible at tablet size
})
test(
'select components in filter bar render correctly',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Wait for filter bar select components to render
const dialog = comfyPage.page.getByRole('dialog')
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
await expect(sortBySelect).toBeVisible()
// Screenshot the filter bar containing MultiSelect and SingleSelect
const filterBar = sortBySelect.locator(
'xpath=ancestor::div[contains(@class, "justify-between")]'
)
await expect(filterBar).toHaveScreenshot(
'template-filter-bar-select-components.png',
{
mask: [comfyPage.page.locator('.p-toast')]
}
)
}
)
test(
'template cards descriptions adjust height dynamically',
{ tag: '@screenshot' },

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -47,6 +47,46 @@ test.describe('Vue Node Moving', () => {
}
)
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
steps: 5
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
expect(afterPos.x).toBeCloseTo(headerPos.x, 0)
expect(afterPos.y).toBeCloseTo(headerPos.y, 0)
// The small movement should have selected the node, not dragged it
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
test('should move node when pointer moves beyond drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move 50px — well beyond the 3px drag threshold
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 50, headerPos.y + 50, {
steps: 20
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(headerPos, afterPos)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

@@ -2,6 +2,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
test.describe('Vue Upload Widgets', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -19,10 +20,14 @@ test.describe('Vue Upload Widgets', () => {
).not.toBeVisible()
await expect
.poll(() => comfyPage.page.getByText('Error loading image').count())
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count()
)
.toBeGreaterThan(0)
await expect
.poll(() => comfyPage.page.getByText('Error loading video').count())
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count()
)
.toBeGreaterThan(0)
})
})

View File

@@ -9,13 +9,10 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
@@ -23,7 +20,6 @@ import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { useDialogService } from '@/services/dialogService'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
@@ -98,12 +94,17 @@ onMounted(() => {
}
})
}
useToastStore().add({
severity: 'error',
summary: t('g.preloadErrorTitle'),
detail: t('g.preloadError'),
life: 10000
})
// Disabled: Third-party custom node extensions frequently trigger this toast
// (e.g., bare "vue" imports, wrong relative paths to scripts/app.js, missing
// core dependencies). These are plugin bugs, not ComfyUI core failures, but
// the generic error message alarms users and offers no actionable guidance.
// The console.error above still logs the details for developers to debug.
// useToastStore().add({
// severity: 'error',
// summary: t('g.preloadErrorTitle'),
// detail: t('g.preloadError'),
// life: 10000
// })
})
// Capture resource load failures (CSS, scripts) in non-localhost distributions

View File

@@ -19,10 +19,7 @@ const props = defineProps<{
const queryString = computed(() => props.errorMessage + ' is:issue')
/**
* Open GitHub issues search and track telemetry.
*/
const openGitHubIssues = () => {
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
})

View File

@@ -49,7 +49,12 @@
<Button variant="muted-textonly" size="unset" @click="dismiss">
{{ t('g.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="seeErrors">
<Button
variant="secondary"
size="lg"
data-testid="error-overlay-see-errors"
@click="seeErrors"
>
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}

View File

@@ -0,0 +1,60 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import MultiSelect from './MultiSelect.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
multiSelectDropdown: 'Multi-select dropdown',
noResultsFound: 'No results found',
search: 'Search',
clearAll: 'Clear all',
itemsSelected: 'Items selected'
}
}
}
})
describe('MultiSelect', () => {
function createWrapper() {
return mount(MultiSelect, {
attachTo: document.body,
global: {
plugins: [i18n]
},
props: {
modelValue: [],
label: 'Category',
options: [
{ name: 'One', value: 'one' },
{ name: 'Two', value: 'two' }
]
}
})
}
it('keeps open-state border styling available while the dropdown is open', async () => {
const wrapper = createWrapper()
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
expect(trigger.classes()).toContain(
'data-[state=open]:border-node-component-border'
)
expect(trigger.attributes('aria-expanded')).toBe('false')
await trigger.trigger('click')
await nextTick()
expect(trigger.attributes('aria-expanded')).toBe('true')
expect(trigger.attributes('data-state')).toBe('open')
wrapper.unmount()
})
})

View File

@@ -1,207 +1,215 @@
<template>
<!--
Note: Unlike SingleSelect, we don't need an explicit options prop because:
1. Our value template only shows a static label (not dynamic based on selection)
2. We display a count badge instead of actual selected labels
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
option-label="name" is required because our option template directly accesses option.name
max-selected-labels="0" is required to show count badge instead of selected item labels
-->
<MultiSelect
<ComboboxRoot
v-model="selectedItems"
v-bind="{ ...$attrs, options: filteredOptions }"
option-label="name"
unstyled
:max-selected-labels="0"
:pt="{
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'relative inline-flex cursor-pointer select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
'focus-within:border-base-foreground',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
}),
labelContainer: {
class: cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
label: {
class: 'p-0'
},
dropdown: {
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
},
header: () => ({
class:
showSearchBox || showSelectedCount || showClearButton
? 'block'
: 'hidden'
}),
// Overlay & list visuals unchanged
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background',
'text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
class: 'scrollbar-custom'
}),
list: {
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2',
'hover:bg-secondary-background-hover',
// Add focus/highlight state for keyboard navigation
context?.focused &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
pcOptionCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
emptyMessage: {
class: 'px-3 pb-4 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.multiSelectDropdown')"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
multiple
by="value"
:disabled
ignore-filter
:reset-search-term-on-select="false"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"
#header
>
<div class="flex flex-col px-2 pt-2 pb-0">
<SearchInput
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:placeholder="searchPlaceholder"
size="sm"
/>
<div
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div class="my-4 h-px bg-border-default"></div>
</div>
</template>
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
>
{{ selectedCount }}
</span>
</template>
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div
role="button"
class="flex cursor-pointer items-center gap-2"
:style="popoverStyle"
<ComboboxAnchor as-child>
<ComboboxTrigger
v-bind="$attrs"
:aria-label="label || t('g.multiSelectDropdown')"
:class="
cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid border-transparent',
selectedCount > 0
? 'border-base-foreground'
: 'focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
:class="
slotProps.selected
? 'bg-primary-background'
: 'bg-secondary-background'
cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
"
>
<i
v-if="slotProps.selected"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
/>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
>
{{ selectedCount }}
</span>
</div>
<span>
{{ slotProps.option.name }}
</span>
</div>
</template>
</MultiSelect>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
position="popper"
:side-offset="8"
align="start"
:style="popoverStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
@focus-outside="preventFocusDismiss"
>
<div
v-if="showSearchBox || showSelectedCount || showClearButton"
class="flex flex-col px-2 pt-2 pb-0"
>
<div
v-if="showSearchBox"
:class="
cn(
'flex items-center gap-2 rounded-lg border border-solid border-border-default px-3 py-1.5',
(showSelectedCount || showClearButton) && 'mb-2'
)
"
>
<i
class="icon-[lucide--search] shrink-0 text-sm text-muted-foreground"
/>
<ComboboxInput
v-model="searchQuery"
:placeholder="searchPlaceholder ?? t('g.search')"
class="w-full border-none bg-transparent text-sm outline-none"
/>
</div>
<div
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{ $t('g.itemsSelected', { count: selectedCount }) }}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div class="my-4 h-px bg-border-default" />
</div>
<ComboboxViewport
:class="
cn(
'flex flex-col gap-0 p-0 text-sm',
'scrollbar-custom overflow-y-auto',
'min-w-(--reka-combobox-trigger-width)'
)
"
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
>
<ComboboxItem
v-for="opt in filteredOptions"
:key="opt.value"
:value="opt"
:class="
cn(
'group flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none',
'hover:bg-secondary-background-hover',
'data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected'
)
"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded-sm transition-all duration-200 group-data-[state=checked]:bg-primary-background group-data-[state=unchecked]:bg-secondary-background [&>span]:flex"
>
<ComboboxItemIndicator>
<i
class="icon-[lucide--check] text-xs font-bold text-base-foreground"
/>
</ComboboxItemIndicator>
</div>
<span>{{ opt.name }}</span>
</ComboboxItem>
<ComboboxEmpty class="px-3 pb-4 text-sm text-muted-foreground">
{{ $t('g.noResultsFound') }}
</ComboboxEmpty>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</template>
<script setup lang="ts">
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
import MultiSelect from 'primevue/multiselect'
import { computed, useAttrs } from 'vue'
import type { FocusOutsideEvent } from 'reka-ui'
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport
} from 'reka-ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
type Option = SelectOption
defineOptions({
inheritAttrs: false
})
interface Props {
const {
label,
options = [],
size = 'lg',
disabled = false,
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
/** Input label shown on the trigger button */
label?: string
/** Available options */
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
/** Disable the select */
disabled?: boolean
/** Show search box in the panel header */
showSearchBox?: boolean
/** Show selected count text in the panel header */
@@ -216,22 +224,9 @@ interface Props {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
// Note: options prop is intentionally omitted.
// It's passed via $attrs to maximize PrimeVue API compatibility
}
const {
label,
size = 'lg',
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder = 'Search...',
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<Props>()
}>()
const selectedItems = defineModel<Option[]>({
const selectedItems = defineModel<SelectOption[]>({
required: true
})
const searchQuery = defineModel<string>('searchQuery', { default: '' })
@@ -239,15 +234,16 @@ const searchQuery = defineModel<string>('searchQuery', { default: '' })
const { t } = useI18n()
const selectedCount = computed(() => selectedItems.value.length)
function preventFocusDismiss(event: FocusOutsideEvent) {
event.preventDefault()
}
const popoverStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
})
const attrs = useAttrs()
const originalOptions = computed(() => (attrs.options as Option[]) || [])
// Use VueUse's useFuse for better reactivity and performance
const fuseOptions: UseFuseOptions<Option> = {
const fuseOptions: UseFuseOptions<SelectOption> = {
fuseOptions: {
keys: ['name', 'value'],
threshold: 0.3,
@@ -256,23 +252,20 @@ const fuseOptions: UseFuseOptions<Option> = {
matchAllWhenSearchEmpty: true
}
const { results } = useFuse(searchQuery, originalOptions, fuseOptions)
const { results } = useFuse(searchQuery, () => options, fuseOptions)
// Filter options based on search, but always include selected items
const filteredOptions = computed(() => {
if (!searchQuery.value || searchQuery.value.trim() === '') {
return originalOptions.value
return options
}
// results.value already contains the search results from useFuse
const searchResults = results.value.map(
(result: { item: Option }) => result.item
(result: { item: SelectOption }) => result.item
)
// Include selected items that aren't in search results
const selectedButNotInResults = selectedItems.value.filter(
(item) =>
!searchResults.some((result: Option) => result.value === item.value)
!searchResults.some((result: SelectOption) => result.value === item.value)
)
return [...selectedButNotInResults, ...searchResults]

View File

@@ -1,21 +1,12 @@
<template>
<!--
Note: We explicitly pass options here (not just via $attrs) because:
1. Our custom value template needs options to look up labels from values
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
3. We need to maintain the icon slot functionality in the value template
option-label="name" is required because our option template directly accesses option.name
-->
<Select
v-model="selectedItem"
v-bind="$attrs"
:options="options"
option-label="name"
option-value="value"
unstyled
:pt="{
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
<SelectRoot v-model="selectedItem" :disabled>
<SelectTrigger
v-bind="$attrs"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
:class="
cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg',
@@ -23,121 +14,107 @@
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
invalid
? 'border-destructive-background'
: 'border-transparent focus-within:border-node-component-border',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
invalid ? 'border-destructive-background' : 'border-transparent',
'focus:border-node-component-border focus:outline-none',
'disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background'
)
}),
label: {
class: cn(
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
dropdown: {
class:
// Right chevron touch area
'flex shrink-0 items-center justify-center px-3 py-2'
},
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: `max-height: min(${listMaxHeight}, 50vh)`,
class: 'scrollbar-custom'
}),
list: {
class:
// Same list tone/size as MultiSelect
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
// Row layout
'flex items-center justify-between gap-3 rounded-sm px-2 py-3',
'hover:bg-secondary-background-hover',
// Add focus state for keyboard navigation
context.focused && 'bg-secondary-background-hover',
// Selected state + check icon
context.selected &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
optionLabel: {
class: 'truncate'
},
optionGroupLabel: {
class: 'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground'
},
emptyMessage: {
class: 'px-3 py-2 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<!-- Trigger value -->
<template #value="slotProps">
"
>
<div
:class="
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
cn(
'flex flex-1 items-center gap-2 overflow-hidden py-2',
size === 'md' ? 'pl-3 text-xs' : 'pl-4 text-sm'
)
"
>
<i
v-if="loading"
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
class="icon-[lucide--loader-circle] shrink-0 animate-spin text-muted-foreground"
/>
<slot v-else name="icon" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-base-foreground"
>
{{ getLabel(slotProps.value) }}
</span>
<span v-else class="text-base-foreground">
{{ label }}
</span>
<SelectValue :placeholder="label" class="truncate" />
</div>
</template>
<!-- Trigger caret (hidden when loading) -->
<template #dropdownicon>
<i
v-if="!loading"
class="icon-[lucide--chevron-down] text-muted-foreground"
/>
</template>
<!-- Option row -->
<template #option="{ option, selected }">
<div
class="flex w-full items-center justify-between gap-3"
:style="optionStyle"
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<span class="truncate">{{ option.name }}</span>
<i v-if="selected" class="icon-[lucide--check] text-base-foreground" />
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</template>
</Select>
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
:side-offset="8"
align="start"
:style="optionStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'min-w-(--reka-select-trigger-width)',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
>
<SelectViewport
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
class="scrollbar-custom w-full"
>
<SelectItem
v-for="opt in options"
:key="opt.value"
:value="opt.value"
:class="
cn(
'relative flex w-full cursor-pointer items-center justify-between select-none',
'gap-3 rounded-sm px-2 py-3 text-sm outline-none',
'hover:bg-secondary-background-hover',
'focus:bg-secondary-background-hover',
'data-[state=checked]:bg-secondary-background-selected',
'data-[state=checked]:hover:bg-secondary-background-selected'
)
"
>
<SelectItemText class="truncate">
{{ opt.name }}
</SelectItemText>
<SelectItemIndicator
class="flex shrink-0 items-center justify-center"
>
<i
class="icon-[lucide--check] text-base-foreground"
aria-hidden="true"
/>
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
<script setup lang="ts">
import type { SelectPassThroughMethodOptions } from 'primevue/select'
import Select from 'primevue/select'
import { computed } from 'vue'
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
@@ -152,16 +129,12 @@ const {
size = 'lg',
invalid = false,
loading = false,
disabled = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
label?: string
/**
* Required for displaying the selected item's label.
* Cannot rely on $attrs alone because we need to access options
* in getLabel() to map values to their display names.
*/
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
@@ -169,6 +142,8 @@ const {
invalid?: boolean
/** Show loading spinner instead of chevron */
loading?: boolean
/** Disable the select */
disabled?: boolean
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */
@@ -181,26 +156,8 @@ const selectedItem = defineModel<string | undefined>({ required: true })
const { t } = useI18n()
/**
* Maps a value to its display label.
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
* only the raw value. We need this to show the correct text when an item is selected.
*/
const getLabel = (val: string | null | undefined) => {
if (val == null) return label ?? ''
if (!options) return label ?? ''
const found = options.find((o) => o.value === val)
return found ? found.name : (label ?? '')
}
// Extract complex style logic from template
const optionStyle = computed(() => {
if (!popoverMinWidth && !popoverMaxWidth) return undefined
const styles: string[] = []
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)
if (popoverMaxWidth) styles.push(`max-width: ${popoverMaxWidth}`)
return styles.join('; ')
const optionStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
})
</script>

View File

@@ -9,12 +9,15 @@ import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { app } from '@/scripts/app'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
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 { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -38,12 +41,21 @@ import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
if (!app.isGraphReady) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingNodesErrorStore.missingAncestorExecutionIds
)
})
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
@@ -291,6 +303,7 @@ function handleTitleCancel() {
v-if="isSingleSubgraphNode"
variant="secondary"
size="icon"
data-testid="subgraph-editor-toggle"
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
@click="
rightSidePanelStore.openPanel(

View File

@@ -237,6 +237,11 @@ describe('ErrorNodeCard.vue', () => {
// Report is still generated with fallback log message
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
serverLogs: 'Failed to retrieve server logs'
})
)
expect(wrapper.text()).toContain('ComfyUI Error Report')
})

View File

@@ -90,6 +90,7 @@
variant="secondary"
size="sm"
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
{{ t('g.findOnGithub') }}
@@ -99,6 +100,7 @@
variant="secondary"
size="sm"
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
{{ t('g.copy') }}
@@ -125,12 +127,10 @@
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { cn } from '@/utils/tailwindUtil'
import type { ErrorCardData, ErrorItem } from './types'
import { useErrorActions } from './useErrorActions'
import { useErrorReport } from './useErrorReport'
const {
@@ -154,10 +154,8 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const telemetry = useTelemetry()
const { staticUrls } = useExternalLink()
const commandStore = useCommandStore()
const { displayedDetailsMap } = useErrorReport(() => card)
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
function handleLocateNode() {
if (card.nodeId) {
@@ -178,23 +176,6 @@ function handleCopyError(idx: number) {
}
function handleCheckGithub(error: ErrorItem) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(error.message + ' is:issue')
window.open(
`${staticUrls.githubIssues}?q=${query}`,
'_blank',
'noopener,noreferrer'
)
}
function handleGetHelp() {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
commandStore.execute('Comfy.ContactSupport')
findOnGitHub(error.message)
}
</script>

View File

@@ -1,7 +1,5 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -42,23 +40,25 @@ vi.mock('@/stores/systemStatsStore', () => ({
})
}))
const mockApplyChanges = vi.fn()
const mockIsRestarting = ref(false)
const mockApplyChanges = vi.hoisted(() => vi.fn())
const mockIsRestarting = vi.hoisted(() => ({ value: false }))
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
useApplyChanges: () => ({
isRestarting: mockIsRestarting,
get isRestarting() {
return mockIsRestarting.value
},
applyChanges: mockApplyChanges
})
}))
const mockIsPackInstalled = vi.fn(() => false)
const mockIsPackInstalled = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
const mockShouldShowManagerButtons = { value: false }
const mockShouldShowManagerButtons = vi.hoisted(() => ({ value: false }))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons
@@ -128,7 +128,7 @@ function mountCard(
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
}

View File

@@ -209,12 +209,9 @@ describe('TabErrors.vue', () => {
}
})
// Find the copy button by text (rendered inside ErrorNodeCard)
const copyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Copy'))
expect(copyButton).toBeTruthy()
await copyButton!.trigger('click')
const copyButton = wrapper.find('[data-testid="error-card-copy"]')
expect(copyButton.exists()).toBe(true)
await copyButton.trigger('click')
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
@@ -245,5 +242,9 @@ describe('TabErrors.vue', () => {
// Should render in the dedicated runtime error panel, not inside accordion
const runtimePanel = wrapper.find('[data-testid="runtime-error-panel"]')
expect(runtimePanel.exists()).toBe(true)
// Verify the error message appears exactly once (not duplicated in accordion)
expect(
wrapper.text().match(/RuntimeError: Out of memory/g) ?? []
).toHaveLength(1)
})
})

View File

@@ -53,6 +53,7 @@
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.title"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:collapse="isSectionCollapsed(group.title) && !isSearching"
class="border-b border-interface-stroke"
:size="getGroupSize(group)"
@@ -209,12 +210,9 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
@@ -238,6 +236,7 @@ import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorActions } from './useErrorActions'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
@@ -246,7 +245,7 @@ import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacemen
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { staticUrls } = useExternalLink()
const { openGitHubIssues, contactSupport } = useErrorActions()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
@@ -372,13 +371,13 @@ watch(
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
const hasMatch =
group.type === 'execution' &&
group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.title, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
@@ -418,20 +417,4 @@ function handleReplaceAll() {
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
async function contactSupport() {
useTelemetry()?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
useCommandStore().execute('Comfy.ContactSupport')
}
</script>

View File

@@ -47,7 +47,7 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
@@ -80,8 +80,7 @@ describe('swapNodeGroups computed', () => {
})
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
const store = useExecutionErrorStore()
store.surfaceMissingNodes(nodeTypes)
useMissingNodesErrorStore().surfaceMissingNodes(nodeTypes)
const searchQuery = ref('')
const t = (key: string) => key

View File

@@ -0,0 +1,39 @@
import { useCommandStore } from '@/stores/commandStore'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
export function useErrorActions() {
const telemetry = useTelemetry()
const commandStore = useCommandStore()
const { staticUrls } = useExternalLink()
function openGitHubIssues() {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
function contactSupport() {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
void commandStore.execute('Comfy.ContactSupport')
}
function findOnGitHub(errorMessage: string) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(errorMessage + ' is:issue')
window.open(
`${staticUrls.githubIssues}?q=${query}`,
'_blank',
'noopener,noreferrer'
)
}
return { openGitHubIssues, contactSupport, findOnGitHub }
}

View File

@@ -58,6 +58,7 @@ vi.mock(
)
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
@@ -126,8 +127,9 @@ describe('useErrorGroups', () => {
})
it('groups non-replaceable nodes by cnrId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
@@ -146,8 +148,9 @@ describe('useErrorGroups', () => {
})
it('excludes replaceable nodes from missingPackGroups', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -164,8 +167,9 @@ describe('useErrorGroups', () => {
})
it('groups nodes without cnrId under null packId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
])
@@ -177,8 +181,9 @@ describe('useErrorGroups', () => {
})
it('sorts groups alphabetically with null packId last', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
makeMissingNodeType('NodeB', { nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
@@ -190,8 +195,9 @@ describe('useErrorGroups', () => {
})
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
@@ -206,8 +212,9 @@ describe('useErrorGroups', () => {
})
it('handles string nodeType entries', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
'StringGroupNode' as unknown as MissingNodeType
])
await nextTick()
@@ -224,8 +231,9 @@ describe('useErrorGroups', () => {
})
it('includes missing_node group when missing nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
@@ -237,8 +245,9 @@ describe('useErrorGroups', () => {
})
it('includes swap_nodes group when replaceable nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -253,8 +262,9 @@ describe('useErrorGroups', () => {
})
it('includes both swap_nodes and missing_node when both exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -272,8 +282,9 @@ describe('useErrorGroups', () => {
})
it('swap_nodes has lower priority than missing_node', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -533,13 +544,18 @@ describe('useErrorGroups', () => {
})
it('includes missing node group title as message', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
expect(groups.groupedErrorMessages.value).toContain(missingGroup!.title)
})
})

View File

@@ -5,6 +5,7 @@ import type { IFuseOptions } from 'fuse.js'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
@@ -195,12 +196,8 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors
.map((e: ErrorItem) => e.message)
.join(' '),
searchableDetails: card.errors
.map((e: ErrorItem) => e.details ?? '')
.join(' ')
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
})
}
}
@@ -240,6 +237,7 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
const missingModelStore = useMissingModelStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
@@ -285,7 +283,7 @@ export function useErrorGroups(
const missingNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
for (const nodeType of nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
@@ -407,7 +405,7 @@ export function useErrorGroups(
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
const pendingTypes = computed(() =>
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
(missingNodesStore.missingNodesError?.nodeTypes ?? []).filter(
(n): n is Exclude<MissingNodeType, string> =>
typeof n !== 'string' && !n.cnrId
)
@@ -448,6 +446,8 @@ export function useErrorGroups(
for (const r of results) {
if (r.status === 'fulfilled') {
final.set(r.value.type, r.value.packId)
} else {
console.warn('Failed to resolve pack ID:', r.reason)
}
}
// Clear any remaining RESOLVING markers for failed lookups
@@ -459,8 +459,18 @@ export function useErrorGroups(
{ immediate: true }
)
// Evict stale entries when missing nodes are cleared
watch(
() => missingNodesStore.missingNodesError,
(error) => {
if (!error && asyncResolvedIds.value.size > 0) {
asyncResolvedIds.value = new Map()
}
}
)
const missingPackGroups = computed<MissingPackGroup[]>(() => {
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const map = new Map<
string | null,
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
@@ -522,7 +532,7 @@ export function useErrorGroups(
})
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const map = new Map<string, SwapNodeGroup>()
for (const nodeType of nodeTypes) {
@@ -546,7 +556,7 @@ export function useErrorGroups(
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
const error = executionErrorStore.missingNodesError
const error = missingNodesStore.missingNodesError
if (!error) return []
const groups: ErrorGroup[] = []

View File

@@ -2,6 +2,8 @@ import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { until } from '@vueuse/core'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -40,24 +42,33 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
if (runtimeErrors.length === 0) return
if (!systemStatsStore.systemStats) {
try {
await systemStatsStore.refetchSystemStats()
} catch {
return
if (systemStatsStore.isLoading) {
await until(systemStatsStore.isLoading).toBe(false)
} else {
try {
await systemStatsStore.refetchSystemStats()
} catch (e) {
console.warn('Failed to fetch system stats for error report:', e)
return
}
}
}
if (cancelled || !systemStatsStore.systemStats) return
let logs: string
try {
logs = await api.getLogs()
} catch {
logs = 'Failed to retrieve server logs'
}
if (!systemStatsStore.systemStats || cancelled) return
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const workflow = app.rootGraph.serialize()
const workflow = (() => {
try {
return app.rootGraph.serialize()
} catch (e) {
console.warn('Failed to serialize workflow for error report:', e)
return null
}
})()
if (!workflow) return
for (const { error, idx } of runtimeErrors) {
try {
@@ -72,8 +83,8 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
workflow
})
enrichedDetails[idx] = report
} catch {
// Fallback: keep original error.details
} catch (e) {
console.warn('Failed to generate error report:', e)
}
}
})

View File

@@ -223,6 +223,7 @@ onMounted(() => {
<div
v-if="filteredActive.length"
data-testid="subgraph-editor-shown-section"
class="flex flex-col border-b border-interface-stroke"
>
<div
@@ -254,6 +255,7 @@ onMounted(() => {
<div
v-if="filteredCandidates.length"
data-testid="subgraph-editor-hidden-section"
class="flex flex-col border-b border-interface-stroke"
>
<div

View File

@@ -38,11 +38,14 @@ function getIcon() {
<div class="line-clamp-1 text-xs text-text-secondary">
{{ nodeTitle }}
</div>
<div class="line-clamp-1 text-sm/8">{{ widgetName }}</div>
<div class="line-clamp-1 text-sm/8" data-testid="subgraph-widget-label">
{{ widgetName }}
</div>
</div>
<Button
variant="muted-textonly"
size="sm"
data-testid="subgraph-widget-toggle"
:disabled="isPhysical"
@click.stop="$emit('toggleVisibility')"
>

View File

@@ -315,6 +315,45 @@ describe('installErrorClearingHooks lifecycle', () => {
cleanup()
expect(graph.onNodeAdded).toBe(originalHook)
})
it('restores original node callbacks when a node is removed', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('clip', 'CLIP')
node.addWidget('number', 'steps', 20, () => undefined, {})
const originalOnConnectionsChange = vi.fn()
const originalOnWidgetChanged = vi.fn()
node.onConnectionsChange = originalOnConnectionsChange
node.onWidgetChanged = originalOnWidgetChanged
graph.add(node)
installErrorClearingHooks(graph)
// Callbacks should be chained (not the originals)
expect(node.onConnectionsChange).not.toBe(originalOnConnectionsChange)
expect(node.onWidgetChanged).not.toBe(originalOnWidgetChanged)
// Simulate node removal via the graph hook
graph.onNodeRemoved!(node)
// Original callbacks should be restored
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
expect(node.onWidgetChanged).toBe(originalOnWidgetChanged)
})
it('does not double-wrap callbacks when installErrorClearingHooks is called twice', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('clip', 'CLIP')
graph.add(node)
installErrorClearingHooks(graph)
const chainedAfterFirst = node.onConnectionsChange
// Install again on the same graph — should be a no-op for existing nodes
installErrorClearingHooks(graph)
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
})
})
describe('clearWidgetRelatedErrors parameter routing', () => {

View File

@@ -35,10 +35,22 @@ function resolvePromotedExecId(
const hookedNodes = new WeakSet<LGraphNode>()
type OriginalCallbacks = {
onConnectionsChange: LGraphNode['onConnectionsChange']
onWidgetChanged: LGraphNode['onWidgetChanged']
}
const originalCallbacks = new WeakMap<LGraphNode, OriginalCallbacks>()
function installNodeHooks(node: LGraphNode): void {
if (hookedNodes.has(node)) return
hookedNodes.add(node)
originalCallbacks.set(node, {
onConnectionsChange: node.onConnectionsChange,
onWidgetChanged: node.onWidgetChanged
})
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
function (type, slotIndex, isConnected) {
@@ -82,6 +94,15 @@ function installNodeHooks(node: LGraphNode): void {
)
}
function restoreNodeHooks(node: LGraphNode): void {
const originals = originalCallbacks.get(node)
if (!originals) return
node.onConnectionsChange = originals.onConnectionsChange
node.onWidgetChanged = originals.onWidgetChanged
originalCallbacks.delete(node)
hookedNodes.delete(node)
}
function installNodeHooksRecursive(node: LGraphNode): void {
installNodeHooks(node)
if (node.isSubgraphNode?.()) {
@@ -91,6 +112,15 @@ function installNodeHooksRecursive(node: LGraphNode): void {
}
}
function restoreNodeHooksRecursive(node: LGraphNode): void {
restoreNodeHooks(node)
if (node.isSubgraphNode?.()) {
for (const innerNode of node.subgraph._nodes ?? []) {
restoreNodeHooksRecursive(innerNode)
}
}
}
export function installErrorClearingHooks(graph: LGraph): () => void {
for (const node of graph._nodes ?? []) {
installNodeHooksRecursive(node)
@@ -102,7 +132,17 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
originalOnNodeAdded?.call(this, node)
}
const originalOnNodeRemoved = graph.onNodeRemoved
graph.onNodeRemoved = function (node: LGraphNode) {
restoreNodeHooksRecursive(node)
originalOnNodeRemoved?.call(this, node)
}
return () => {
for (const node of graph._nodes ?? []) {
restoreNodeHooksRecursive(node)
}
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
}
}

View File

@@ -197,14 +197,16 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with displayName="value" (subgraph input
// Create a PromotedWidgetView with identityName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value", but safeWidgetMapper sets
// SafeWidgetData.name to sourceWidgetName ("prompt").
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
'value',
undefined,
'value'
)

View File

@@ -92,6 +92,10 @@ export interface SafeWidgetData {
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
promotedLabel?: string
}
export interface VueNodeData {
@@ -352,7 +356,8 @@ function safeWidgetMapper(
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
}
} catch (error) {
console.warn(
@@ -803,6 +808,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so promotedLabel reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}

View File

@@ -0,0 +1,111 @@
import type { Ref } from 'vue'
import { computed, watch } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
import { getParentExecutionIds } from '@/types/nodeIdentification'
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
if (node.has_errors === hasErrors) return
const oldValue = node.has_errors
node.has_errors = hasErrors
node.graph?.trigger('node:property:changed', {
type: 'node:property:changed',
nodeId: node.id,
property: 'has_errors',
oldValue,
newValue: hasErrors
})
}
/**
* Single-pass reconciliation of node error flags.
* Collects the set of nodes that should have errors, then walks all nodes
* once, setting each flag exactly once. This avoids the redundant
* true→false→true transition (and duplicate events) that a clear-then-apply
* approach would cause.
*/
function reconcileNodeErrorFlags(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError> | null,
missingModelExecIds: Set<string>
): void {
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
const flaggedNodes = new Set<LGraphNode>()
const errorSlots = new Map<LGraphNode, Set<string>>()
if (nodeErrors) {
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
const node = getNodeByExecutionId(rootGraph, executionId)
if (!node) continue
flaggedNodes.add(node)
const slotNames = new Set<string>()
for (const error of nodeError.errors) {
const name = error.extra_info?.input_name
if (name) slotNames.add(name)
}
if (slotNames.size > 0) errorSlots.set(node, slotNames)
for (const parentId of getParentExecutionIds(executionId)) {
const parentNode = getNodeByExecutionId(rootGraph, parentId)
if (parentNode) flaggedNodes.add(parentNode)
}
}
}
for (const execId of missingModelExecIds) {
const node = getNodeByExecutionId(rootGraph, execId)
if (node) flaggedNodes.add(node)
}
forEachNode(rootGraph, (node) => {
setNodeHasErrors(node, flaggedNodes.has(node))
if (node.inputs) {
const nodeSlotNames = errorSlots.get(node)
for (const slot of node.inputs) {
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
}
}
})
}
export function useNodeErrorFlagSync(
lastNodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): () => void {
const settingStore = useSettingStore()
const showErrorsTab = computed(() =>
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
)
const stop = watch(
[
lastNodeErrors,
() => missingModelStore.missingModelNodeIds,
showErrorsTab
],
() => {
if (!app.isGraphReady) return
// Legacy (LGraphNode) only: suppress missing-model error flags when
// the Errors tab is hidden, since legacy nodes lack the per-widget
// red highlight that Vue nodes use to indicate *why* a node has errors.
// Vue nodes compute hasAnyError independently and are unaffected.
reconcileNodeErrorFlags(
app.rootGraph,
lastNodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set()
)
},
{ flush: 'post' }
)
return stop
}

View File

@@ -86,6 +86,24 @@ function useVueNodeLifecycleIndividual() {
() => !shouldRenderVueNodes.value,
() => {
disposeNodeManagerAndSyncs()
// Force arrange() on all nodes so input.pos is computed before
// the first legacy drawConnections frame (which may run before
// drawNode on the foreground canvas).
const graph = comfyApp.canvas?.graph
if (!graph) {
comfyApp.canvas?.setDirty(true, true)
return
}
for (const node of graph._nodes) {
if (node.flags.collapsed) continue
try {
node.arrange()
} catch {
/* skip nodes not fully initialized */
}
}
comfyApp.canvas?.setDirty(true, true)
}
)

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import {
exceedsClickThreshold,
useClickDragGuard
} from '@/composables/useClickDragGuard'
describe('exceedsClickThreshold', () => {
it('returns false when distance is within threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 2, y: 2 }, 5)).toBe(false)
})
it('returns true when distance exceeds threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 5 }, 5)).toBe(true)
})
it('returns false when distance exactly equals threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 4 }, 5)).toBe(false)
})
it('handles negative deltas', () => {
expect(exceedsClickThreshold({ x: 10, y: 10 }, { x: 4, y: 2 }, 5)).toBe(
true
)
})
})
describe('useClickDragGuard', () => {
it('reports no drag when pointer has not moved', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
})
it('reports no drag when movement is within threshold', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 103, clientY: 204 })).toBe(false)
})
it('reports drag when movement exceeds threshold', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 106, clientY: 200 })).toBe(true)
})
it('returns false when no start has been recorded', () => {
const guard = useClickDragGuard(5)
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
})
it('returns false after reset', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
guard.reset()
expect(guard.wasDragged({ clientX: 200, clientY: 300 })).toBe(false)
})
it('respects custom threshold', () => {
const guard = useClickDragGuard(3)
guard.recordStart({ clientX: 0, clientY: 0 })
expect(guard.wasDragged({ clientX: 3, clientY: 0 })).toBe(false)
expect(guard.wasDragged({ clientX: 4, clientY: 0 })).toBe(true)
})
})

View File

@@ -0,0 +1,41 @@
interface PointerPosition {
readonly x: number
readonly y: number
}
function squaredDistance(a: PointerPosition, b: PointerPosition): number {
const dx = a.x - b.x
const dy = a.y - b.y
return dx * dx + dy * dy
}
export function exceedsClickThreshold(
start: PointerPosition,
end: PointerPosition,
threshold: number
): boolean {
return squaredDistance(start, end) > threshold * threshold
}
export function useClickDragGuard(threshold: number = 5) {
let start: PointerPosition | null = null
function recordStart(e: { clientX: number; clientY: number }) {
start = { x: e.clientX, y: e.clientY }
}
function wasDragged(e: { clientX: number; clientY: number }): boolean {
if (!start) return false
return exceedsClickThreshold(
start,
{ x: e.clientX, y: e.clientY },
threshold
)
}
function reset() {
start = null
}
return { recordStart, wasDragged, reset }
}

View File

@@ -107,6 +107,27 @@ export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap<
EssentialsCategory
> = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c]))
/**
* Precomputed rank map: category → display order index.
* Used for sorting essentials folders in their canonical order.
*/
export const ESSENTIALS_CATEGORY_RANK: ReadonlyMap<string, number> = new Map(
ESSENTIALS_CATEGORIES.map((c, i) => [c, i])
)
/**
* Precomputed rank maps: category → (node name → display order index).
* Used for sorting nodes within each essentials folder.
*/
export const ESSENTIALS_NODE_RANK: Partial<
Record<EssentialsCategory, ReadonlyMap<string, number>>
> = Object.fromEntries(
Object.entries(ESSENTIALS_NODES).map(([category, nodes]) => [
category,
new Map(nodes.map((name, i) => [name, i]))
])
)
/**
* "Novel" toolkit nodes for telemetry — basics excluded.
* Derived from ESSENTIALS_NODES minus the 'basics' category.

View File

@@ -138,15 +138,18 @@ describe(createPromotedWidgetView, () => {
expect(view.name).toBe('myWidget')
})
test('name uses displayName when provided', () => {
test('name uses identityName when provided, label uses displayName', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(
subgraphNode,
'1',
'myWidget',
'Custom Label'
'Custom Label',
undefined,
'my_slot'
)
expect(view.name).toBe('Custom Label')
expect(view.name).toBe('my_slot')
expect(view.label).toBe('Custom Label')
})
test('node getter returns the subgraphNode', () => {
@@ -334,11 +337,11 @@ describe(createPromotedWidgetView, () => {
innerNode.addWidget('text', 'myWidget', 'val', () => {})
const bareId = String(innerNode.id)
// No displayName → falls back to widgetName
// No displayName → label is undefined (rendering uses widget.label ?? widget.name)
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
expect(view1.label).toBe('myWidget')
expect(view1.label).toBeUndefined()
// With displayName → falls back to displayName
// With displayName → label falls back to displayName
const view2 = createPromotedWidgetView(
subgraphNode,
bareId,
@@ -1012,7 +1015,9 @@ describe('SubgraphNode.widgets getter', () => {
const afterRename = promotedWidgets(subgraphNode)[0]
if (!afterRename) throw new Error('Expected linked promoted view')
expect(afterRename.name).toBe('seed_renamed')
// .name stays as identity (subgraph input name), .label updates for display
expect(afterRename.name).toBe('seed')
expect(afterRename.label).toBe('seed_renamed')
})
test('caches view objects across getter calls (stable references)', () => {

View File

@@ -27,6 +27,12 @@ import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidget
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
interface SubgraphSlotRef {
name: string
label?: string
displayName?: string
}
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
if (value === undefined) return true
if (typeof value === 'string') return true
@@ -50,14 +56,16 @@ export function createPromotedWidgetView(
nodeId: string,
widgetName: string,
displayName?: string,
disambiguatingSourceNodeId?: string
disambiguatingSourceNodeId?: string,
identityName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId
disambiguatingSourceNodeId,
identityName
)
}
@@ -83,12 +91,17 @@ class PromotedWidgetView implements IPromotedWidgetView {
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
/** Cached reference to the bound subgraph slot, set at construction. */
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string
readonly disambiguatingSourceNodeId?: string,
private readonly identityName?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
@@ -100,7 +113,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get name(): string {
return this.displayName ?? this.sourceWidgetName
return this.identityName ?? this.sourceWidgetName
}
get y(): number {
@@ -188,15 +201,58 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
// Fall back to persisted widget state (survives save/reload before
// the slot binding is established) then to construction displayName.
const state = this.getWidgetState()
return state?.label ?? this.displayName ?? this.sourceWidgetName
return state?.label ?? this.displayName
}
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) slot.label = value || undefined
// Also persist to widget state store for save/reload resilience
const state = this.getWidgetState()
if (state) state.label = value
}
/**
* Returns the cached bound subgraph slot reference, refreshing only when
* the subgraph node's input list has changed (length mismatch).
*
* Note: Using length as the cache key works because the returned reference
* is the same mutable slot object. When slot properties (label, name) change,
* the caller reads fresh values from that reference. The cache only needs
* to invalidate when slots are added or removed, which changes length.
*/
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const version = this.subgraphNode.inputs?.length ?? 0
if (this._boundSlotVersion === version) return this._boundSlot
this._boundSlot = this.findBoundSubgraphSlot()
this._boundSlotVersion = version
return this._boundSlot
}
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
for (const input of this.subgraphNode.inputs ?? []) {
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
if (!slot) continue
const w = input._widget
if (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === this.sourceNodeId &&
w.sourceWidgetName === this.sourceWidgetName
) {
return slot
}
}
return undefined
}
get hidden(): boolean {
return this.resolveDeepest()?.widget.hidden ?? false
}
@@ -238,21 +294,27 @@ class PromotedWidgetView implements IPromotedWidgetView {
const originalComputedHeight = projected.computedHeight
const originalComputedDisabled = projected.computedDisabled
const originalLabel = projected.label
projected.y = this.y
projected.computedHeight = this.computedHeight
projected.computedDisabled = this.computedDisabled
projected.value = this.value
projected.label = this.label
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
try {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
} finally {
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
projected.label = originalLabel
}
}
onPointerDown(

View File

@@ -1,5 +1,7 @@
import * as THREE from 'three'
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
@@ -68,9 +70,7 @@ class Load3d {
targetAspectRatio: number = 1
isViewerMode: boolean = false
// Context menu tracking
private rightMouseDownX: number = 0
private rightMouseDownY: number = 0
private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 }
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
@@ -197,18 +197,20 @@ class Load3d {
const mousedownHandler = (e: MouseEvent) => {
if (e.button === 2) {
this.rightMouseDownX = e.clientX
this.rightMouseDownY = e.clientY
this.rightMouseStart = { x: e.clientX, y: e.clientY }
this.rightMouseMoved = false
}
}
const mousemoveHandler = (e: MouseEvent) => {
if (e.buttons === 2) {
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
if (dx > this.dragThreshold || dy > this.dragThreshold) {
if (
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
) {
this.rightMouseMoved = true
}
}
@@ -217,12 +219,13 @@ class Load3d {
const contextmenuHandler = (e: MouseEvent) => {
if (this.isViewerMode) return
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
const wasDragging =
this.rightMouseMoved ||
dx > this.dragThreshold ||
dy > this.dragThreshold
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
this.rightMouseMoved = false

View File

@@ -0,0 +1,207 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { LLink } from '@/lib/litegraph/src/LLink'
import { createMockCanvas2DContext } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn(),
setSource: vi.fn(),
batchUpdateNodeBounds: vi.fn(),
getCurrentSource: vi.fn(() => 'test'),
getCurrentActor: vi.fn(() => 'test'),
applyOperation: vi.fn(),
pendingSlotSync: false
}
}))
function createMockCtx(): CanvasRenderingContext2D {
return createMockCanvas2DContext({
translate: vi.fn(),
scale: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
closePath: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
createLinearGradient: vi.fn().mockReturnValue({
addColorStop: vi.fn()
}),
bezierCurveTo: vi.fn(),
quadraticCurveTo: vi.fn(),
isPointInStroke: vi.fn().mockReturnValue(false),
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline,
shadowColor: '',
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
imageSmoothingEnabled: true
})
}
/**
* Creates a link between two nodes by directly mutating graph state,
* bypassing the layout store integration in connect().
*/
function createTestLink(
graph: LGraph,
sourceNode: LGraphNode,
outputSlot: number,
targetNode: LGraphNode,
inputSlot: number
): LLink {
const linkId = ++graph.state.lastLinkId
const link = new LLink(
linkId,
sourceNode.outputs[outputSlot].type,
sourceNode.id,
outputSlot,
targetNode.id,
inputSlot
)
graph._links.set(linkId, link)
sourceNode.outputs[outputSlot].links ??= []
sourceNode.outputs[outputSlot].links!.push(linkId)
targetNode.inputs[inputSlot].link = linkId
return link
}
describe('drawConnections widget-input slot positioning', () => {
let graph: LGraph
let canvas: LGraphCanvas
let canvasElement: HTMLCanvasElement
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia())
canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi.fn().mockReturnValue(createMockCtx())
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true
})
LiteGraph.vueNodesMode = false
})
afterEach(() => {
LiteGraph.vueNodesMode = false
})
it('arranges widget-input slots before rendering links', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]
sourceNode.size = [150, 60]
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.pos = [300, 100]
targetNode.size = [200, 120]
const widget = targetNode.addWidget('text', 'value', '', null)
const input = targetNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
graph.add(targetNode)
createTestLink(graph, sourceNode, 0, targetNode, 0)
// Before drawConnections, input.pos should not be set
expect(input.pos).toBeUndefined()
canvas.drawConnections(createMockCtx())
// After drawConnections, input.pos should be set to the widget row
expect(input.pos).toBeDefined()
expect(input.pos![1]).toBeGreaterThan(0)
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(input.pos![1]).toBe(widget.y + offset)
})
it('does not re-arrange nodes whose widget-input slots already have positions', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]
sourceNode.size = [150, 60]
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.pos = [300, 100]
targetNode.size = [200, 120]
targetNode.addWidget('text', 'value', '', null)
const input = targetNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
graph.add(targetNode)
createTestLink(graph, sourceNode, 0, targetNode, 0)
// Pre-arrange so input.pos is already set
targetNode._setConcreteSlots()
targetNode.arrange()
expect(input.pos).toBeDefined()
const arrangeSpy = vi.spyOn(targetNode, 'arrange')
canvas.drawConnections(createMockCtx())
expect(arrangeSpy).not.toHaveBeenCalled()
})
it('positions widget-input slots when display name differs from slot.widget.name', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]
sourceNode.size = [150, 60]
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.pos = [300, 100]
targetNode.size = [200, 120]
// Widget has a display name that differs from the slot's widget.name
// (simulates a renamed subgraph label)
const widget = targetNode.addWidget('text', 'renamed_label', '', null)
const input = targetNode.addInput('renamed_label', 'STRING')
input.widget = { name: 'original_name' }
// Bind the widget as the slot's _widget (preferred over name-map lookup)
input._widget = widget
graph.add(targetNode)
createTestLink(graph, sourceNode, 0, targetNode, 0)
canvas.drawConnections(createMockCtx())
expect(input.pos).toBeDefined()
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(input.pos![1]).toBe(widget.y + offset)
})
})

View File

@@ -5954,6 +5954,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.globalAlpha = this.editor_alpha
// for every node
const nodes = graph._nodes
// Ensure widget-input slot positions are computed before rendering links.
// arrange() sets input.pos for widget-backed slots, but is normally called
// in drawNode (foreground canvas). drawConnections runs on the background
// canvas, which may render before drawNode has executed for this frame.
// The dirty flag avoids a per-frame O(N) scan of all inputs.
for (const node of nodes) {
if (node.flags.collapsed || !node._widgetSlotsDirty) continue
node._setConcreteSlots()
node.arrange()
}
for (const node of nodes) {
// for every input (we render just inputs because it is easier as every slot can only have one input)
const { inputs } = node

View File

@@ -295,6 +295,12 @@ export class LGraphNode
*/
freeWidgetSpace?: number
/**
* Set to true when widget-backed input slot positions need recalculation.
* Cleared after arrange() runs. Avoids per-frame O(N) scans in drawConnections.
*/
_widgetSlotsDirty = false
locked?: boolean
/** Execution order, automatically computed during run @see {@link LGraph.computeExecutionOrder} */
@@ -1992,6 +1998,7 @@ export class LGraphNode
this.widgets ||= []
const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget
this.widgets.push(widget)
this._widgetSlotsDirty = true
// Only register with store if node has a valid ID (is already in a graph).
// If the node isn't in a graph yet (id === -1), registration happens
@@ -2031,9 +2038,11 @@ export class LGraphNode
if (input._widget === widget) {
input._widget = undefined
input.widget = undefined
input.pos = undefined
}
}
}
this._widgetSlotsDirty = true
widget.onRemove?.()
this.widgets.splice(widgetIndex, 1)
@@ -4206,40 +4215,29 @@ export class LGraphNode
* Arranges the layout of the node's widget input slots.
*/
private _arrangeWidgetInputSlots(): void {
if (!this.widgets) return
if (!this.widgets?.length) return
const slotByWidgetName = new Map<
string,
INodeInputSlot & { index: number }
>()
// Build a name→widget map for fast lookup.
const widgetByName = new Map<string, IBaseWidget>()
for (const w of this.widgets) widgetByName.set(w.name, w)
for (const [i, slot] of this.inputs.entries()) {
// Set widget-backed slot positions from widget Y coordinates.
// In Vue mode, promoted widget inputs are not rendered as <InputSlot>
// components (NodeSlots filters them out), so they have no DOM-registered
// position. input.pos serves as the fallback for getSlotPosition().
for (const [i, slot] of this._concreteInputs.entries()) {
if (!isWidgetInputSlot(slot)) continue
slotByWidgetName.set(slot.widget.name, { ...slot, index: i })
}
if (!slotByWidgetName.size) return
// Prefer the slot's direct _widget binding (1:1 for promoted inputs).
// Fall back to name-map lookup for regular nodes without _widget set.
// Note: the name-map is ambiguous if two promoted inputs share a label;
// _widget avoids this since it is a direct reference.
const widget = slot._widget ?? widgetByName.get(slot.widget.name)
if (!widget) continue
// Only set custom pos if not using Vue positioning
// Vue positioning calculates widget slot positions dynamically
if (!LiteGraph.vueNodesMode) {
for (const widget of this.widgets) {
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
const actualSlot = this._concreteInputs[slot.index]
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
actualSlot.pos = [offset, widget.y + offset]
this._measureSlot(actualSlot, slot.index, true)
}
} else {
// For Vue positioning, just measure the slots without setting pos
for (const widget of this.widgets) {
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
this._measureSlot(this._concreteInputs[slot.index], slot.index, true)
}
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.pos = [offset, widget.y + offset]
this._measureSlot(slot, i, true)
}
}
@@ -4269,6 +4267,7 @@ export class LGraphNode
: 0
this._arrangeWidgets(widgetStartY)
this._arrangeWidgetInputSlots()
this._widgetSlotsDirty = false
}
/**

View File

@@ -1,8 +1,8 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
type ViewManagerEntry = PromotedWidgetSource & { viewKey?: string }
type CreateView<TView> = (entry: ViewManagerEntry) => TView
type ViewManagerEntry = PromotedWidgetSource & {
viewKey?: string
}
/**
* Reconciles promoted widget entries to stable view instances.
@@ -15,9 +15,9 @@ export class PromotedWidgetViewManager<TView> {
private cachedViews: TView[] | null = null
private cachedEntryKeys: string[] | null = null
reconcile(
entries: readonly ViewManagerEntry[],
createView: CreateView<TView>
reconcile<TEntry extends ViewManagerEntry>(
entries: readonly TEntry[],
createView: (entry: TEntry) => TView
): TView[] {
const entryKeys = entries.map((entry) =>
this.makeKey(entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey)

View File

@@ -9,7 +9,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -196,6 +196,258 @@ describe('SubgraphNode Synchronization', () => {
expect(subgraphNode.outputs[0].label).toBe('newOutput')
})
it('should keep input.widget.name stable after rename (onGraphConfigured safety)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'STRING')
interiorNode.addWidget('text', 'value', '', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
expect(promotedInput.widget).toBeDefined()
const originalWidgetName = promotedInput.widget!.name
// Rename the subgraph input label
subgraph.inputs[0].label = 'my_custom_prompt'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[0],
index: 0,
oldName: 'text',
newName: 'my_custom_prompt'
})
// widget.name stays as the internal name — NOT the display label
expect(promotedInput.widget!.name).toBe(originalWidgetName)
// The display label is on input.label (live-read via PromotedWidgetView.label)
expect(promotedInput.label).toBe('my_custom_prompt')
// input.widget.name should still match a widget in node.widgets
const matchingWidget = subgraphNode.widgets?.find(
(w) => w.name === promotedInput.widget!.name
)
expect(matchingWidget).toBeDefined()
})
it('should preserve renamed label through serialize/configure round-trip', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'INT')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'INT')
interiorNode.addWidget('number', 'value', 0, () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedWidget = subgraphNode.widgets?.[0]
expect(promotedWidget).toBeDefined()
// Rename via the subgraph slot (simulates right-click rename)
subgraph.inputs[0].label = 'My Seed'
subgraphNode.inputs[0].label = 'My Seed'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[0],
index: 0,
oldName: 'seed',
newName: 'My Seed'
})
// Label should be visible before round-trip
const widgetBeforeRoundTrip = subgraphNode.widgets?.[0]
expect(widgetBeforeRoundTrip!.label || widgetBeforeRoundTrip!.name).toBe(
'My Seed'
)
// Serialize and reconfigure (simulates save/reload)
const serialized = subgraphNode.serialize()
subgraphNode.configure(serialized)
// Label should survive the round-trip
const widgetAfterRoundTrip = subgraphNode.widgets?.[0]
expect(widgetAfterRoundTrip).toBeDefined()
expect(widgetAfterRoundTrip!.label || widgetAfterRoundTrip!.name).toBe(
'My Seed'
)
})
})
describe('SubgraphNode widget name collision on rename', () => {
it('should not collapse two inputs when renamed to the same label', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'prompt_a', type: 'STRING' },
{ name: 'prompt_b', type: 'STRING' }
]
})
// Create two interior nodes with widgets
const nodeA = new LGraphNode('NodeA')
nodeA.addInput('value', 'STRING')
nodeA.inputs[0].widget = { name: 'value' }
nodeA.addOutput('out', 'STRING')
nodeA.addWidget('text', 'value', '', () => {})
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
const nodeB = new LGraphNode('NodeB')
nodeB.addInput('value', 'STRING')
nodeB.inputs[0].widget = { name: 'value' }
nodeB.addOutput('out', 'STRING')
nodeB.addWidget('text', 'value', '', () => {})
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.inputs).toHaveLength(2)
// widget.name is now nodeId:widgetName (stable composite key)
const key0 = subgraphNode.inputs[0].widget?.name
const key1 = subgraphNode.inputs[1].widget?.name
expect(key0).toBeDefined()
expect(key1).toBeDefined()
expect(key0).not.toBe(key1)
// Rename prompt_b to same LABEL as prompt_a
subgraph.inputs[1].label = 'prompt_a'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[1],
index: 1,
oldName: 'prompt_b',
newName: 'prompt_a'
})
// Both inputs survive — widget.name stays as composite key, no collision
expect(subgraphNode.inputs).toHaveLength(2)
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
// Display labels: input[1] was renamed
expect(subgraphNode.inputs[1].label).toBe('prompt_a')
// Distinct _widget bindings
expect(subgraphNode.inputs[0]._widget).toBeDefined()
expect(subgraphNode.inputs[1]._widget).toBeDefined()
expect(subgraphNode.inputs[0]._widget).not.toBe(
subgraphNode.inputs[1]._widget
)
})
it('should keep unique widget.name keys even with duplicate labels', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'seed', type: 'INT' },
{ name: 'seed2', type: 'INT' }
]
})
const nodeA = new LGraphNode('NodeA')
nodeA.addInput('value', 'INT')
nodeA.inputs[0].widget = { name: 'value' }
nodeA.addOutput('out', 'INT')
nodeA.addWidget('number', 'value', 0, () => {})
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
const nodeB = new LGraphNode('NodeB')
nodeB.addInput('value', 'INT')
nodeB.inputs[0].widget = { name: 'value' }
nodeB.addOutput('out', 'INT')
nodeB.addWidget('number', 'value', 0, () => {})
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
const subgraphNode = createTestSubgraphNode(subgraph)
const key0 = subgraphNode.inputs[0].widget?.name
const key1 = subgraphNode.inputs[1].widget?.name
// Keys should be unique composite identifiers (nodeId:widgetName)
expect(key0).toBeDefined()
expect(key1).toBeDefined()
expect(key0).not.toBe(key1)
// Rename seed2 to "seed" — duplicate display label
subgraph.inputs[1].label = 'seed'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[1],
index: 1,
oldName: 'seed2',
newName: 'seed'
})
// Widget keys remain stable — rename only affects display label
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
// Distinct _widget bindings survive the rename
expect(subgraphNode.inputs[0]._widget).toBeDefined()
expect(subgraphNode.inputs[1]._widget).toBeDefined()
expect(subgraphNode.inputs[0]._widget).not.toBe(
subgraphNode.inputs[1]._widget
)
})
it('should not lose input when onGraphConfigured runs after duplicate rename', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'alpha', type: 'STRING' },
{ name: 'beta', type: 'STRING' }
]
})
const nodeA = new LGraphNode('NodeA')
nodeA.addInput('value', 'STRING')
nodeA.inputs[0].widget = { name: 'value' }
nodeA.addOutput('out', 'STRING')
nodeA.addWidget('text', 'value', '', () => {})
subgraph.add(nodeA)
subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA)
const nodeB = new LGraphNode('NodeB')
nodeB.addInput('value', 'STRING')
nodeB.inputs[0].widget = { name: 'value' }
nodeB.addOutput('out', 'STRING')
nodeB.addWidget('text', 'value', '', () => {})
subgraph.add(nodeB)
subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB)
const subgraphNode = createTestSubgraphNode(subgraph)
// Rename beta to "alpha" — collision
subgraph.inputs[1].label = 'alpha'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[1],
index: 1,
oldName: 'beta',
newName: 'alpha'
})
// Simulate onGraphConfigured check: for each input with widget,
// find a matching widget by name. If not found, the input gets removed.
for (const input of subgraphNode.inputs) {
if (!input.widget) continue
const name = input.widget.name
const w = subgraphNode.widgets?.find((w) => w.name === name)
// Every input should find at least one matching widget
expect(w).toBeDefined()
}
// Both inputs should survive
expect(subgraphNode.inputs).toHaveLength(2)
})
})
describe('SubgraphNode Lifecycle', () => {

View File

@@ -63,6 +63,8 @@ workflowSvg.src =
type LinkedPromotionEntry = PromotedWidgetSource & {
inputName: string
inputKey: string
/** The subgraph input slot's internal name (stable identity). */
slotName: string
}
// Pre-rasterize the SVG to a bitmap canvas to avoid Firefox re-processing
// the SVG's internal stylesheet on every ctx.drawImage() call per frame.
@@ -192,6 +194,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
sourceNodeId: boundWidget.sourceNodeId,
sourceWidgetName: boundWidget.sourceWidgetName
})
@@ -206,6 +209,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
...resolved
})
}
@@ -277,7 +281,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
entry.disambiguatingSourceNodeId
entry.disambiguatingSourceNodeId,
entry.slotName
)
)
@@ -333,6 +338,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
sourceWidgetName: string
viewKey?: string
disambiguatingSourceNodeId?: string
slotName?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
@@ -562,17 +568,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
sourceNodeId: string
sourceWidgetName: string
viewKey: string
disambiguatingSourceNodeId?: string
slotName: string
}> {
return linkedEntries.map(
({
inputKey,
inputName,
slotName,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}) => ({
sourceNodeId,
sourceWidgetName,
slotName,
disambiguatingSourceNodeId,
viewKey: this._makePromotionViewKey(
inputKey,
sourceNodeId,
@@ -780,9 +791,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (!input) throw new Error('Subgraph input not found')
input.label = newName
if (input._widget) {
input._widget.label = newName
}
// Do NOT change input.widget.name — it is the stable internal
// identifier used by onGraphConfigured (widgetInputs.ts) to match
// inputs to widgets. Changing it to the display label would cause
// collisions when two promoted inputs share the same label.
// Display is handled via input.label and _widget.label.
if (input._widget) input._widget.label = newName
this._invalidatePromotedViewsCache()
this.graph?.trigger('node:slot-label:changed', {
nodeId: this.id,
@@ -1134,6 +1148,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
/**
* Binds a promoted widget view to a subgraph input slot.
*
* Creates or retrieves a {@link PromotedWidgetView}, registers it in the
* promotion store, sets up the prototype chain for multi-level subgraph
* nesting, and dispatches the `widget-promoted` event.
*/
private _setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
@@ -1187,7 +1208,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
})
}
// Create/retrieve the view from cache
// Create/retrieve the view from cache.
// The cache key uses `input.name` (the slot's internal name) rather
// than `subgraphInput.name` because nested subgraphs may remap
// the internal name independently of the interior node.
const view = this._promotedViewManager.getOrCreate(
nodeId,
widgetName,
@@ -1197,7 +1221,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
nodeId,
widgetName,
input.label ?? subgraphInput.name,
sourceNodeId
sourceNodeId,
subgraphInput.name
),
this._makePromotionViewKey(
String(subgraphInput.id),
@@ -1211,6 +1236,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
// creating new objects. Have care when making changes.
// Use subgraphInput.name as the stable identity — unique per subgraph
// slot, immune to label renames. Matches PromotedWidgetView.name.
// Display is handled via widget.label / PromotedWidgetView.label.
input.widget ??= { name: subgraphInput.name }
input.widget.name = subgraphInput.name
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)

View File

@@ -888,7 +888,9 @@
"extensionFileHint": "قد يكون السبب هو السكربت التالي",
"loadWorkflowTitle": "تم إلغاء التحميل بسبب خطأ في إعادة تحميل بيانات سير العمل",
"noStackTrace": "لا توجد تتبع للمكدس متاحة",
"promptExecutionError": "فشل تنفيذ الطلب"
"promptExecutionError": "فشل تنفيذ الطلب",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
},
"errorOverlay": {
"errorCount": "{count} خطأ | {count} أخطاء",

View File

@@ -34,6 +34,8 @@
"imageLightbox": "Image preview",
"imagePreview": "Image preview - Use arrow keys to navigate between images",
"videoPreview": "Video preview - Use arrow keys to navigate between videos",
"viewGrid": "Grid view",
"imageGallery": "image gallery",
"galleryImage": "Gallery image",
"galleryThumbnail": "Gallery thumbnail",
"previousImage": "Previous image",
@@ -276,8 +278,7 @@
"clearAll": "Clear all",
"copyURL": "Copy URL",
"releaseTitle": "{package} {version} Release",
"itemSelected": "{selectedCount} item selected",
"itemsSelected": "{selectedCount} items selected",
"itemsSelected": "No items selected | {count} item selected | {count} items selected",
"multiSelectDropdown": "Multi-select dropdown",
"singleSelectDropdown": "Single-select dropdown",
"progressCountOf": "of",
@@ -1885,7 +1886,9 @@
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",
"noStackTrace": "No stacktrace available",
"extensionFileHint": "This may be due to the following script",
"promptExecutionError": "Prompt execution failed"
"promptExecutionError": "Prompt execution failed",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
},
"apiNodesSignInDialog": {
"title": "Sign In Required to Use API Nodes",
@@ -3703,5 +3706,18 @@
"footer": "ComfyUI stays free and open source. Cloud is optional.",
"continueLocally": "Continue Locally",
"exploreCloud": "Try Cloud for Free"
},
"execution": {
"generating": "Generating…",
"saving": "Saving…",
"loading": "Loading…",
"encoding": "Encoding…",
"decoding": "Decoding…",
"processing": "Processing…",
"resizing": "Resizing…",
"generatingVideo": "Generating video…",
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
}
}

View File

@@ -888,7 +888,9 @@
"extensionFileHint": "Esto puede deberse al siguiente script",
"loadWorkflowTitle": "La carga se interrumpió debido a un error al recargar los datos del flujo de trabajo",
"noStackTrace": "No hay seguimiento de pila disponible",
"promptExecutionError": "La ejecución del prompt falló"
"promptExecutionError": "La ejecución del prompt falló",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
},
"errorOverlay": {
"errorCount": "{count} ERROR | {count} ERRORES",

View File

@@ -888,7 +888,9 @@
"extensionFileHint": "این ممکن است به دلیل اسکریپت زیر باشد",
"loadWorkflowTitle": "بارگذاری به دلیل خطا در بارگذاری مجدد داده‌های workflow متوقف شد",
"noStackTrace": "هیچ stacktraceی موجود نیست",
"promptExecutionError": "اجرای prompt با شکست مواجه شد"
"promptExecutionError": "اجرای prompt با شکست مواجه شد",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
},
"errorOverlay": {
"errorCount": "{count} خطا",

View File

@@ -888,7 +888,9 @@
"extensionFileHint": "Cela peut être dû au script suivant",
"loadWorkflowTitle": "Chargement interrompu en raison d'une erreur de rechargement des données de workflow",
"noStackTrace": "Aucune trace de pile disponible",
"promptExecutionError": "L'exécution de l'invite a échoué"
"promptExecutionError": "L'exécution de l'invite a échoué",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
},
"errorOverlay": {
"errorCount": "{count} ERREUR | {count} ERREURS",

View File

@@ -888,7 +888,9 @@
"extensionFileHint": "これは次のスクリプトが原因かもしれません",
"loadWorkflowTitle": "ワークフローデータの再読み込みエラーにより、読み込みが中止されました",
"noStackTrace": "スタックトレースは利用できません",
"promptExecutionError": "プロンプトの実行に失敗しました"
"promptExecutionError": "プロンプトの実行に失敗しました",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
},
"errorOverlay": {
"errorCount": "{count} 件のエラー",

View File

@@ -888,7 +888,9 @@
"extensionFileHint": "다음 스크립트 때문일 수 있습니다",
"loadWorkflowTitle": "워크플로 데이터를 다시 로드하는 중 오류로 인해 로드가 중단되었습니다",
"noStackTrace": "스택 추적을 사용할 수 없습니다",
"promptExecutionError": "프롬프트 실행 실패"
"promptExecutionError": "프롬프트 실행 실패",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
},
"errorOverlay": {
"errorCount": "{count}개 오류",

View File

@@ -888,7 +888,9 @@
"extensionFileHint": "Isso pode ser devido ao seguinte script",
"loadWorkflowTitle": "Carregamento abortado devido a erro ao recarregar os dados do fluxo de trabalho",
"noStackTrace": "Nenhum stacktrace disponível",
"promptExecutionError": "Falha na execução do prompt"
"promptExecutionError": "Falha na execução do prompt",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
},
"errorOverlay": {
"errorCount": "{count} ERRO | {count} ERROS",

View File

@@ -888,7 +888,9 @@
"extensionFileHint": "Это может быть связано со следующим скриптом",
"loadWorkflowTitle": "Загрузка прервана из-за ошибки при перезагрузке данных рабочего процесса",
"noStackTrace": "Стек вызовов недоступен",
"promptExecutionError": "Ошибка выполнения запроса"
"promptExecutionError": "Ошибка выполнения запроса",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
},
"errorOverlay": {
"errorCount": "{count} ОШИБОК | {count} ОШИБКА | {count} ОШИБКИ",

View File

@@ -888,7 +888,9 @@
"extensionFileHint": "Bu, aşağıdaki komut dosyasından kaynaklanıyor olabilir",
"loadWorkflowTitle": "İş akışı verileri yeniden yüklenirken hata nedeniyle yükleme iptal edildi",
"noStackTrace": "Yığın izi mevcut değil",
"promptExecutionError": "İstem yürütmesi başarısız oldu"
"promptExecutionError": "İstem yürütmesi başarısız oldu",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
},
"errorOverlay": {
"errorCount": "{count} HATA | {count} HATA",

View File

@@ -888,7 +888,9 @@
"extensionFileHint": "這可能是由於以下指令碼所致",
"loadWorkflowTitle": "由於重新載入工作流程資料時發生錯誤,已中止載入",
"noStackTrace": "沒有可用的堆疊追蹤",
"promptExecutionError": "提示執行失敗"
"promptExecutionError": "提示執行失敗",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
},
"errorOverlay": {
"errorCount": "{count} 個錯誤",

View File

@@ -888,7 +888,9 @@
"extensionFileHint": "这可能是由于以下脚本",
"loadWorkflowTitle": "由于重新加载工作流数据出错,加载被中止",
"noStackTrace": "无可用堆栈跟踪",
"promptExecutionError": "提示执行失败"
"promptExecutionError": "提示执行失败",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature."
},
"errorOverlay": {
"errorCount": "{count}个错误",

View File

@@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getCnrIdFromProperties,
getCnrIdFromNode
} from './missingNodeErrorUtil'
import { getCnrIdFromNode, getCnrIdFromProperties } from './cnrIdUtil'
describe('getCnrIdFromProperties', () => {
it('returns cnr_id when present', () => {

View File

@@ -20,8 +20,8 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
getExecutionIdByNode: vi.fn()
}))
vi.mock('@/workbench/extensions/manager/utils/missingNodeErrorUtil', () => ({
getCnrIdFromNode: vi.fn(() => null)
vi.mock('@/platform/nodeReplacement/cnrIdUtil', () => ({
getCnrIdFromNode: vi.fn(() => undefined)
}))
vi.mock('@/i18n', () => ({
@@ -48,11 +48,10 @@ import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
// eslint-disable-next-line import-x/no-restricted-paths
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { rescanAndSurfaceMissingNodes } from './missingNodeScan'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
function mockNode(
id: number,
@@ -72,7 +71,7 @@ function mockGraph(): LGraph {
}
function getMissingNodesError(
store: ReturnType<typeof useExecutionErrorStore>
store: ReturnType<typeof useMissingNodesErrorStore>
) {
const error = store.missingNodesError
if (!error) throw new Error('Expected missingNodesError to be defined')
@@ -99,7 +98,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
expect(store.missingNodesError).toBeNull()
})
@@ -112,7 +111,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
expect(error.nodeTypes).toHaveLength(2)
})
@@ -129,7 +128,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
expect(error.nodeTypes).toHaveLength(1)
const missing = error.nodeTypes[0]
@@ -142,7 +141,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.nodeId).toBe('exec-42')
@@ -154,7 +153,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.nodeId).toBe('99')
@@ -167,7 +166,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.cnrId).toBe(
@@ -194,7 +193,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.isReplaceable).toBe(true)
@@ -209,7 +208,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.isReplaceable).toBe(false)
@@ -225,7 +224,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useExecutionErrorStore()
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.type).toBe('OriginalType')

View File

@@ -2,13 +2,13 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
// eslint-disable-next-line import-x/no-restricted-paths
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
/** Scan the live graph for unregistered node types and build a full MissingNodeType list. */
function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
@@ -41,5 +41,7 @@ function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
/** Re-scan the graph for missing nodes and update the error store. */
export function rescanAndSurfaceMissingNodes(rootGraph: LGraph): void {
const types = scanMissingNodes(rootGraph)
useExecutionErrorStore().surfaceMissingNodes(types)
if (useMissingNodesErrorStore().surfaceMissingNodes(types)) {
useExecutionErrorStore().showErrorOverlay()
}
}

View File

@@ -0,0 +1,215 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const mockShowErrorsTab = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => mockShowErrorsTab.value)
}))
}))
import { useMissingNodesErrorStore } from './missingNodesErrorStore'
describe('missingNodesErrorStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('setMissingNodeTypes', () => {
it('sets missingNodesError with provided types', () => {
const store = useMissingNodesErrorStore()
const types: MissingNodeType[] = [
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
]
store.setMissingNodeTypes(types)
expect(store.missingNodesError).not.toBeNull()
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.hasMissingNodes).toBe(true)
})
it('clears missingNodesError when given empty array', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(store.missingNodesError).not.toBeNull()
store.setMissingNodeTypes([])
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('deduplicates string entries by value', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
'NodeA',
'NodeA',
'NodeB'
] as MissingNodeType[])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('deduplicates object entries by nodeId when present', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '2', isReplaceable: false }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('deduplicates object entries by type when nodeId is absent', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', isReplaceable: false },
{ type: 'NodeA', isReplaceable: true }
] as MissingNodeType[])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('keeps distinct nodeIds even when type is the same', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '2', isReplaceable: false },
{ type: 'NodeA', nodeId: '3', isReplaceable: false }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(3)
})
})
describe('surfaceMissingNodes', () => {
beforeEach(() => {
mockShowErrorsTab.value = false
})
it('stores missing node types and returns false when setting disabled', () => {
const store = useMissingNodesErrorStore()
const types: MissingNodeType[] = [
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
]
const shouldShowOverlay = store.surfaceMissingNodes(types)
expect(store.missingNodesError).not.toBeNull()
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.hasMissingNodes).toBe(true)
expect(shouldShowOverlay).toBe(false)
})
it('returns true when ShowErrorsTab setting is enabled', () => {
mockShowErrorsTab.value = true
const store = useMissingNodesErrorStore()
const shouldShowOverlay = store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(shouldShowOverlay).toBe(true)
})
it('returns false when ShowErrorsTab setting is disabled', () => {
mockShowErrorsTab.value = false
const store = useMissingNodesErrorStore()
const shouldShowOverlay = store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(shouldShowOverlay).toBe(false)
})
it('returns false for empty types even when setting is enabled', () => {
mockShowErrorsTab.value = true
const store = useMissingNodesErrorStore()
const shouldShowOverlay = store.surfaceMissingNodes([])
expect(shouldShowOverlay).toBe(false)
})
it('deduplicates node types', () => {
const store = useMissingNodesErrorStore()
store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
})
describe('removeMissingNodesByType', () => {
it('removes matching types from the missing nodes list', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
])
store.removeMissingNodesByType(['NodeA', 'NodeC'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
const remaining = store.missingNodesError?.nodeTypes[0]
expect(typeof remaining !== 'string' && remaining?.type).toBe('NodeB')
})
it('clears missingNodesError when all types are removed', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
store.removeMissingNodesByType(['NodeA'])
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('does nothing when missingNodesError is null', () => {
const store = useMissingNodesErrorStore()
expect(store.missingNodesError).toBeNull()
store.removeMissingNodesByType(['NodeA'])
expect(store.missingNodesError).toBeNull()
})
it('does nothing when removing non-existent types', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
store.removeMissingNodesByType(['NonExistent'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('handles removing from string entries', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
'StringNodeA',
'StringNodeB'
] as MissingNodeType[])
store.removeMissingNodesByType(['StringNodeA'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
})
})

View File

@@ -0,0 +1,125 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { MissingNodeType } from '@/types/comfy'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
interface MissingNodesError {
message: string
nodeTypes: MissingNodeType[]
}
export const useMissingNodesErrorStore = defineStore(
'missingNodesError',
() => {
const missingNodesError = ref<MissingNodesError | null>(null)
function setMissingNodeTypes(types: MissingNodeType[]) {
if (!types.length) {
missingNodesError.value = null
return
}
const seen = new Set<string>()
const uniqueTypes = types.filter((node) => {
// For string entries (group nodes), deduplicate by the string itself.
// For object entries, prefer nodeId so multiple instances of the same
// type are kept as separate rows; fall back to type if nodeId is absent.
const isString = typeof node === 'string'
let key: string
if (isString) {
key = node
} else if (node.nodeId != null) {
key = String(node.nodeId)
} else {
key = node.type
}
if (seen.has(key)) return false
seen.add(key)
return true
})
missingNodesError.value = {
message: isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
nodeTypes: uniqueTypes
}
}
/** Set missing node types. Returns true if the Errors tab is enabled and types were set. */
function surfaceMissingNodes(types: MissingNodeType[]): boolean {
setMissingNodeTypes(types)
return (
types.length > 0 &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
)
}
/** Remove specific node types from the missing nodes list (e.g. after replacement). */
function removeMissingNodesByType(typesToRemove: string[]) {
if (!missingNodesError.value) return
const removeSet = new Set(typesToRemove)
const remaining = missingNodesError.value.nodeTypes.filter((node) => {
const nodeType = typeof node === 'string' ? node : node.type
return !removeSet.has(nodeType)
})
setMissingNodeTypes(remaining)
}
const hasMissingNodes = computed(() => !!missingNodesError.value)
const missingNodeCount = computed(
() => missingNodesError.value?.nodeTypes.length ?? 0
)
/**
* Set of all execution ID prefixes derived from missing node execution IDs,
* including the missing nodes themselves.
*
* Example: missing node at "65:70:63" → Set { "65", "65:70", "65:70:63" }
*/
const missingAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
const ids = new Set<NodeExecutionId>()
const error = missingNodesError.value
if (!error) return ids
for (const nodeType of error.nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
for (const id of getAncestorExecutionIds(String(nodeType.nodeId))) {
ids.add(id)
}
}
return ids
})
/** True if the node has a missing node inside it at any nesting depth. */
function isContainerWithMissingNode(node: LGraphNode): boolean {
if (!app.isGraphReady) return false
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return false
return missingAncestorExecutionIds.value.has(execId)
}
return {
missingNodesError,
setMissingNodeTypes,
surfaceMissingNodes,
removeMissingNodesByType,
hasMissingNodes,
missingNodeCount,
missingAncestorExecutionIds,
isContainerWithMissingNode
}
}
)

View File

@@ -47,8 +47,8 @@ vi.mock('@/i18n', () => ({
const { mockRemoveMissingNodesByType } = vi.hoisted(() => ({
mockRemoveMissingNodesByType: vi.fn()
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: vi.fn(() => ({
vi.mock('@/platform/nodeReplacement/missingNodesErrorStore', () => ({
useMissingNodesErrorStore: vi.fn(() => ({
removeMissingNodesByType: mockRemoveMissingNodesByType
}))
}))

View File

@@ -7,7 +7,7 @@ import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app, sanitizeNodeName } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
@@ -329,24 +329,24 @@ export function useNodeReplacement() {
/**
* Replaces all nodes in a single swap group and removes successfully
* replaced types from the execution error store.
* replaced types from the missing nodes error store.
*/
function replaceGroup(group: ReplacementGroup): void {
const replaced = replaceNodesInPlace(group.nodeTypes)
if (replaced.length > 0) {
useExecutionErrorStore().removeMissingNodesByType(replaced)
useMissingNodesErrorStore().removeMissingNodesByType(replaced)
}
}
/**
* Replaces every available node across all swap groups and removes
* the succeeded types from the execution error store.
* the succeeded types from the missing nodes error store.
*/
function replaceAllGroups(groups: ReplacementGroup[]): void {
const allNodeTypes = groups.flatMap((g) => g.nodeTypes)
const replaced = replaceNodesInPlace(allNodeTypes)
if (replaced.length > 0) {
useExecutionErrorStore().removeMissingNodesByType(replaced)
useMissingNodesErrorStore().removeMissingNodesByType(replaced)
}
}

View File

@@ -1,5 +1,5 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="md">
<BaseModalLayout content-title="" data-testid="settings-dialog" size="sm">
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
@@ -12,6 +12,7 @@
size="md"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
autofocus
@search="handleSearch"
/>
</div>

View File

@@ -25,13 +25,15 @@ export async function initTelemetry(): Promise<void> {
{ MixpanelTelemetryProvider },
{ GtmTelemetryProvider },
{ ImpactTelemetryProvider },
{ PostHogTelemetryProvider }
{ PostHogTelemetryProvider },
{ ClickHouseTelemetryProvider }
] = await Promise.all([
import('./TelemetryRegistry'),
import('./providers/cloud/MixpanelTelemetryProvider'),
import('./providers/cloud/GtmTelemetryProvider'),
import('./providers/cloud/ImpactTelemetryProvider'),
import('./providers/cloud/PostHogTelemetryProvider')
import('./providers/cloud/PostHogTelemetryProvider'),
import('./providers/cloud/ClickHouseTelemetryProvider')
])
const registry = new TelemetryRegistry()
@@ -39,6 +41,7 @@ export async function initTelemetry(): Promise<void> {
registry.registerProvider(new GtmTelemetryProvider())
registry.registerProvider(new ImpactTelemetryProvider())
registry.registerProvider(new PostHogTelemetryProvider())
registry.registerProvider(new ClickHouseTelemetryProvider())
setTelemetryRegistry(registry)
})()

View File

@@ -0,0 +1,45 @@
import { api } from '@/scripts/api'
import type { TelemetryProvider, WorkflowImportMetadata } from '../../types'
/**
* ClickHouse Telemetry Provider - Cloud Build Implementation
*
* Sends observability events to the cloud backend's ClickHouse pipeline
* via POST /api/internal/cloud_analytics. Currently tracks missing node
* data when users open/import workflows with unsupported nodes.
*
* This provider is separate from Mixpanel because ClickHouse is the
* canonical store for post-hoc analytics (per observability philosophy).
*
* CRITICAL: OSS Build Safety
* This file is tree-shaken away in OSS builds (DISTRIBUTION unset).
*/
export class ClickHouseTelemetryProvider implements TelemetryProvider {
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
this.reportMissingNodes(metadata)
}
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
this.reportMissingNodes(metadata)
}
private reportMissingNodes(metadata: WorkflowImportMetadata): void {
if (metadata.missing_node_count <= 0) return
api
.fetchApi('/internal/cloud_analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event_name: 'node_missing',
event_data: {
missing_class_types: metadata.missing_node_types,
missing_count: metadata.missing_node_count,
source: metadata.open_source ?? 'unknown'
}
})
})
.catch(() => {})
}
}

View File

@@ -12,7 +12,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { app } from '@/scripts/app'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
@@ -160,7 +160,7 @@ describe('useWorkflowService', () => {
useWorkflowService().showPendingWarnings(workflow)
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).not.toHaveBeenCalled()
})
@@ -170,9 +170,9 @@ describe('useWorkflowService', () => {
useWorkflowService().showPendingWarnings(workflow)
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
missingNodeTypes
)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(missingNodeTypes)
expect(workflow.pendingWarnings).toBeNull()
})
@@ -185,9 +185,9 @@ describe('useWorkflowService', () => {
useWorkflowService().showPendingWarnings(workflow)
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
['CustomNode1']
)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['CustomNode1'])
expect(workflow.pendingWarnings).toBeNull()
})
@@ -201,7 +201,7 @@ describe('useWorkflowService', () => {
service.showPendingWarnings(workflow)
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
})
})
@@ -226,7 +226,7 @@ describe('useWorkflowService', () => {
)
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).not.toHaveBeenCalled()
await useWorkflowService().openWorkflow(workflow)
@@ -238,9 +238,9 @@ describe('useWorkflowService', () => {
workflow,
expect.objectContaining({ deferWarnings: true })
)
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
['CustomNode1']
)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['CustomNode1'])
expect(workflow.pendingWarnings).toBeNull()
})
@@ -258,20 +258,20 @@ describe('useWorkflowService', () => {
await service.openWorkflow(workflow1)
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
['MissingNodeA']
)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['MissingNodeA'])
expect(workflow1.pendingWarnings).toBeNull()
expect(workflow2.pendingWarnings).not.toBeNull()
await service.openWorkflow(workflow2)
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(2)
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenLastCalledWith(['MissingNodeB'])
expect(workflow2.pendingWarnings).toBeNull()
})
@@ -286,12 +286,12 @@ describe('useWorkflowService', () => {
await service.openWorkflow(workflow, { force: true })
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
await service.openWorkflow(workflow, { force: true })
expect(
useExecutionErrorStore().surfaceMissingNodes
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
})
})

View File

@@ -23,6 +23,7 @@ import type { AppMode } from '@/composables/useAppMode'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
appendJsonExt,
@@ -43,6 +44,7 @@ export const useWorkflowService = () => {
const workflowThumbnail = useWorkflowThumbnail()
const domWidgetStore = useDomWidgetStore()
const executionErrorStore = useExecutionErrorStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const workflowDraftStore = useWorkflowDraftStore()
function confirmOverwrite(targetPath: string) {
@@ -542,7 +544,9 @@ export const useWorkflowService = () => {
wf.pendingWarnings = null
if (missingNodeTypes?.length) {
executionErrorStore.surfaceMissingNodes(missingNodeTypes)
if (missingNodesErrorStore.surfaceMissingNodes(missingNodeTypes)) {
executionErrorStore.showErrorOverlay()
}
}
}

View File

@@ -250,61 +250,125 @@ function readSessionPointer<T extends { workspaceId: string }>(
/**
* Reads the active path pointer from sessionStorage.
* Falls back to workspace-based search when clientId changes after reload.
* Falls back to workspace-based search when clientId changes after reload,
* then to localStorage when sessionStorage is empty (browser restart).
*/
export function readActivePath(
clientId: string,
targetWorkspaceId?: string
): ActivePathPointer | null {
return readSessionPointer<ActivePathPointer>(
StorageKeys.activePath(clientId),
StorageKeys.prefixes.activePath,
targetWorkspaceId
return (
readSessionPointer<ActivePathPointer>(
StorageKeys.activePath(clientId),
StorageKeys.prefixes.activePath,
targetWorkspaceId
) ??
(targetWorkspaceId
? readLocalPointer<ActivePathPointer>(
StorageKeys.lastActivePath(targetWorkspaceId),
isValidActivePathPointer
)
: null)
)
}
/**
* Writes the active path pointer to sessionStorage.
* Writes the active path pointer to both sessionStorage (tab-scoped)
* and localStorage (survives browser restart).
*/
export function writeActivePath(
clientId: string,
pointer: ActivePathPointer
): void {
try {
const key = StorageKeys.activePath(clientId)
sessionStorage.setItem(key, JSON.stringify(pointer))
} catch {
// Best effort - ignore errors
}
const json = JSON.stringify(pointer)
writeStorage(sessionStorage, StorageKeys.activePath(clientId), json)
writeStorage(
localStorage,
StorageKeys.lastActivePath(pointer.workspaceId),
json
)
}
/**
* Reads the open paths pointer from sessionStorage.
* Falls back to workspace-based search when clientId changes after reload.
* Falls back to workspace-based search when clientId changes after reload,
* then to localStorage when sessionStorage is empty (browser restart).
*/
export function readOpenPaths(
clientId: string,
targetWorkspaceId?: string
): OpenPathsPointer | null {
return readSessionPointer<OpenPathsPointer>(
StorageKeys.openPaths(clientId),
StorageKeys.prefixes.openPaths,
targetWorkspaceId
return (
readSessionPointer<OpenPathsPointer>(
StorageKeys.openPaths(clientId),
StorageKeys.prefixes.openPaths,
targetWorkspaceId
) ??
(targetWorkspaceId
? readLocalPointer<OpenPathsPointer>(
StorageKeys.lastOpenPaths(targetWorkspaceId),
isValidOpenPathsPointer
)
: null)
)
}
/**
* Writes the open paths pointer to sessionStorage.
* Writes the open paths pointer to both sessionStorage (tab-scoped)
* and localStorage (survives browser restart).
*/
export function writeOpenPaths(
clientId: string,
pointer: OpenPathsPointer
): void {
const json = JSON.stringify(pointer)
writeStorage(sessionStorage, StorageKeys.openPaths(clientId), json)
writeStorage(
localStorage,
StorageKeys.lastOpenPaths(pointer.workspaceId),
json
)
}
function hasWorkspaceId(obj: Record<string, unknown>): boolean {
return typeof obj.workspaceId === 'string'
}
function isValidActivePathPointer(value: unknown): value is ActivePathPointer {
if (typeof value !== 'object' || value === null) return false
const obj = value as Record<string, unknown>
return hasWorkspaceId(obj) && typeof obj.path === 'string'
}
function isValidOpenPathsPointer(value: unknown): value is OpenPathsPointer {
if (typeof value !== 'object' || value === null) return false
const obj = value as Record<string, unknown>
return (
hasWorkspaceId(obj) &&
Array.isArray(obj.paths) &&
typeof obj.activeIndex === 'number'
)
}
function readLocalPointer<T>(
key: string,
validate: (value: unknown) => value is T
): T | null {
try {
const key = StorageKeys.openPaths(clientId)
sessionStorage.setItem(key, JSON.stringify(pointer))
const json = localStorage.getItem(key)
if (!json) return null
const parsed = JSON.parse(json)
return validate(parsed) ? parsed : null
} catch {
// Best effort - ignore errors
return null
}
}
function writeStorage(storage: Storage, key: string, value: string): void {
try {
storage.setItem(key, value)
} catch {
// Best effort — silently degrade when storage is full or unavailable
}
}
@@ -317,7 +381,9 @@ export function clearAllV2Storage(): void {
const prefixes = [
StorageKeys.prefixes.draftIndex,
StorageKeys.prefixes.draftPayload
StorageKeys.prefixes.draftPayload,
StorageKeys.prefixes.lastActivePath,
StorageKeys.prefixes.lastOpenPaths
]
try {

View File

@@ -72,6 +72,19 @@ export const StorageKeys = {
return `Comfy.Workflow.OpenPaths:${clientId}`
},
/**
* localStorage copies of tab pointers for cross-session restore.
* sessionStorage is per-tab (correct for in-session use) but lost
* on browser restart; these keys preserve the last-written state.
*/
lastActivePath(workspaceId: string): string {
return `Comfy.Workflow.LastActivePath:${workspaceId}`
},
lastOpenPaths(workspaceId: string): string {
return `Comfy.Workflow.LastOpenPaths:${workspaceId}`
},
/**
* Prefix patterns for cleanup operations.
*/
@@ -79,6 +92,8 @@ export const StorageKeys = {
draftIndex: 'Comfy.Workflow.DraftIndex.v2:',
draftPayload: 'Comfy.Workflow.Draft.v2:',
activePath: 'Comfy.Workflow.ActivePath:',
openPaths: 'Comfy.Workflow.OpenPaths:'
openPaths: 'Comfy.Workflow.OpenPaths:',
lastActivePath: 'Comfy.Workflow.LastActivePath:',
lastOpenPaths: 'Comfy.Workflow.LastOpenPaths:'
}
} as const

View File

@@ -4,6 +4,7 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ImageLightbox from '@/components/common/ImageLightbox.vue'
import { useClickDragGuard } from '@/composables/useClickDragGuard'
import { cn } from '@/utils/tailwindUtil'
defineOptions({ inheritAttrs: false })
@@ -30,26 +31,17 @@ const {
const dropZoneRef = ref<HTMLElement | null>(null)
const canAcceptDrop = ref(false)
const pointerStart = ref<{ x: number; y: number } | null>(null)
const clickGuard = useClickDragGuard(5)
const lightboxOpen = ref(false)
function onPointerDown(e: PointerEvent) {
pointerStart.value = { x: e.clientX, y: e.clientY }
clickGuard.recordStart(e)
}
function onIndicatorClick(e: MouseEvent) {
if (e.detail !== 0) {
const start = pointerStart.value
if (start) {
const dx = e.clientX - start.x
const dy = e.clientY - start.y
if (dx * dx + dy * dy > 25) {
pointerStart.value = null
return
}
}
}
pointerStart.value = null
const dragged = e.detail !== 0 && clickGuard.wasDragged(e)
clickGuard.reset()
if (dragged) return
dropIndicator?.onClick?.(e)
}

View File

@@ -2,19 +2,29 @@
import { ref, useTemplateRef } from 'vue'
import ZoomPane from '@/components/ui/ZoomPane.vue'
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
import { cn } from '@/utils/tailwindUtil'
const { executionStatusMessage } = useExecutionStatus()
defineOptions({ inheritAttrs: false })
const { src } = defineProps<{
const { src, showSize = true } = defineProps<{
src: string
mobile?: boolean
label?: string
showSize?: boolean
}>()
const imageRef = useTemplateRef('imageRef')
const width = ref('')
const height = ref('')
const width = ref<number | null>(null)
const height = ref<number | null>(null)
function onImageLoad() {
if (!imageRef.value || !showSize) return
width.value = imageRef.value.naturalWidth
height.value = imageRef.value.naturalHeight
}
</script>
<template>
<ZoomPane
@@ -27,13 +37,7 @@ const height = ref('')
:src
v-bind="slotProps"
class="size-full object-contain"
@load="
() => {
if (!imageRef) return
width = `${imageRef.naturalWidth}`
height = `${imageRef.naturalHeight}`
}
"
@load="onImageLoad"
/>
</ZoomPane>
<img
@@ -41,15 +45,15 @@ const height = ref('')
ref="imageRef"
class="grow object-contain contain-size"
:src
@load="
() => {
if (!imageRef) return
width = `${imageRef.naturalWidth}`
height = `${imageRef.naturalHeight}`
}
"
@load="onImageLoad"
/>
<span class="self-center md:z-10">
<span
v-if="executionStatusMessage"
class="animate-pulse self-center text-muted md:z-10"
>
{{ executionStatusMessage }}
</span>
<span v-else-if="width && height" class="self-center md:z-10">
{{ `${width} x ${height}` }}
<template v-if="label"> | {{ label }}</template>
</span>

View File

@@ -1,7 +1,23 @@
<script setup lang="ts">
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
const { executionStatusMessage } = useExecutionStatus()
</script>
<template>
<div class="flex min-h-0 w-full flex-1 items-center justify-center">
<div
class="skeleton-shimmer aspect-square size-[min(50vw,50vh)] rounded-lg"
/>
<div
class="sz-full flex min-h-0 flex-1 flex-col items-center justify-center gap-3"
>
<div class="flex h-full items-center justify-center">
<div
class="skeleton-shimmer aspect-square size-[min(50vw,50vh)] rounded-lg"
/>
</div>
<span
v-if="executionStatusMessage"
class="animate-pulse text-sm text-muted"
>
{{ executionStatusMessage }}
</span>
</div>
</template>

View File

@@ -136,6 +136,7 @@ async function rerun(e: Event) {
v-if="canShowPreview && latentPreview"
:mobile
:src="latentPreview"
:show-size="false"
/>
<MediaOutputPreview
v-else-if="selectedOutput"

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { getExecutionStatusMessage } from './getExecutionStatusMessage'
// Pass-through t so we can assert the i18n key
const t = (key: string) => key
describe('getExecutionStatusMessage', () => {
describe('custom messages', () => {
it('returns custom message from properties when set', () => {
expect(
getExecutionStatusMessage(t, 'KSampler', null, {
'Execution Message': 'custom status'
})
).toBe('custom status')
})
it('ignores empty or whitespace-only custom message', () => {
expect(
getExecutionStatusMessage(t, 'KSampler', null, {
'Execution Message': ' '
})
).toBe('execution.generating')
})
})
describe('API nodes', () => {
it('returns processing for API nodes', () => {
const apiDef = { api_node: true } as ComfyNodeDefImpl
expect(getExecutionStatusMessage(t, 'SomeApiNode', apiDef)).toBe(
'execution.processing'
)
})
it('statusMap takes precedence over api_node flag', () => {
const apiDef = { api_node: true } as ComfyNodeDefImpl
expect(getExecutionStatusMessage(t, 'KSampler', apiDef)).toBe(
'execution.generating'
)
})
})
describe('Node type matching', () => {
it('does not match partial PascalCase words', () => {
expect(getExecutionStatusMessage(t, 'Loads')).toBeNull()
})
it('matches identifier mid-string at PascalCase boundary', () => {
expect(getExecutionStatusMessage(t, 'CompositeSaveImage')).toBe(
'execution.saving'
)
})
it('matches identifier followed by non-letter characters', () => {
expect(getExecutionStatusMessage(t, 'Save_V2')).toBe('execution.saving')
expect(getExecutionStatusMessage(t, 'LoadImage🐍')).toBe(
'execution.loading'
)
})
const testNodeTypes: [string, string[]][] = [
['generating', ['KSampler', 'SamplerCustomAdvanced']],
[
'saving',
['SaveImage', 'SaveAnimatedWEBP', 'PreviewImage', 'MaskPreview']
],
['loading', ['LoadImage', 'VAELoader', 'CheckpointLoaderSimple']],
[
'encoding',
['VAEEncode', 'StableCascade_StageC_VAEEncode', 'CLIPTextEncode']
],
['decoding', ['VAEDecode', 'VAEDecodeHunyuan3D']],
[
'resizing',
['ImageUpscaleWithModel', 'LatentUpscale', 'ResizeImageMaskNode']
],
[
'processing',
['TorchCompileModel', 'SVD_img2vid_Conditioning', 'ModelMergeSimple']
],
['generatingVideo', ['WanImageToVideo', 'WanFunControlToVideo']],
['processingVideo', ['Video Slice', 'CreateVideo']],
['training', ['TrainLoraNode']]
]
it.for(
testNodeTypes.flatMap(([status, nodes]) =>
nodes.map((node) => [status, node] as const)
)
)('%s ← %s', ([status, nodeType]) => {
expect(getExecutionStatusMessage(t, nodeType)).toBe(`execution.${status}`)
})
})
it('returns null for nodes matching no pattern', () => {
expect(getExecutionStatusMessage(t, 'PrimitiveString')).toBeNull()
})
})

View File

@@ -0,0 +1,67 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
type ExecutionStatusKey =
| 'generating'
| 'saving'
| 'loading'
| 'encoding'
| 'decoding'
| 'processing'
| 'resizing'
| 'generatingVideo'
| 'processingVideo'
| 'training'
/**
* Specific status messages for nodes that can't be matched by PascalCase
* identifier patterns (e.g. unconventional naming, spaces).
*/
const statusMap: Record<string, ExecutionStatusKey> = {
// Video utility nodes with non-standard naming
'Video Slice': 'processingVideo',
GetVideoComponents: 'processingVideo',
CreateVideo: 'processingVideo',
// Training
TrainLoraNode: 'training'
}
/**
* Matches a PascalCase identifier within a node type name.
*/
function pascalId(...ids: string[]): RegExp {
return new RegExp('(?:' + ids.join('|') + ')(?![a-z])')
}
const identifierRules: [RegExp, ExecutionStatusKey][] = [
[pascalId('Save', 'Preview'), 'saving'],
[pascalId('Load', 'Loader'), 'loading'],
[pascalId('Encode'), 'encoding'],
[pascalId('Decode'), 'decoding'],
[pascalId('Compile', 'Conditioning', 'Merge'), 'processing'],
[pascalId('Upscale', 'Resize'), 'resizing'],
[pascalId('ToVideo'), 'generatingVideo'],
[pascalId('Sampler'), 'generating']
]
export function getExecutionStatusMessage(
t: (key: string) => string,
nodeType: string,
nodeDef?: ComfyNodeDefImpl | null,
properties?: Record<string, unknown>
): string | null {
const customMessage = properties?.['Execution Message']
if (typeof customMessage === 'string' && customMessage.trim()) {
return customMessage.trim()
}
if (nodeType in statusMap) return t(`execution.${statusMap[nodeType]}`)
for (const [pattern, key] of identifierRules) {
if (pattern.test(nodeType)) return t(`execution.${key}`)
}
if (nodeDef?.api_node) return t('execution.processing')
return null
}

View File

@@ -0,0 +1,41 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { getExecutionStatusMessage } from '@/renderer/extensions/linearMode/getExecutionStatusMessage'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
executionIdToNodeLocatorId,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
function resolveStatus(
t: (key: string) => string,
nodeDefStore: ReturnType<typeof useNodeDefStore>,
executionId: string | number
): string | null {
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
if (!locatorId) return null
const node = getNodeByLocatorId(app.rootGraph, locatorId)
const nodeType = node?.type
if (!nodeType) return null
const nodeDef = nodeDefStore.nodeDefsByName[nodeType] ?? null
return getExecutionStatusMessage(t, nodeType, nodeDef, node.properties)
}
export function useExecutionStatus() {
const { t } = useI18n()
const executionStore = useExecutionStore()
const nodeDefStore = useNodeDefStore()
const executionStatusMessage = computed<string | null>(() => {
const executionId = executionStore.executingNodeId
if (!executionId) return null
return resolveStatus(t, nodeDefStore, executionId) || t('execution.running')
})
return { executionStatusMessage }
}

View File

@@ -101,7 +101,11 @@
<!-- Video Dimensions -->
<div class="mt-2 text-center text-xs text-muted-foreground">
<span v-if="videoError" class="text-red-400">
<span
v-if="videoError"
class="text-red-400"
data-testid="error-loading-video"
>
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="showLoader" class="text-smoke-400">

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -31,7 +31,9 @@ const i18n = createI18n({
imageFailedToLoad: 'Image failed to load',
imageDoesNotExist: 'Image does not exist',
unknownFile: 'Unknown file',
loading: 'Loading'
loading: 'Loading',
viewGrid: 'Grid view',
galleryThumbnail: 'Gallery thumbnail'
}
}
}
@@ -69,6 +71,17 @@ describe('ImagePreview', () => {
return wrapper
}
/** Switch a multi-image wrapper from default grid mode to gallery mode */
async function switchToGallery(wrapper: VueWrapper) {
const thumbnails = wrapper.findAll('button[aria-label^="View image"]')
await thumbnails[0].trigger('click')
await nextTick()
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
wrapperRegistry.forEach((wrapper) => {
wrapper.unmount()
@@ -76,30 +89,23 @@ describe('ImagePreview', () => {
wrapperRegistry.clear()
})
it('renders image preview when imageUrls provided', () => {
const wrapper = mountImagePreview()
expect(wrapper.find('.image-preview').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(true)
expect(wrapper.find('img').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
})
it('does not render when no imageUrls provided', () => {
const wrapper = mountImagePreview({ imageUrls: [] })
expect(wrapper.find('.image-preview').exists()).toBe(false)
})
it('displays calculating dimensions text initially', () => {
const wrapper = mountImagePreview()
it('displays calculating dimensions text in gallery mode', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
expect(wrapper.text()).toContain('Calculating dimensions')
})
it('shows navigation dots for multiple images', () => {
it('shows navigation dots for multiple images in gallery mode', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
expect(navigationDots).toHaveLength(2)
@@ -114,113 +120,23 @@ describe('ImagePreview', () => {
expect(navigationDots).toHaveLength(0)
})
it('shows action buttons on hover', async () => {
const wrapper = mountImagePreview()
// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)
// Trigger hover on the image wrapper (the element with role="img" has the hover handlers)
const imageWrapper = wrapper.find('[role="img"]')
await imageWrapper.trigger('mouseenter')
await nextTick()
// Action buttons should now be visible
expect(wrapper.find('.actions').exists()).toBe(true)
// For multiple images: download and remove buttons (no mask button)
expect(wrapper.find('[aria-label="Download image"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Remove image"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Edit or mask image"]').exists()).toBe(
false
)
})
it('hides action buttons when not hovering', async () => {
const wrapper = mountImagePreview()
const imageWrapper = wrapper.find('[role="img"]')
// Trigger hover
await imageWrapper.trigger('mouseenter')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)
// Trigger mouse leave
await imageWrapper.trigger('mouseleave')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})
it('shows action buttons on focus', async () => {
const wrapper = mountImagePreview()
// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)
// Trigger focusin on the image wrapper (useFocusWithin listens to focusin/focusout)
const imageWrapper = wrapper.find('[role="img"]')
await imageWrapper.trigger('focusin')
await nextTick()
// Action buttons should now be visible
expect(wrapper.find('.actions').exists()).toBe(true)
})
it('hides action buttons on blur', async () => {
const wrapper = mountImagePreview()
const imageWrapper = wrapper.find('[role="img"]')
// Trigger focus
await imageWrapper.trigger('focusin')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)
// Trigger focusout
await imageWrapper.trigger('focusout')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})
it('shows mask/edit button only for single images', async () => {
// Multiple images - should not show mask button
// Multiple images in gallery mode - should not show mask button
const multipleImagesWrapper = mountImagePreview()
await multipleImagesWrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
await switchToGallery(multipleImagesWrapper)
const maskButtonMultiple = multipleImagesWrapper.find(
'[aria-label="Edit or mask image"]'
)
expect(maskButtonMultiple.exists()).toBe(false)
expect(
multipleImagesWrapper.find('[aria-label="Edit or mask image"]').exists()
).toBe(false)
// Single image - should show mask button
const singleImageWrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await singleImageWrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
const maskButtonSingle = singleImageWrapper.find(
'[aria-label="Edit or mask image"]'
)
expect(maskButtonSingle.exists()).toBe(true)
})
it('handles action button clicks', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
// Test Edit/Mask button - just verify it can be clicked without errors
const editButton = wrapper.find('[aria-label="Edit or mask image"]')
expect(editButton.exists()).toBe(true)
await editButton.trigger('click')
// Test Remove button - just verify it can be clicked without errors
const removeButton = wrapper.find('[aria-label="Remove image"]')
expect(removeButton.exists()).toBe(true)
await removeButton.trigger('click')
expect(
singleImageWrapper.find('[aria-label="Edit or mask image"]').exists()
).toBe(true)
})
it('handles download button click', async () => {
@@ -228,20 +144,16 @@ describe('ImagePreview', () => {
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
// Test Download button
const downloadButton = wrapper.find('[aria-label="Download image"]')
expect(downloadButton.exists()).toBe(true)
await downloadButton.trigger('click')
// Verify the mocked downloadFile was called
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
})
it('switches images when navigation dots are clicked', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
// Initially shows first image
expect(wrapper.find('img').attributes('src')).toBe(
@@ -253,14 +165,14 @@ describe('ImagePreview', () => {
await navigationDots[1].trigger('click')
await nextTick()
// Now should show second image
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1])
expect(wrapper.find('img').attributes('src')).toBe(
defaultProps.imageUrls[1]
)
})
it('marks active navigation dot with aria-current', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
@@ -268,7 +180,6 @@ describe('ImagePreview', () => {
expect(navigationDots[0].attributes('aria-current')).toBe('true')
expect(navigationDots[1].attributes('aria-current')).toBeUndefined()
// Switch to second image
await navigationDots[1].trigger('click')
await nextTick()
@@ -277,38 +188,224 @@ describe('ImagePreview', () => {
expect(navigationDots[1].attributes('aria-current')).toBe('true')
})
it('loads image without errors', async () => {
const wrapper = mountImagePreview()
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
// Just verify the image element is properly set up
expect(img.attributes('src')).toBe(defaultProps.imageUrls[0])
})
it('has proper accessibility attributes', () => {
const wrapper = mountImagePreview()
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
const img = wrapper.find('img')
expect(img.attributes('alt')).toBe('Node output 1')
expect(img.attributes('alt')).toBe('View image 1 of 1')
})
it('updates alt text when switching images', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
// Initially first image
expect(wrapper.find('img').attributes('alt')).toBe('Node output 1')
expect(wrapper.find('img').attributes('alt')).toBe('View image 1 of 2')
// Switch to second image
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
await navigationDots[1].trigger('click')
await nextTick()
// Alt text should update
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('alt')).toBe('Node output 2')
expect(wrapper.find('img').attributes('alt')).toBe('View image 2 of 2')
})
describe('keyboard navigation', () => {
it('navigates to next image with ArrowRight', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
defaultProps.imageUrls[1]
)
})
it('navigates to previous image with ArrowLeft', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
})
it('wraps around from last to first with ArrowRight', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
})
it('wraps around from first to last with ArrowLeft', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
defaultProps.imageUrls[1]
)
})
it('navigates to first image with Home', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
await wrapper.find('.image-preview').trigger('keydown', { key: 'Home' })
await nextTick()
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
})
it('navigates to last image with End', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
await wrapper.find('.image-preview').trigger('keydown', { key: 'End' })
await nextTick()
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
defaultProps.imageUrls[1]
)
})
it('ignores arrow keys in grid mode', async () => {
const wrapper = mountImagePreview()
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
expect(gridThumbnails).toHaveLength(2)
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
expect(wrapper.find('[role="region"]').exists()).toBe(false)
})
it('ignores arrow keys for single image', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
const initialSrc = wrapper.find('img').attributes('src')
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
expect(wrapper.find('img').attributes('src')).toBe(initialSrc)
})
})
describe('grid view', () => {
it('defaults to grid mode for multiple images', () => {
const wrapper = mountImagePreview()
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
expect(gridThumbnails).toHaveLength(2)
})
it('defaults to gallery mode for single image', () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
expect(wrapper.find('[role="region"]').exists()).toBe(true)
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
expect(gridThumbnails).toHaveLength(0)
})
it('switches to gallery mode when grid thumbnail is clicked', async () => {
const wrapper = mountImagePreview()
const thumbnails = wrapper.findAll('button[aria-label^="View image"]')
await thumbnails[1].trigger('click')
await nextTick()
const mainImg = wrapper.find('[data-testid="main-image"]')
expect(mainImg.exists()).toBe(true)
expect(mainImg.attributes('src')).toBe(defaultProps.imageUrls[1])
})
it('shows back-to-grid button next to navigation dots', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
const gridButton = wrapper.find('[aria-label="Grid view"]')
expect(gridButton.exists()).toBe(true)
})
it('switches back to grid mode via back-to-grid button', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
const gridButton = wrapper.find('[aria-label="Grid view"]')
await gridButton.trigger('click')
await nextTick()
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
expect(gridThumbnails).toHaveLength(2)
})
it('resets to grid mode when URLs change to multiple images', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
// Verify we're in gallery mode
expect(wrapper.find('[role="region"]').exists()).toBe(true)
// Change URLs
await wrapper.setProps({
imageUrls: [
'/api/view?filename=new1.png&type=output',
'/api/view?filename=new2.png&type=output',
'/api/view?filename=new3.png&type=output'
]
})
await nextTick()
// Should be back in grid mode
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
expect(gridThumbnails).toHaveLength(3)
})
})
describe('batch cycling with identical URLs', () => {
@@ -319,6 +416,7 @@ describe('ImagePreview', () => {
const wrapper = mountImagePreview({
imageUrls: [sameUrl, sameUrl, sameUrl]
})
await switchToGallery(wrapper)
// Simulate initial image load
await wrapper.find('img').trigger('load')
@@ -365,8 +463,7 @@ describe('ImagePreview', () => {
await vi.advanceTimersByTimeAsync(300)
await nextTick()
// Loading state should NOT have been reset - aria-busy should still be false
// because the URLs are identical (just a new array reference)
// Loading state should NOT have been reset
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
} finally {
vi.useRealTimers()
@@ -406,16 +503,13 @@ describe('ImagePreview', () => {
it('should handle empty to non-empty URL transitions correctly', async () => {
const wrapper = mountImagePreview({ imageUrls: [] })
// No preview initially
expect(wrapper.find('.image-preview').exists()).toBe(false)
// Add URLs
await wrapper.setProps({
imageUrls: ['/api/view?filename=test.png&type=output']
})
await nextTick()
// Preview should appear
expect(wrapper.find('.image-preview').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(true)
})

View File

@@ -4,18 +4,45 @@
class="image-preview group relative flex size-full min-h-55 min-w-16 flex-col justify-center px-2"
@keydown="handleKeyDown"
>
<!-- Image Wrapper -->
<!-- Grid View -->
<div
ref="imageWrapperEl"
class="relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
v-if="viewMode === 'grid'"
data-testid="image-grid"
class="group/panel relative grid w-full gap-1 overflow-hidden rounded-sm p-1"
:style="{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }"
>
<button
v-for="(url, index) in imageUrls"
:key="index"
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
:aria-label="
$t('g.viewImageOfTotal', {
index: index + 1,
total: imageUrls.length
})
"
@pointerdown="trackPointerStart"
@click="handleGridThumbnailClick($event, index)"
>
<img
:src="url"
:alt="`${$t('g.galleryThumbnail')} ${index + 1}`"
draggable="false"
class="pointer-events-none size-full object-contain"
/>
</button>
</div>
<!-- Gallery View (Image Wrapper) -->
<div
v-if="viewMode === 'gallery'"
ref="galleryPanelEl"
class="group/panel relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
tabindex="0"
role="img"
role="region"
:aria-roledescription="$t('g.imageGallery')"
:aria-label="$t('g.imagePreview')"
:aria-busy="showLoader"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@focusin="handleFocusIn"
@focusout="handleFocusOut"
>
<!-- Error State -->
<div
@@ -38,22 +65,18 @@
<!-- Main Image -->
<img
v-if="!imageError"
data-testid="main-image"
:src="currentImageUrl"
:alt="imageAltText"
:class="
cn(
'pointer-events-none absolute inset-0 block size-full object-contain transition-opacity',
(isHovered || isFocused) && 'opacity-60'
)
"
draggable="false"
class="pointer-events-none absolute inset-0 block size-full object-contain"
@load="handleImageLoad"
@error="handleImageError"
/>
<!-- Floating Action Buttons (appear on hover and focus) -->
<div
v-if="isHovered || isFocused"
class="actions absolute top-2 right-2 flex gap-1"
class="actions invisible absolute top-2 right-2 flex gap-1 group-focus-within/panel:visible group-hover/panel:visible"
>
<!-- Mask/Edit Button -->
<button
@@ -76,21 +99,29 @@
<i class="icon-[lucide--download] size-4" />
</button>
<!-- Close Button -->
<!-- Back to Grid Button -->
<button
v-if="hasMultipleImages"
:class="actionButtonClass"
:title="$t('g.removeImage')"
:aria-label="$t('g.removeImage')"
@click="handleRemove"
:title="$t('g.viewGrid')"
:aria-label="$t('g.viewGrid')"
@click="viewMode = 'grid'"
>
<i class="icon-[lucide--circle-x] size-4" />
<i class="icon-[lucide--layout-grid] size-4" />
</button>
</div>
</div>
<!-- Image Dimensions -->
<div class="pt-2 text-center text-xs text-base-foreground">
<span v-if="imageError" class="text-red-400">
<!-- Image Dimensions (gallery mode only) -->
<div
v-if="viewMode === 'gallery'"
class="pt-2 text-center text-xs text-base-foreground"
>
<span
v-if="imageError"
class="text-error"
data-testid="error-loading-image"
>
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="showLoader" class="text-base-foreground">
@@ -100,11 +131,23 @@
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
<!-- Multiple Images Navigation -->
<!-- Multiple Images Navigation (gallery mode only) -->
<div
v-if="hasMultipleImages"
class="flex flex-wrap justify-center gap-1 pt-4"
v-if="viewMode === 'gallery' && hasMultipleImages"
class="flex flex-wrap items-center justify-center gap-1 pt-4"
>
<!-- Back to Grid button -->
<button
class="mr-1 flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0.5 text-base-foreground/50 transition-colors hover:text-base-foreground"
:title="$t('g.viewGrid')"
:aria-label="$t('g.viewGrid')"
@click="viewMode = 'grid'"
>
<i class="icon-[lucide--layout-grid] size-3.5" />
</button>
<!-- Navigation Dots -->
<button
v-for="(_, index) in imageUrls"
:key="index"
@@ -124,7 +167,7 @@
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
@@ -142,7 +185,7 @@ interface ImagePreviewProps {
readonly nodeId?: string
}
const props = defineProps<ImagePreviewProps>()
const { imageUrls, nodeId } = defineProps<ImagePreviewProps>()
const { t } = useI18n()
const maskEditor = useMaskEditor()
@@ -152,16 +195,19 @@ const toastStore = useToastStore()
const actionButtonClass =
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
// Component state
type ViewMode = 'gallery' | 'grid'
function defaultViewMode(urls: readonly string[]): ViewMode {
return urls.length > 1 ? 'grid' : 'gallery'
}
const currentIndex = ref(0)
const isHovered = ref(false)
const isFocused = ref(false)
const viewMode = ref<ViewMode>(defaultViewMode(imageUrls))
const galleryPanelEl = ref<HTMLDivElement>()
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)
const imageWrapperEl = ref<HTMLDivElement>()
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
() => {
showLoader.value = true
@@ -171,14 +217,23 @@ const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
{ immediate: false }
)
// Computed values
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
const currentImageUrl = computed(() => imageUrls[currentIndex.value] ?? '')
const hasMultipleImages = computed(() => imageUrls.length > 1)
const imageAltText = computed(() =>
t('g.viewImageOfTotal', {
index: currentIndex.value + 1,
total: imageUrls.length
})
)
const gridCols = computed(() => {
const count = imageUrls.length
if (count <= 4) return 2
if (count <= 9) return 3
return 4
})
// Watch for URL changes and reset state
watch(
() => props.imageUrls,
() => imageUrls,
(newUrls, oldUrls) => {
// Only reset state if URLs actually changed (not just array reference)
const urlsChanged =
@@ -196,14 +251,14 @@ watch(
// Reset loading and error states when URLs change
actualDimensions.value = null
viewMode.value = defaultViewMode(newUrls)
imageError.value = false
if (newUrls.length > 0) startDelayedLoader()
},
{ immediate: true }
)
// Event handlers
const handleImageLoad = (event: Event) => {
function handleImageLoad(event: Event) {
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
stopDelayedLoader()
@@ -213,29 +268,29 @@ const handleImageLoad = (event: Event) => {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
if (props.nodeId) {
nodeOutputStore.syncLegacyNodeImgs(props.nodeId, img, currentIndex.value)
if (nodeId) {
nodeOutputStore.syncLegacyNodeImgs(nodeId, img, currentIndex.value)
}
}
const handleImageError = () => {
function handleImageError() {
stopDelayedLoader()
showLoader.value = false
imageError.value = true
actualDimensions.value = null
}
const handleEditMask = () => {
if (!props.nodeId) return
const node = resolveNode(Number(props.nodeId))
function handleEditMask() {
if (!nodeId) return
const node = resolveNode(Number(nodeId))
if (!node) return
maskEditor.openMaskEditor(node)
}
const handleDownload = () => {
function handleDownload() {
try {
downloadFile(currentImageUrl.value)
} catch (error) {
} catch {
toastStore.add({
severity: 'error',
summary: t('g.error'),
@@ -244,46 +299,35 @@ const handleDownload = () => {
}
}
const handleRemove = () => {
if (!props.nodeId) return
const node = resolveNode(Number(props.nodeId))
nodeOutputStore.removeNodeOutputs(props.nodeId)
if (node) {
node.imgs = undefined
const imageWidget = node.widgets?.find((w) => w.name === 'image')
if (imageWidget) {
imageWidget.value = ''
}
}
}
const setCurrentIndex = (index: number) => {
function setCurrentIndex(index: number) {
if (currentIndex.value === index) return
if (index >= 0 && index < props.imageUrls.length) {
const urlChanged = props.imageUrls[index] !== currentImageUrl.value
if (index >= 0 && index < imageUrls.length) {
const urlChanged = imageUrls[index] !== currentImageUrl.value
currentIndex.value = index
imageError.value = false
if (urlChanged) startDelayedLoader()
}
}
const handleMouseEnter = () => {
isHovered.value = true
const CLICK_THRESHOLD = 3
let pointerStartPos = { x: 0, y: 0 }
function trackPointerStart(event: PointerEvent) {
pointerStartPos = { x: event.clientX, y: event.clientY }
}
const handleMouseLeave = () => {
isHovered.value = false
function handleGridThumbnailClick(event: MouseEvent, index: number) {
const dx = event.clientX - pointerStartPos.x
const dy = event.clientY - pointerStartPos.y
if (Math.abs(dx) > CLICK_THRESHOLD || Math.abs(dy) > CLICK_THRESHOLD) return
openImageInGallery(index)
}
const handleFocusIn = () => {
isFocused.value = true
}
const handleFocusOut = (event: FocusEvent) => {
// Only unfocus if focus is leaving the wrapper entirely
if (!imageWrapperEl.value?.contains(event.relatedTarget as Node)) {
isFocused.value = false
}
async function openImageInGallery(index: number) {
setCurrentIndex(index)
viewMode.value = 'gallery'
await nextTick()
galleryPanelEl.value?.focus()
}
function getNavigationDotClass(index: number) {
@@ -295,24 +339,30 @@ function getNavigationDotClass(index: number) {
)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (props.imageUrls.length <= 1) return
function handleKeyDown(event: KeyboardEvent) {
if (
event.key === 'Escape' &&
viewMode.value === 'gallery' &&
hasMultipleImages.value
) {
event.preventDefault()
viewMode.value = 'grid'
return
}
if (imageUrls.length <= 1 || viewMode.value === 'grid') return
switch (event.key) {
case 'ArrowLeft':
event.preventDefault()
setCurrentIndex(
currentIndex.value > 0
? currentIndex.value - 1
: props.imageUrls.length - 1
currentIndex.value > 0 ? currentIndex.value - 1 : imageUrls.length - 1
)
break
case 'ArrowRight':
event.preventDefault()
setCurrentIndex(
currentIndex.value < props.imageUrls.length - 1
? currentIndex.value + 1
: 0
currentIndex.value < imageUrls.length - 1 ? currentIndex.value + 1 : 0
)
break
case 'Home':
@@ -321,12 +371,12 @@ const handleKeyDown = (event: KeyboardEvent) => {
break
case 'End':
event.preventDefault()
setCurrentIndex(props.imageUrls.length - 1)
setCurrentIndex(imageUrls.length - 1)
break
}
}
const getImageFilename = (url: string): string => {
function getImageFilename(url: string): string {
if (!url) return t('g.imageDoesNotExist')
try {
return new URL(url).searchParams.get('filename') || t('g.unknownFile')

View File

@@ -304,6 +304,7 @@ import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeS
import { app } from '@/scripts/app'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isVideoOutput } from '@/utils/litegraphUtil'
@@ -355,6 +356,7 @@ const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const hasExecutionError = computed(
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
)
@@ -368,7 +370,7 @@ const hasAnyError = computed((): boolean => {
missingModelStore.hasMissingModelOnNode(nodeLocatorId.value) ||
(lgraphNode.value &&
(executionErrorStore.isContainerWithInternalError(lgraphNode.value) ||
executionErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
missingNodesErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
missingModelStore.isContainerWithMissingModel(lgraphNode.value)))
)
})

View File

@@ -28,7 +28,7 @@
'-z-10 bg-node-component-header-surface'
)
"
:style="{ backgroundColor: headerColor }"
:style="headerColorStyle"
@click.stop="$emit('enterSubgraph')"
>
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
@@ -73,6 +73,7 @@
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
@@ -124,7 +125,7 @@
'-z-10 bg-node-component-header-surface'
)
"
:style="{ backgroundColor: headerColor }"
:style="headerColorStyle"
@click.stop="$emit('enterSubgraph')"
>
<div class="flex size-full items-center justify-center gap-2">
@@ -145,6 +146,7 @@
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
@@ -233,6 +235,10 @@ const getTabStyles = (isBackground = false) => {
)
}
const headerColorStyle = computed(() =>
props.headerColor ? { backgroundColor: props.headerColor } : undefined
)
// Case 1 context: Split widths
const errorTabWidth = 'w-[calc(50%+4px)]'
const enterTabFullWidth = 'w-[calc(100%+8px)]'

View File

@@ -412,7 +412,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widgetState?.label,
label: widget.promotedLabel ?? widgetState?.label,
linkedUpstream,
options: widgetOptions,
spec: widget.spec

View File

@@ -1,7 +1,8 @@
import { onScopeDispose, ref, toValue } from 'vue'
import { onScopeDispose, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import { useClickDragGuard } from '@/composables/useClickDragGuard'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -28,9 +29,7 @@ export function useNodePointerInteractions(
let hasDraggingStarted = false
const startPosition = ref({ x: 0, y: 0 })
const DRAG_THRESHOLD = 3 // pixels
const dragGuard = useClickDragGuard(3)
function onPointerdown(event: PointerEvent) {
if (forwardMiddlePointerIfNeeded(event)) return
@@ -57,7 +56,7 @@ export function useNodePointerInteractions(
return
}
startPosition.value = { x: event.clientX, y: event.clientY }
dragGuard.recordStart(event)
safeDragStart(event, nodeId)
}
@@ -85,11 +84,7 @@ export function useNodePointerInteractions(
}
// Check if we should start dragging (pointer moved beyond threshold)
if (lmbDown && !layoutStore.isDraggingVueNodes.value) {
const dx = event.clientX - startPosition.value.x
const dy = event.clientY - startPosition.value.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance > DRAG_THRESHOLD) {
if (dragGuard.wasDragged(event)) {
layoutStore.isDraggingVueNodes.value = true
handleNodeSelect(event, nodeId)
}

View File

@@ -245,6 +245,7 @@ const renderPreview = (
}
// Draw individual
const img = imgs[imageIndex]
if (!img) return
let w = img.naturalWidth
let h = img.naturalHeight

View File

@@ -281,10 +281,12 @@ export interface ComfyApi extends EventTarget {
export class PromptExecutionError extends Error {
response: PromptResponse
status?: number
constructor(response: PromptResponse) {
constructor(response: PromptResponse, status?: number) {
super('Prompt execution failed')
this.response = response
this.status = status
}
override toString() {
@@ -901,7 +903,7 @@ export class ComfyApi extends EventTarget {
}
}
}
throw new PromptExecutionError(errorResponse)
throw new PromptExecutionError(errorResponse, res.status)
}
return await res.json()

View File

@@ -65,6 +65,7 @@ import { useCommandStore } from '@/stores/commandStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
@@ -83,7 +84,7 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import type { ExtensionManager } from '@/types/extensionTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
import { getCnrIdFromProperties } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { getCnrIdFromProperties } from '@/platform/nodeReplacement/cnrIdUtil'
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
import {
scanAllModelCandidates,
@@ -1105,7 +1106,9 @@ export class ComfyApp {
}
private showMissingNodesError(missingNodeTypes: MissingNodeType[]) {
useExecutionErrorStore().surfaceMissingNodes(missingNodeTypes)
if (useMissingNodesErrorStore().surfaceMissingNodes(missingNodeTypes)) {
useExecutionErrorStore().showErrorOverlay()
}
}
async loadGraphData(
@@ -1647,6 +1650,34 @@ export class ComfyApp {
) {
// Re-scan the full graph instead of using the server's single-node response.
rescanAndSurfaceMissingNodes(this.rootGraph)
} else if (
error instanceof PromptExecutionError &&
error.status === 403
) {
// User is authenticated but not authorized (e.g. not whitelisted).
// Show a clear message instead of a generic error or sign-in prompt.
// The response may be middleware JSON {"message": "..."} or the
// standard {"error": {"message": "..."}} shape, so check both.
const raw =
error.response && typeof error.response === 'object'
? (error.response as Record<string, unknown>)
: {}
const rawError =
raw.error && typeof raw.error === 'object'
? (raw.error as Record<string, unknown>)
: undefined
const detail =
typeof raw.message === 'string'
? raw.message
: typeof rawError?.message === 'string'
? rawError.message
: typeof raw.error === 'string'
? raw.error
: t('errorDialog.accessRestrictedMessage')
useDialogService().showErrorDialog(new Error(detail), {
title: t('errorDialog.accessRestrictedTitle'),
reportType: 'accessRestrictedError'
})
} else if (
!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab') ||
!(error instanceof PromptExecutionError)

View File

@@ -1,8 +1,9 @@
import { resolveBlueprintEssentialsCategory } from '@/constants/essentialsDisplayNames'
import type { EssentialsCategory } from '@/constants/essentialsNodes'
import {
ESSENTIALS_CATEGORIES,
ESSENTIALS_NODES
ESSENTIALS_CATEGORY_CANONICAL,
ESSENTIALS_CATEGORY_RANK,
ESSENTIALS_NODE_RANK
} from '@/constants/essentialsNodes'
import { t } from '@/i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -19,6 +20,34 @@ import type { TreeNode } from '@/types/treeExplorerTypes'
import { sortedTree, unwrapTreeRoot } from '@/utils/treeUtil'
const DEFAULT_ICON = 'pi pi-sort'
const UNKNOWN_RANK = Number.MAX_SAFE_INTEGER
function resolveEssentialsCategory(
nodeDef: ComfyNodeDefImpl
): EssentialsCategory | undefined {
if (!nodeDef.isCoreNode) return undefined
if (nodeDef.essentials_category) {
return (
ESSENTIALS_CATEGORY_CANONICAL.get(
nodeDef.essentials_category.toLowerCase()
) ?? (nodeDef.essentials_category as EssentialsCategory)
)
}
return resolveBlueprintEssentialsCategory(nodeDef.name)
}
function sortByKnownOrder<T>(
items: T[],
getKey: (item: T) => string | undefined,
rankMap: ReadonlyMap<string, number>
): void {
items.sort(
(a, b) =>
(rankMap.get(getKey(a) ?? '') ?? UNKNOWN_RANK) -
(rankMap.get(getKey(b) ?? '') ?? UNKNOWN_RANK)
)
}
function categoryPathExtractor(nodeDef: ComfyNodeDefImpl): string[] {
const category = nodeDef.category || ''
@@ -147,44 +176,39 @@ class NodeOrganizationService {
}
private organizeEssentials(nodes: ComfyNodeDefImpl[]): NodeSection[] {
const essentialNodes = nodes.filter(
(nodeDef) =>
!!nodeDef.essentials_category ||
!!resolveBlueprintEssentialsCategory(nodeDef.name)
)
const tree = buildNodeDefTree(essentialNodes, {
pathExtractor: (nodeDef) => {
const folder =
nodeDef.essentials_category ||
resolveBlueprintEssentialsCategory(nodeDef.name) ||
''
return folder ? [folder, nodeDef.name] : [nodeDef.name]
}
const categoryByNode = new Map<ComfyNodeDefImpl, EssentialsCategory>()
const essentialNodes = nodes.filter((node) => {
const category = resolveEssentialsCategory(node)
if (!category) return false
categoryByNode.set(node, category)
return true
})
this.sortEssentialsFolders(tree)
const tree = buildNodeDefTree(essentialNodes, {
pathExtractor: (node) => [categoryByNode.get(node)!, node.name]
})
this.sortEssentialsTree(tree)
return [{ tree }]
}
private sortEssentialsFolders(tree: TreeNode): void {
private sortEssentialsTree(tree: TreeNode): void {
if (!tree.children) return
const catLen = ESSENTIALS_CATEGORIES.length
tree.children.sort((a, b) => {
const ai = ESSENTIALS_CATEGORIES.indexOf(a.label as EssentialsCategory)
const bi = ESSENTIALS_CATEGORIES.indexOf(b.label as EssentialsCategory)
return (ai === -1 ? catLen : ai) - (bi === -1 ? catLen : bi)
})
sortByKnownOrder(
tree.children,
(node) => node.label,
ESSENTIALS_CATEGORY_RANK
)
for (const folder of tree.children) {
if (!folder.children) continue
const order = ESSENTIALS_NODES[folder.label as EssentialsCategory]
if (!order) continue
const orderLen = order.length
folder.children.sort((a, b) => {
const ai = order.indexOf(a.data?.name ?? a.label ?? '')
const bi = order.indexOf(b.data?.name ?? b.label ?? '')
return (ai === -1 ? orderLen : ai) - (bi === -1 ? orderLen : bi)
})
const rankMap = ESSENTIALS_NODE_RANK[folder.label as EssentialsCategory]
if (!rankMap) continue
sortByKnownOrder(
folder.children,
(node) => node.data?.name ?? node.label,
rankMap
)
}
}

View File

@@ -34,193 +34,7 @@ vi.mock(
)
import { useExecutionErrorStore } from './executionErrorStore'
describe('executionErrorStore — missing node operations', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('setMissingNodeTypes', () => {
it('sets missingNodesError with provided types', () => {
const store = useExecutionErrorStore()
const types: MissingNodeType[] = [
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
]
store.setMissingNodeTypes(types)
expect(store.missingNodesError).not.toBeNull()
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.hasMissingNodes).toBe(true)
})
it('clears missingNodesError when given empty array', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(store.missingNodesError).not.toBeNull()
store.setMissingNodeTypes([])
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('deduplicates string entries by value', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
'NodeA',
'NodeA',
'NodeB'
] as MissingNodeType[])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('deduplicates object entries by nodeId when present', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '2', isReplaceable: false }
])
// Same nodeId='1' deduplicated, nodeId='2' kept
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('deduplicates object entries by type when nodeId is absent', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', isReplaceable: false },
{ type: 'NodeA', isReplaceable: true }
] as MissingNodeType[])
// Same type, no nodeId → deduplicated
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('keeps distinct nodeIds even when type is the same', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '2', isReplaceable: false },
{ type: 'NodeA', nodeId: '3', isReplaceable: false }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(3)
})
})
describe('surfaceMissingNodes', () => {
beforeEach(() => {
mockShowErrorsTab.value = false
})
it('stores missing node types when called', () => {
const store = useExecutionErrorStore()
const types: MissingNodeType[] = [
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
]
store.surfaceMissingNodes(types)
expect(store.missingNodesError).not.toBeNull()
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.hasMissingNodes).toBe(true)
})
it('opens error overlay when ShowErrorsTab setting is true', () => {
mockShowErrorsTab.value = true
const store = useExecutionErrorStore()
store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(store.isErrorOverlayOpen).toBe(true)
})
it('does not open error overlay when ShowErrorsTab setting is false', () => {
mockShowErrorsTab.value = false
const store = useExecutionErrorStore()
store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(store.isErrorOverlayOpen).toBe(false)
})
it('deduplicates node types', () => {
const store = useExecutionErrorStore()
store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
})
describe('removeMissingNodesByType', () => {
it('removes matching types from the missing nodes list', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
])
store.removeMissingNodesByType(['NodeA', 'NodeC'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
const remaining = store.missingNodesError?.nodeTypes[0]
expect(typeof remaining !== 'string' && remaining?.type).toBe('NodeB')
})
it('clears missingNodesError when all types are removed', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
store.removeMissingNodesByType(['NodeA'])
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('does nothing when missingNodesError is null', () => {
const store = useExecutionErrorStore()
expect(store.missingNodesError).toBeNull()
// Should not throw
store.removeMissingNodesByType(['NodeA'])
expect(store.missingNodesError).toBeNull()
})
it('does nothing when removing non-existent types', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
store.removeMissingNodesByType(['NonExistent'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('handles removing from string entries', () => {
const store = useExecutionErrorStore()
store.setMissingNodeTypes([
'StringNodeA',
'StringNodeB'
] as MissingNodeType[])
store.removeMissingNodesByType(['StringNodeA'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
})
})
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
describe('executionErrorStore — node error operations', () => {
beforeEach(() => {
@@ -537,16 +351,18 @@ describe('executionErrorStore — node error operations', () => {
})
describe('clearAllErrors', () => {
let store: ReturnType<typeof useExecutionErrorStore>
let executionErrorStore: ReturnType<typeof useExecutionErrorStore>
let missingNodesStore: ReturnType<typeof useMissingNodesErrorStore>
beforeEach(() => {
const pinia = createPinia()
setActivePinia(pinia)
store = useExecutionErrorStore()
executionErrorStore = useExecutionErrorStore()
missingNodesStore = useMissingNodesErrorStore()
})
it('resets all error categories and closes error overlay', () => {
store.lastExecutionError = {
executionErrorStore.lastExecutionError = {
prompt_id: 'test',
timestamp: 0,
node_id: '1',
@@ -556,8 +372,12 @@ describe('clearAllErrors', () => {
exception_type: 'RuntimeError',
traceback: []
}
store.lastPromptError = { type: 'execution', message: 'fail', details: '' }
store.lastNodeErrors = {
executionErrorStore.lastPromptError = {
type: 'execution',
message: 'fail',
details: ''
}
executionErrorStore.lastNodeErrors = {
'1': {
errors: [
{
@@ -571,19 +391,18 @@ describe('clearAllErrors', () => {
class_type: 'Test'
}
}
store.missingNodesError = {
message: 'Missing nodes',
nodeTypes: [{ type: 'MissingNode', hint: '' }]
}
store.showErrorOverlay()
missingNodesStore.setMissingNodeTypes([
{ type: 'MissingNode', hint: '' }
] as unknown as MissingNodeType[])
executionErrorStore.showErrorOverlay()
store.clearAllErrors()
executionErrorStore.clearAllErrors()
expect(store.lastExecutionError).toBeNull()
expect(store.lastPromptError).toBeNull()
expect(store.lastNodeErrors).toBeNull()
expect(store.missingNodesError).toBeNull()
expect(store.isErrorOverlayOpen).toBe(false)
expect(store.hasAnyError).toBe(false)
expect(executionErrorStore.lastExecutionError).toBeNull()
expect(executionErrorStore.lastPromptError).toBeNull()
expect(executionErrorStore.lastNodeErrors).toBeNull()
expect(missingNodesStore.missingNodesError).toBeNull()
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
expect(executionErrorStore.hasAnyError).toBe(false)
})
})

View File

@@ -1,122 +1,43 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeErrorFlagSync } from '@/composables/graph/useNodeErrorFlagSync'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type {
ExecutionErrorWsMessage,
NodeError,
PromptError
} from '@/schemas/apiSchema'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getAncestorExecutionIds,
getParentExecutionIds
} from '@/types/nodeIdentification'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import type { MissingNodeType } from '@/types/comfy'
import {
executionIdToNodeLocatorId,
forEachNode,
getNodeByExecutionId,
getExecutionIdByNode,
getActiveGraphNodeIds
getNodeByExecutionId
} from '@/utils/graphTraversalUtil'
import {
isValueStillOutOfRange,
SIMPLE_ERROR_TYPES
SIMPLE_ERROR_TYPES,
isValueStillOutOfRange
} from '@/utils/executionErrorUtil'
interface MissingNodesError {
message: string
nodeTypes: MissingNodeType[]
}
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
if (node.has_errors === hasErrors) return
const oldValue = node.has_errors
node.has_errors = hasErrors
node.graph?.trigger('node:property:changed', {
type: 'node:property:changed',
nodeId: node.id,
property: 'has_errors',
oldValue,
newValue: hasErrors
})
}
/**
* Single-pass reconciliation of node error flags.
* Collects the set of nodes that should have errors, then walks all nodes
* once, setting each flag exactly once. This avoids the redundant
* true→false→true transition (and duplicate events) that a clear-then-apply
* approach would cause.
*/
function reconcileNodeErrorFlags(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError> | null,
missingModelExecIds: Set<string>
): void {
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
const flaggedNodes = new Set<LGraphNode>()
const errorSlots = new Map<LGraphNode, Set<string>>()
if (nodeErrors) {
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
const node = getNodeByExecutionId(rootGraph, executionId)
if (!node) continue
flaggedNodes.add(node)
const slotNames = new Set<string>()
for (const error of nodeError.errors) {
const name = error.extra_info?.input_name
if (name) slotNames.add(name)
}
if (slotNames.size > 0) errorSlots.set(node, slotNames)
for (const parentId of getParentExecutionIds(executionId)) {
const parentNode = getNodeByExecutionId(rootGraph, parentId)
if (parentNode) flaggedNodes.add(parentNode)
}
}
}
for (const execId of missingModelExecIds) {
const node = getNodeByExecutionId(rootGraph, execId)
if (node) flaggedNodes.add(node)
}
forEachNode(rootGraph, (node) => {
setNodeHasErrors(node, flaggedNodes.has(node))
if (node.inputs) {
const nodeSlotNames = errorSlots.get(node)
for (const slot of node.inputs) {
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
}
}
})
}
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
/** Execution error state: node errors, runtime errors, prompt errors, and missing assets. */
export const useExecutionErrorStore = defineStore('executionError', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
const missingModelStore = useMissingModelStore()
const missingNodesStore = useMissingNodesErrorStore()
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
const lastPromptError = ref<PromptError | null>(null)
const missingNodesError = ref<MissingNodesError | null>(null)
const isErrorOverlayOpen = ref(false)
@@ -136,7 +57,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastExecutionError.value = null
lastPromptError.value = null
lastNodeErrors.value = null
missingNodesError.value = null
missingNodesStore.setMissingNodeTypes([])
isErrorOverlayOpen.value = false
}
@@ -238,14 +159,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
missingModelStore.removeMissingModelByWidget(executionId, widgetName)
}
/** Set missing node types and open the error overlay if the Errors tab is enabled. */
function surfaceMissingNodes(types: MissingNodeType[]) {
setMissingNodeTypes(types)
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
showErrorOverlay()
}
}
/** Set missing models and open the error overlay if the Errors tab is enabled. */
function surfaceMissingModels(models: MissingModelCandidate[]) {
missingModelStore.setMissingModels(models)
@@ -257,51 +170,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
}
}
/** Remove specific node types from the missing nodes list (e.g. after replacement). */
function removeMissingNodesByType(typesToRemove: string[]) {
if (!missingNodesError.value) return
const removeSet = new Set(typesToRemove)
const remaining = missingNodesError.value.nodeTypes.filter((node) => {
const nodeType = typeof node === 'string' ? node : node.type
return !removeSet.has(nodeType)
})
setMissingNodeTypes(remaining)
}
function setMissingNodeTypes(types: MissingNodeType[]) {
if (!types.length) {
missingNodesError.value = null
return
}
const seen = new Set<string>()
const uniqueTypes = types.filter((node) => {
// For string entries (group nodes), deduplicate by the string itself.
// For object entries, prefer nodeId so multiple instances of the same
// type are kept as separate rows; fall back to type if nodeId is absent.
const isString = typeof node === 'string'
let key: string
if (isString) {
key = node
} else if (node.nodeId != null) {
key = String(node.nodeId)
} else {
key = node.type
}
if (seen.has(key)) return false
seen.add(key)
return true
})
missingNodesError.value = {
message: isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
nodeTypes: uniqueTypes
}
}
const lastExecutionErrorNodeLocatorId = computed(() => {
const err = lastExecutionError.value
if (!err) return null
@@ -323,14 +191,12 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
)
const hasMissingNodes = computed(() => !!missingNodesError.value)
const hasAnyError = computed(
() =>
hasExecutionError.value ||
hasPromptError.value ||
hasNodeError.value ||
hasMissingNodes.value ||
missingNodesStore.hasMissingNodes ||
missingModelStore.hasMissingModels
)
@@ -361,14 +227,12 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const executionErrorCount = computed(() => (lastExecutionError.value ? 1 : 0))
const missingNodeCount = computed(() => (missingNodesError.value ? 1 : 0))
const totalErrorCount = computed(
() =>
promptErrorCount.value +
nodeErrorCount.value +
executionErrorCount.value +
missingNodeCount.value +
missingNodesStore.missingNodeCount +
missingModelStore.missingModelCount
)
@@ -400,37 +264,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return ids
})
/**
* Set of all execution ID prefixes derived from missing node execution IDs,
* including the missing nodes themselves.
*
* Example: missing node at "65:70:63" → Set { "65", "65:70", "65:70:63" }
*/
const missingAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
const ids = new Set<NodeExecutionId>()
const error = missingNodesError.value
if (!error) return ids
for (const nodeType of error.nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
for (const id of getAncestorExecutionIds(String(nodeType.nodeId))) {
ids.add(id)
}
}
return ids
})
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
if (!app.isGraphReady) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingAncestorExecutionIds.value
)
})
/** Map of node errors indexed by locator ID. */
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
() => {
@@ -493,42 +326,13 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return errorAncestorExecutionIds.value.has(execId)
}
/** True if the node has a missing node inside it at any nesting depth. */
function isContainerWithMissingNode(node: LGraphNode): boolean {
if (!app.isGraphReady) return false
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return false
return missingAncestorExecutionIds.value.has(execId)
}
watch(
[lastNodeErrors, () => missingModelStore.missingModelNodeIds],
() => {
if (!app.isGraphReady) return
// Legacy (LGraphNode) only: suppress missing-model error flags when
// the Errors tab is hidden, since legacy nodes lack the per-widget
// red highlight that Vue nodes use to indicate *why* a node has errors.
// Vue nodes compute hasAnyError independently and are unaffected.
const showErrorsTab = useSettingStore().get(
'Comfy.RightSidePanel.ShowErrorsTab'
)
reconcileNodeErrorFlags(
app.rootGraph,
lastNodeErrors.value,
showErrorsTab
? missingModelStore.missingModelAncestorExecutionIds
: new Set()
)
},
{ flush: 'post' }
)
useNodeErrorFlagSync(lastNodeErrors, missingModelStore)
return {
// Raw state
lastNodeErrors,
lastExecutionError,
lastPromptError,
missingNodesError,
// Clearing
clearAllErrors,
@@ -543,30 +347,22 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
hasExecutionError,
hasPromptError,
hasNodeError,
hasMissingNodes,
hasAnyError,
allErrorExecutionIds,
totalErrorCount,
lastExecutionErrorNodeId,
activeGraphErrorNodeIds,
activeMissingNodeGraphIds,
// Clearing (targeted)
clearSimpleNodeErrors,
clearWidgetRelatedErrors,
// Missing node actions
setMissingNodeTypes,
surfaceMissingNodes,
removeMissingNodesByType,
// Missing model coordination (delegates to missingModelStore)
surfaceMissingModels,
// Lookup helpers
getNodeErrors,
slotHasError,
isContainerWithInternalError,
isContainerWithMissingNode
isContainerWithInternalError
}
})

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
// Create mock functions that will be shared
@@ -598,13 +599,13 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
})
describe('useExecutionErrorStore - setMissingNodeTypes', () => {
let store: ReturnType<typeof useExecutionErrorStore>
describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
let store: ReturnType<typeof useMissingNodesErrorStore>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionErrorStore()
store = useMissingNodesErrorStore()
})
it('clears missingNodesError when called with an empty array', () => {

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