Compare commits

...

35 Commits

Author SHA1 Message Date
bymyself
f3c00f0afa test: add VirtualGrid coverage for single item, exact fit, and overflow 2026-03-28 00:55:33 -07:00
Christian Byrne
e34548724d feat(telemetry): add view_mode and is_app_mode to run_button_click event (#9881)
## Summary

Adds `view_mode` and `is_app_mode` properties to the
`app:run_button_click` telemetry event so analytics can segment run
button clicks by the user's current view context.

## Changes

- **`types.ts`**: Added `view_mode?: string` and `is_app_mode?: boolean`
to `RunButtonProperties`
- **`PostHogTelemetryProvider.ts`**: Computes `view_mode` and
`is_app_mode` from `useAppMode()` in `trackRunButton()`
- **`MixpanelTelemetryProvider.ts`**: Same as PostHog (providers are
mirrors)

## New Properties

| Property | Type | Description | Example Values |
|----------|------|-------------|----------------|
| `view_mode` | `string` | Granular AppMode value | `'graph'`, `'app'`,
`'builder:inputs'`, `'builder:outputs'`, `'builder:arrange'` |
| `is_app_mode` | `boolean` | Simplified flag for app mode vs non-app
mode | `true` when `mode === 'app' \|\| mode === 'builder:arrange'` |

## Design Decisions

- **Both granular and simplified**: `view_mode` gives exact mode for
detailed analysis; `is_app_mode` gives a quick boolean for simple
segmentation
- **Computed in providers**: View mode is read from `useAppMode()` at
tracking time, same pattern as `getExecutionContext()` — no changes
needed at call sites
- **`trigger_source` unchanged**: `keybindingService.ts` already reports
`trigger_source: 'keybinding'` regardless of view mode, satisfying the
requirement that keybinding invocations are correctly identified even in
app mode

## Testing

- Typecheck passes (no new errors)
- Format and lint pass (no new errors)
- Manual verification: all pre-existing errors are in unrelated files
(`draftCacheV2.property.test.ts`, `workflowDraftStoreV2.fsm.test.ts`)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9881-feat-telemetry-add-view_mode-and-is_app_mode-to-run_button_click-event-3226d73d36508101b3a8c7ff27706f81)
by [Unito](https://www.unito.io)
2026-03-13 10:35:13 -07:00
Johnpaul Chiwetelu
fcdc08fb27 feat: structured preload error logging with Sentry enrichment (#8928)
## Summary

Add structured preload error logging with Sentry context enrichment and
a user-facing toast notification when chunk loading fails (e.g. after a
deploy with new hashed filenames).

## Changes

- **`parsePreloadError` utility** (`src/utils/preloadErrorUtil.ts`):
Extracts structured info from `vite:preloadError` events — URL, file
type (JS/CSS/unknown), chunk name, and whether it looks like a hash
mismatch.
- **Sentry enrichment** (`src/App.vue`): Sets Sentry context and tags on
preload errors so they are searchable/filterable in the Sentry
dashboard.
- **User-facing toast**: Shows an actionable "please refresh" message
when a preload error occurs, across all distributions (cloud, desktop,
localhost).
- **Capture-phase resource error listener** (`src/App.vue`): Catches
CSS/script load failures that bypass `vite:preloadError` and reports
them to Sentry with the same structured context.
- **Unit tests** (`src/utils/preloadErrorUtil.test.ts`): 9 tests
covering URL parsing, chunk name extraction, hash mismatch detection,
and edge cases.

## Files Changed

| File | What |
|------|------|
| `src/App.vue` | Preload error handler + resource error listener |
| `src/locales/en/main.json` | Toast message string |
| `src/utils/preloadErrorUtil.ts` | `parsePreloadError()` utility |
| `src/utils/preloadErrorUtil.test.ts` | Unit tests |

## Review Focus

- Toast fires for all distributions (cloud/desktop/localhost) —
intentional so all users see stale chunk errors
- `parsePreloadError` is defensive — returns `unknown` for any field it
cannot parse
- Capture-phase listener filters to only `<script>` and `<link
rel="stylesheet">` elements

## References

- [Vite preload error
handling](https://vite.dev/guide/build#load-error-handling)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-13 10:29:33 -07:00
pythongosssss
f1626acb61 refactor: Unify app builder & app widget lists (#9829)
## Summary

Currently app builder & app mode use slightly different rendering paths
for the widgets, giving a different preview to what you actually get,
this changes it to use the same path for both.

## Changes

- **What**: 
- Extract LinearControls widget rendering
- Replace app builder arrange step with this
- Add ability to rename/remove widgets during app mode

## Screenshots (if applicable)

<img width="1205" height="853" alt="image"
src="https://github.com/user-attachments/assets/9160e33f-08c7-4863-a62d-c03929ffd3c8"
/>
<img width="1246" height="843" alt="image"
src="https://github.com/user-attachments/assets/82d8bacd-fd32-4ad7-b09d-eaa6670590ef"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9829-refactor-Unify-app-builder-app-widget-lists-3216d73d36508157a888f08dbe3655ea)
by [Unito](https://www.unito.io)
2026-03-13 10:01:46 -07:00
Arthur R Longbottom
5640eb7d92 fix: subgraph output slot labels not updating in v2 renderer (#9266)
## Summary

Custom names set on subgraph output nodes are ignored in the v2 renderer
— it always shows the data type name (e.g. "texts") instead of the
user-defined label. Works correctly in v1.

## Changes

- **What**: Made `outputs` in `extractVueNodeData` reactive via
`shallowReactive` + `defineProperty` (matching the existing `inputs`
pattern). Added a `node:slot-label:changed` graph trigger that
`SubgraphNode` fires when input/output labels are renamed, so the Vue
layer picks up the change.

## Review Focus

- The `outputs` reactivity mirrors `inputs` exactly — same
`shallowReactive` + setter pattern. The new trigger event forces
`shallowReactive` to detect the deep property change by re-assigning the
array.
- Also handles input label renames for consistency, even though the
current bug report is output-specific.

## Screenshots

**v1 — output correctly shows custom label "output_text":**
<img width="1076" height="628" alt="Screenshot 2026-02-26 at 4 43 00 PM"
src="https://github.com/user-attachments/assets/b4d6ae4c-9970-4d99-a872-4ce1b28522f2"
/>

**v2 before fix — output shows type name "texts" instead of custom
label:**
<img width="808" height="298" alt="Screenshot 2026-02-26 at 4 43 30 PM"
src="https://github.com/user-attachments/assets/cf06aa6c-6d4d-4be9-9bcd-dcc072ed1907"
/>

**v2 after fix — output correctly shows "output_text":**
<img width="1013" height="292" alt="Screenshot 2026-02-26 at 5 14 44 PM"
src="https://github.com/user-attachments/assets/3c43fa9b-0615-4758-bee6-be3481168675"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9266-fix-subgraph-output-slot-labels-not-updating-in-v2-renderer-3146d73d365081979327fd775a6ef62b)
by [Unito](https://www.unito.io)
2026-03-13 09:58:13 -07:00
Benjamin Lu
9447a1f5d6 test: warn on fix PRs without e2e regression coverage (#9880)
## Summary

Add a CodeRabbit pre-merge warning for fix-like PRs that do not update
`browser_tests/` and do not explain why no end-to-end regression test
was added.

Requested by Christian

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9880-test-warn-on-fix-PRs-without-e2e-regression-coverage-3226d73d3650816eb3e1c1c7d0824edd)
by [Unito](https://www.unito.io)
2026-03-13 09:57:56 -07:00
pythongosssss
1054503b4e Add support for values factory function in widget select combo (#8775)
## Summary

Adds support for values factory functions, e.g.
```
this.addWidget(
    "combo",
    "Dynamic",
    "",
    (e) => { },
    {
        values: () => {
            return getSomeValuesHere() 
        }
    }
)
```

Specifically for fixing KJNodes get/set

## Changes

- **What**:  Check if object is a function, if so, calls it.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8775-Add-support-for-values-factory-function-in-widget-select-combo-3036d73d3650819bb4e4f9181445cb1d)
by [Unito](https://www.unito.io)
2026-03-13 09:47:27 -07:00
jaeone94
9652871aaf [bugfix] Align advanced footer design with subgraph footer layout (#9879)
## Summary
Fix advanced widget footer on Vue nodes to use the same absolute
positioning and design as subgraph/error footers, and add dual-tab
layout when both error and advanced states coexist.

## Changes
- **What**: Changed advanced footer (Case 4) from relative to absolute
positioning matching subgraph footer design. Added new Case 1b for Error
+ Advanced dual-tab layout on regular nodes.
- **i18n**: Added `showAdvancedShort` / `hideAdvancedShort` keys for
compact dual-tab display

## Review Focus
- Visual consistency between advanced footer and subgraph footer across
collapsed/expanded states
- Dual-tab (Error + Advanced) layout mirrors subgraph dual-tab (Error +
Enter Subgraph)

## Screenshots 
**Before**

<img width="451" height="313" alt="image"
src="https://github.com/user-attachments/assets/98998e27-2283-49b4-8e32-3593c5577851"
/>

**After**
<img width="902" height="687" alt="image"
src="https://github.com/user-attachments/assets/83e7cec8-1ee7-44a6-b984-fd4d7282edd1"
/>
<img width="1150" height="1154" alt="image3"
src="https://github.com/user-attachments/assets/32605807-4c43-48e3-bb6e-b645afd1a403"
/>
<img width="1212" height="415" alt="image4"
src="https://github.com/user-attachments/assets/3e8184a6-6be4-4c83-98e3-0a6732ae4e3f"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9879-bugfix-Align-advanced-footer-design-with-subgraph-footer-layout-3226d73d365081868c3af5df528dc81e)
by [Unito](https://www.unito.io)
2026-03-14 00:44:04 +09:00
Alexander Brown
1280d4110d fix: simplify ensureCorrectLayoutScale and fix link sync during Vue node drag (#9680)
## Summary

Fix node layout drift from repeated `ensureCorrectLayoutScale` scaling,
simplify it to a pure one-time normalizer, and fix links not following
Vue nodes during drag.

## Changes

- **What**:
- `ensureCorrectLayoutScale` simplified to a one-time normalizer:
unprojects legacy Vue-scaled coordinates back to canonical LiteGraph
coordinates, marks the graph as corrected, and does nothing else. No
longer touches the layout store, syncs reroutes, or changes canvas
scale.
- Removed no-op calls from `useVueNodeLifecycle.ts` (a renderer version
string was passed where an `LGraph` was expected).
- `layoutStore.finalizeOperation` now calls `notifyChange` synchronously
instead of via `setTimeout`. This ensures `useLayoutSync`'s `onChange`
callback pushes positions to LiteGraph `node.pos` and calls
`canvas.setDirty()` within the same RAF frame as a drag update, fixing
links not following Vue nodes during drag.
- **Tests**: Added tests for `ensureCorrectLayoutScale` (idempotency,
round-trip, unknown-renderer no-op) and `graphRenderTransform`
(project/unproject round-trips, anchor caching).

## Review Focus

- The `setTimeout(() => this.notifyChange(change), 0)` →
`this.notifyChange(change)` change in `layoutStore.ts` is the key fix
for the drag-link-sync bug. The listener (`useLayoutSync`) only writes
to LiteGraph, not back to the layout store, so synchronous notification
is safe.
- `ensureCorrectLayoutScale` no longer has any side effects beyond
normalizing coordinates and setting `workflowRendererVersion` metadata.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-13 08:43:18 -07:00
Christian Byrne
871715113c fix: detect and remove duplicate links in subgraph unpacking (#9120)
## Summary

Fix duplicate LLink objects created during subgraph unpacking, where
output.links contains multiple link IDs for the same connection but
input.link only references one, leaving orphaned links.

## Changes

- **What**: Three layers of defense against duplicate links:
1. **Serialization fix** (`slotUtils.ts`): Clone `output.links` array in
`outputAsSerialisable` to prevent shared-reference mutation during
serialization round-trips
2. **Self-healing** (`LGraph.ts`): `_removeDuplicateLinks()` sanitizes
corrupted data during `configure()`, keeping the link referenced by
`input.link` and removing orphaned duplicates from `output.links` and
`_links`
3. **Unpack dedup** (`LGraph.ts`): Subgraph unpacking filters `newLinks`
via a `seenLinks` Set before creating connections

Runtime diagnostic logging via `graph.events` (no Sentry import in
litegraph):
- `_dupLinkIndex` Map for O(1) duplicate detection, only allocated when
enabled
- `_checkDuplicateLink()` called at the 3 link-creation sites
(`connectSlots`, `SubgraphInput.connect`, `SubgraphOutput.connect`)
- App layer listens for `diagnostic:duplicate-link` events and forwards
to Sentry with rate-limiting (1 per key per 60s)

## Review Focus

- The `_removeDuplicateLinks` strategy of keeping the link referenced by
`input.link` and removing others from `output.links` + `_links`
- The diagnostic index lifecycle: built on enable, updated on link
create/remove, cleared on disable
- Sentry integration in `app.ts` using the existing `graph.events`
system to avoid coupling litegraph to Sentry

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9120-fix-detect-and-remove-duplicate-links-in-subgraph-unpacking-3106d73d3650815b995ddf8f41da67ae)
by [Unito](https://www.unito.io)
2026-03-13 08:35:27 -07:00
Christian Byrne
a9f9afd062 fix: avoid forced layout in renderInfo by using canvas.height (#9304)
## What

Replace `canvas.offsetHeight` with `canvas.height / devicePixelRatio` in
`renderInfo` to avoid forced synchronous layout.

## Why

`renderInfo` is called ~2,631 times in a typical session. Each call
reads `this.canvas.offsetHeight`, which forces the browser to flush
pending style/layout changes synchronously. With PrimeVue injecting
styles dynamically and Vue patching the DOM, there are almost always
pending mutations — converting every canvas-only `renderInfo` call into
a forced layout.

## How

`canvas.height` is the DPR-scaled internal resolution (set in
`resizeCanvas` as `cssHeight * devicePixelRatio`). Dividing by
`devicePixelRatio` yields the same CSS pixel value as `offsetHeight`
without triggering layout.

## Verification

- [x] Unit test: verifies `offsetHeight` is not accessed when y is
provided
- [x] Unit test: verifies fallback uses `canvas.height /
devicePixelRatio`
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] All litegraph tests pass (538 passed)

## Perf Impact

Eliminates ~2,631 forced synchronous layouts per session from the canvas
info panel.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9304-fix-avoid-forced-layout-in-renderInfo-by-using-canvas-height-3156d73d36508171973dda289b30d5ee)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-13 08:35:03 -07:00
Christian Byrne
79d0e6dc69 fix: cache ctx.measureText results to avoid redundant calls in draw loop (#9404)
## What
Add a per-frame text measurement cache for all hot-path
ctx.measureText() calls.

## Why
drawTruncatingText() in BaseWidget calls ctx.measureText() per widget
per frame with zero caching. For a 50-node workflow at 60fps:
~78,000-243,000 measureText calls/sec. Text labels rarely change between
frames.

## How
Global Map<string, number> cache keyed by font+text, cleared once per
frame at the start of drawFrontCanvas(). Replaces direct
ctx.measureText() calls in BaseWidget.drawTruncatingText, draw.ts
truncateTextToWidth/drawTextInArea, LGraphBadge.getWidth,
LGraphButton.getWidth, and textUtils.truncateText.

## Perf Impact
Expected: ~95% reduction in measureText calls (only cache misses on
first frame and value changes). Firefox has slower measureText than
Chrome, so this disproportionately benefits Firefox.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9404-fix-cache-ctx-measureText-results-to-avoid-redundant-calls-in-draw-loop-31a6d73d3650814e9cdac16949c55cb7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-13 08:16:53 -07:00
Christian Byrne
de866a15d2 Add prompt_id support to progress_text WS messages (#9002)
## Summary

Add frontend support for `prompt_id` in `progress_text` binary WS
messages, enabling parallel workflow execution to route progress text to
the correct active prompt.

Backend PR: https://github.com/Comfy-Org/ComfyUI/pull/12540

## Changes

- Advertise `supports_progress_text_metadata` in client feature flags
- Decode `prompt_id` from new binary format when flag is active
- Add optional `prompt_id` to `zProgressTextWsMessage` schema
- Filter `progress_text` events by `activePromptId` — skip messages for
non-active prompts

## Deployment

Can be deployed independently in any order — feature flag negotiation
ensures graceful degradation.

Part of COM-12671

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9002-Add-prompt_id-support-to-progress_text-WS-messages-30d6d73d365081acabbcdac939e0c751)
by [Unito](https://www.unito.io)


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

* **New Features**
* Progress text updates can include optional metadata so richer context
is available.
* **Bug Fixes / Improvements**
* Progress updates are now filtered to show only the currently active
prompt, reducing cross-talk from concurrent operations and improving
update accuracy.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-13 07:55:05 -07:00
jaeone94
31a33a0ba2 feat: auto-resolve simple validation errors on widget change and slot connection (#9464)
## Summary

Automatically clears transient validation errors
(`value_bigger_than_max`, `value_smaller_than_min`, `value_not_in_list`,
`required_input_missing`) when the user modifies a widget value or
connects an input slot, so resolved errors don't linger in the error
panel. Also clears missing model state when the user changes a combo
widget value.

## Changes

- **`useNodeErrorAutoResolve` composable**: watches widget changes and
slot connections, clears matching errors via `executionErrorStore`
- **`executionErrorStore`**: adds `clearSimpleNodeErrors` and
`clearSimpleWidgetErrorIfValid` with granular per-slot error removal
- **`executionErrorUtil`**: adds `isValueStillOutOfRange` to prevent
premature clearing when a new value still violates the constraint
- **`graphTraversalUtil`**: adds `getExecutionIdFromNodeData` for
subgraph-aware execution ID resolution
- **`GraphCanvas.vue`**: fixes subgraph error key lookup by using
`getExecutionIdByNode` instead of raw `node.id`
- **`NodeWidgets.vue`**: wires up the new composable to the widget layer
- **`missingModelStore`**: adds `removeMissingModelByWidget` to clear
missing model state on widget value change
- **`useGraphNodeManager`**: registers composable per node
- **Tests**: 126 new unit tests covering error clearing, range
validation, and graph traversal edge cases

## Screenshots



https://github.com/user-attachments/assets/515ea811-ff84-482a-a866-a17e5c779c39



https://github.com/user-attachments/assets/a2b30f02-4929-4537-952c-a0febe20f02e


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9464-feat-auto-resolve-simple-validation-errors-on-widget-change-and-slot-connection-31b6d73d3650816b8afdc34f4b40295a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 23:49:44 +09:00
Dante
d73f8e1beb fix: show most recent image first in asset sidebar batch view (#9467)
## Summary
- Use the last previewable output as the batch cover/thumbnail instead
of the first, so the most recently generated image (e.g., `_00010`) is
shown as the representative
- Reverse output order in batch folder view so newest images appear at
the top
- Gitignore `.claude/worktrees` to fix knip scanning untracked worktree
copies

## Linked Issues
- Fixes #9354
- Related to #9080

## Test plan
- [ ] Generate a batch of images (e.g., 10 images) and verify the
sidebar shows the last generated image as the cover
- [ ] Expand the batch folder view and verify images are in
reverse-chronological order (newest first)
- [ ] Verify existing unit tests pass (`pnpm test:unit`)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9467-fix-show-most-recent-image-first-in-asset-sidebar-batch-view-31b6d73d365081cbaf30f81009c7fcfa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 23:49:42 +09:00
Dante
4e85537b15 test: add property-based FSM tests for workflow persistence (#9370)
## Summary

- Fixes #9319
- Add [fast-check](https://github.com/dubzzz/fast-check) property-based
testing with FSM (Finite State Machine) traversal to automatically
explore state combinations in the workflow persistence system
- Fix a real bug in `saveDraft()` discovered by the FSM test: orphan
cleanup in `loadIndex()` could delete a just-written payload when the
in-memory cache was empty

## Why this is needed

#9317 exposed a class of bug where two independently correct changes
interact to cause workflow loss. Conventional unit tests verify
specific, hand-picked scenarios and cannot catch these cross-PR
interaction bugs.

### AS IS (before)

| Aspect | Status |
|---|---|
| Testing approach | Example-based: developer picks specific inputs and
expected outputs |
| State coverage | Only explicitly written scenarios are tested |
| Cross-interaction bugs | Not detectable — each test runs one isolated
path |
| Bug in `saveDraft` | Undetected — `loadIndex()` orphan cleanup could
delete a just-written payload after `reset()` |

### TO BE (after)

| Aspect | Status |
|---|---|
| Testing approach | Property-based: fast-check generates **200 random
command sequences** per run |
| State coverage | Random exploration of `SaveDraft → GetDraft →
RemoveDraft → MoveDraft → GetMostRecentPath → Reset` combinations |
| Cross-interaction bugs | Detected automatically — fast-check shrinks
failing sequences to minimal reproductions |
| Bug in `saveDraft` | Found and fixed — `loadIndex()` now runs
**before** `writePayload()` to prevent orphan cleanup race |

## What fast-check does

fast-check is a property-based testing library. Instead of testing "does
this specific input produce this specific output?", it tests "does this
**property** hold for **all possible inputs**?"

For FSM testing specifically, fast-check:
1. Takes a set of **commands** (SaveDraft, GetDraft, RemoveDraft,
MoveDraft, GetMostRecentPath, Reset)
2. Generates **random sequences** of these commands
3. Runs each sequence against both a **model** (simplified oracle) and
the **real system** (store + localStorage)
4. Verifies **invariants** after every mutating command (index/payload
consistency, no orphans, LRU correctness, model agreement)
5. When a failure is found, **shrinks** the sequence to the minimal
reproduction

Example: the bug this PR fixes was shrunk to just 4 commands:
```
SaveDraft(d.json) → RemoveDraft(d.json) → Reset() → SaveDraft(a.json) ✗
```

## Changes

| File | Change |
|---|---|
| `package.json` / `pnpm-workspace.yaml` | Add `fast-check`
devDependency |
| `draftCacheV2.property.test.ts` | 7 property tests for pure index
functions |
| `workflowDraftStoreV2.fsm.test.ts` | FSM test: 6 commands, invariant
checking, 200 runs |
| `workflowDraftStoreV2.ts` | Fix: move `loadIndex()` before
`writePayload()` in `saveDraft()` |

## Test plan

- [x] `pnpm test:unit` — all 117 persistence tests pass (including 7
property + 1 FSM)
- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — clean
- [x] Pre-commit hooks pass (format, lint, typecheck)
- [x] Pre-push hook passes (knip)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9370-test-add-property-based-FSM-tests-for-workflow-persistence-3196d73d3650813daa98cdd8bef7e975)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-13 07:28:38 -07:00
Christian Byrne
b5ddc70233 feat: show ComfyUI context menu on textarea widget right-click (#9840)
## Summary

Right-clicking a textarea widget (e.g. text node) shows the browser's
native context menu instead of ComfyUI's context menu, preventing access
to promote/un-promote options in subgraphs.

## Changes

- **What**: Replace `@contextmenu.capture.stop` on
`WidgetTextarea.vue`'s `<Textarea>` with a handler implementing
double-right-click toggling: first right-click shows ComfyUI's context
menu, second right-click (while menu is open) allows browser native
menu. Exposes `isNodeOptionsOpen()` from `useMoreOptionsMenu.ts` to
check menu state.

## Review Focus

The capture-phase handler in `WidgetTextarea.vue` only changes
`contextmenu` handling — pointer event modifiers
(`pointerdown/move/up.capture.stop`) that prevent canvas panning are
untouched. The double-right-click pattern matches Notion/YouTube
behavior for editable text fields.

<!-- Pipeline-Ticket: d7a53160-e1e1-42bb-a5ac-c0c2702c629c -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9840-fix-show-ComfyUI-context-menu-on-textarea-widget-right-click-3216d73d36508102b4c9c13a5915bc48)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-13 07:03:20 -07:00
Johnpaul Chiwetelu
48d928fc9e feat: multi-keybinding support in settings panel (#9738)
## Summary

Adds VS Code-style multi-keybinding support to the Keybinding settings
panel. Commands can now have multiple keybindings displayed, expanded,
and individually managed.

- Fixes #1088

## Changes

### Store (`keybindingStore.ts`)
- `removeAllKeybindingsForCommand(commandId)` — unsets all bindings for
a command
- `updateSpecificKeybinding(old, new)` — replaces a single binding
without affecting others
- `resetKeybindingForCommand` — updated to restore **all** default
bindings, not just the first
- `isCommandKeybindingModified` — updated to compare full sorted sets of
bindings

### UI (`KeybindingPanel.vue`)
- **Data model**: `keybinding: KeybindingImpl | null` → `keybindings:
KeybindingImpl[]`
- **Multi-binding display**: shows up to 2 combos inline with `, `
separator, then `+ N more` badge
- **Expand/collapse**: click any row with 2+ bindings to expand
individual binding rows; chevron-right icon rotates on expand
- **Per-binding actions**: edit (pencil), reset, trash on each expanded
sub-row
- **Parent row actions**: `+`/trash for 2+ bindings, pencil/reset/trash
for 1, `+`/disabled for 0
- **Edit modes**: `edit` (replace specific binding via
`updateSpecificKeybinding`) and `add` (append via `addUserKeybinding`)
- **Right-click context menu**: Change keybinding, Add new, Reset to
default, Remove keybinding — with proper disabled states and lucide
icons
- **Remove all dialog**: confirmation via `showSmallLayoutDialog` with
`RemoveAllKeybindingsHeader`/`Content` components
- **Reset all dialog**: confirmation via `showConfirmDialog` before
resetting all keybindings to defaults
- **Double-click**: 0 bindings → add, 1 → edit, 2+ → no-op (single click
toggles expand)
- **Consistent alignment**: commands without chevron get `pl-5` padding
to align with those that have it

### Tests (`keybindingStore.test.ts`)
- 7 new tests covering `removeAllKeybindingsForCommand`,
`updateSpecificKeybinding`, multi-binding `isCommandKeybindingModified`,
and multi-binding `resetKeybindingForCommand`

### i18n (`main.json`)
- 11 new keys: removeAllKeybindingsTitle/Message, removeAll,
changeKeybinding, addNewKeybinding, resetToDefault, removeKeybinding,
nMoreKeybindings, resetAllKeybindingsTitle/Message, allKeybindingsReset

### New components
- `RemoveAllKeybindingsHeader.vue` — dialog header
- `RemoveAllKeybindingsContent.vue` — dialog body with Close/Remove all
buttons

## Test plan

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes (no new errors)
- [x] `pnpm vitest run src/platform/keybindings/` — 45 tests pass
- [x] CodeRabbit review — 0 findings
- [ ] Manual: open Settings → Keybindings, verify multi-binding commands
(e.g. Delete Selected Items, Zoom In) show multiple combos
- [ ] Manual: click row to expand, verify per-binding actions work
- [ ] Manual: right-click row, verify context menu actions
- [ ] Manual: click trash on 2+ binding command, verify "Remove all"
confirmation dialog
- [ ] Manual: click "Reset All" button, verify confirmation dialog
appears
- [ ] Manual: add/edit/remove individual bindings, verify persistence

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9738-feat-multi-keybinding-support-in-settings-panel-3206d73d365081e9b08bd3cfe21495f1)
by [Unito](https://www.unito.io)
2026-03-13 13:43:33 +00:00
Jin Yi
10b0350d01 feat: unify sidebar panel header layout with SidebarTopArea component (#9740)
## Summary

Unify the search bar + action buttons layout across all left sidebar
panels (Node Library, Workflows, Model Library, Media Assets) using a
shared `SidebarTopArea` presentation component.

## Changes

- **What**:
- Add `SidebarTopArea.vue` — layout component with `flex-1` default slot
(search) and `#actions` slot (buttons), plus optional `bottomDivider`
prop
- Replace raw `<button>` elements in Node Library with `<Button
variant="secondary" size="icon">`
- Replace reka-ui `TabsTrigger` with shared `Tab/TabList` component in
Node Library
- Move Media Assets tab list from hover-only `#tool-buttons` to
always-visible header below search area
- Unify spacing (`gap-2`, `p-2 2xl:px-4`) and divider styles across all
sidebar panels
- Remove unused `assetType` prop and header from
`AssetsSidebarGridView`/`AssetsSidebarListView`

## Review Focus

- `SidebarTopArea` API simplicity — just slots + one optional prop
- Node Library still requires `TabsRoot` in the body for reka-ui
`TabsContent` in child panels
- Media Assets tabs are now always visible instead of hover-only

[screen-capture
(1).webm](https://github.com/user-attachments/assets/fe1d8f7b-5674-4bb3-9842-569e4c3af6c9)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9740-feat-unify-sidebar-panel-header-layout-with-SidebarTopArea-component-3206d73d365081ea8ba7fd6ac54e0169)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-13 06:32:18 -07:00
Dante
5167a511ce fix: advanced widgets always visible regardless of setting (#9857)
## Summary

- Fixes a bug where widgets marked as `advanced` were always visible,
ignoring the "Always show advanced widgets on all nodes" setting
- Root cause: `extractWidgetDisplayOptions` in `useGraphNodeManager.ts`
read `widget.advanced` (always `undefined` on BaseWidget) instead of
`widget.options?.advanced` (where `litegraphService` actually sets the
flag)
- Consistent with how `hidden` is already read from
`widget.options.hidden` on the adjacent line

## Test plan

- [ ] Load a node with advanced inputs (e.g. `LTXVScheduler`)
- [ ] Verify `max_shift`, `base_shift`, `stretch`, `terminal` are hidden
when "Always show advanced widgets on all nodes" is disabled
- [ ] Verify they become visible when the setting is enabled or the
per-node toggle is clicked
- [ ] Verify the advanced toggle button appears on nodes with advanced
widgets

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9857-fix-advanced-widgets-always-visible-regardless-of-setting-3226d73d36508132b338d098fbf19433)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 06:25:03 -07:00
Christian Byrne
4bdf67ca21 fix: restore fork PR lint/format CI workflow (#9846)
## Problem

The lint/format CI workflow was broken for fork PRs in two ways:

### 1. Node version mismatch in setup-frontend action
The `setup-frontend` shared action (created in #8377) was missed when
Node version was standardized to `.nvmrc` in #9521. It still used
`node-version: 'lts/*'` instead of `node-version-file: '.nvmrc'`.

### 2. Fork PRs with lint issues silently passed CI
Fork PRs with auto-fixable lint/format issues got a **green checkmark**
despite having unfixed issues:
1. Auto-fix steps (`lint:fix`, `format`) fix issues in the workspace
2. `Commit changes` is correctly skipped for forks (can't push to fork
branches)
3. `Final validation` passes because it runs on the already-fixed
workspace
4. The `Comment on PR about manual fix needed` step tries to post a
comment via `actions/github-script`, but fork PRs have a read-only
`GITHUB_TOKEN` — the comment silently fails (`continue-on-error: true`)
5. **Result**: workflow reports success, contributor thinks their code
is clean

## Fix

- **setup-frontend**: Use `node-version-file: '.nvmrc'` instead of
`node-version: 'lts/*'`
- **ci-lint-format**: Replace the broken fork comment step with an
explicit `exit 1` that fails CI and prints clear fix instructions in the
log. This follows the principle from `.github/AGENTS.md`: fork PRs can't
post comments, so don't try.

## Testing
- [ ] Verify fork PRs with clean code still pass
- [ ] Verify fork PRs with lint issues now properly fail (instead of
silently passing)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9846-fix-restore-fork-PR-lint-format-CI-workflow-3226d73d3650811cb5bfe9f1f989cc0c)
by [Unito](https://www.unito.io)
2026-03-13 06:14:16 -07:00
Christian Byrne
e119383072 feat: select group children on click (#9149)
## Summary

Add a setting to select all children (nodes, reroutes, nested groups)
when clicking a group on the canvas.

## Changes

- **What**: New `LiteGraph.Group.SelectChildrenOnClick` boolean setting
(default: `false`). When enabled, selecting a group cascades `select()`
to all its `_children`, and deselecting cascades `deselect()`. Recursion
handles nested groups naturally. No double-move risk — the drag handler
already uses `skipChildren=true`. The setting is wired via `onChange` to
`canvas.groupSelectChildren`, keeping litegraph free of platform
imports.

## Review Focus

- The select/deselect cascading in `LGraphCanvas.select()` /
`deselect()` — verify no infinite recursion risk with deeply nested
groups.
- The `groupSelectChildren` property is set via the setting's `onChange`
callback on `LGraphCanvas.active_canvas` — confirm this covers canvas
re-creation scenarios.

## Screenshots (if applicable)

N/A — behavioral change behind a setting toggle.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9149-feat-select-group-children-on-click-3116d73d365081a1a7b8c82dea95b242)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-13 06:13:18 -07:00
John Haugeland
f4bf169b2f perf: remove deep: true from 3 hot watchers to reduce reactivity overhead (#9248)
All three watchers used { deep: true } unnecessarily because their
watched sources already produce new object/array references on change,
making deep traversal redundant:

- GraphView.vue: queueStore.tasks is a computed that spreads three
  shallowRef arrays into a new array each time. TaskItemImpl instances
  are immutable (readonly fields, replaced not mutated).

- DomWidget.vue: Replaced opaque widgetState deep watcher with explicit
  property deps (pos, size, zIndex, readonly, positionOverride). During
  60 FPS pan/zoom, Vue no longer walks the entire DomWidgetState object
  graph including the markRaw widget — only 5 leaf properties are
  checked. pos and size are new array literals each frame; the rest are
  primitives.

- GraphCanvas.vue: nodeLocationProgressStates is a computed returning a
  new Record on every execution progress event (nodeProgressStates is
  replaced wholesale via WebSocket handler).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9248-perf-remove-deep-true-from-3-hot-watchers-to-reduce-reactivity-overhead-3136d73d365081278f18da5a2eef6971)
by [Unito](https://www.unito.io)

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-13 05:55:52 -07:00
Dante
07d5bd50f6 feat: extract SeedControlButton component (#9744)
<img width="1048" height="482" alt="스크린샷 2026-03-12 오전 9 11 56"
src="https://github.com/user-attachments/assets/68009980-097c-4736-b7c4-eb8f9a6f05be"
/>

## Summary
- Extract inline value control button from `WidgetWithControl` into
reusable `SeedControlButton` component
- Support `badge` (pill) and `button` (square) variants per Figma design
system spec
- Use native `<button>` element for proper a11y (works with Reka UI's
`PopoverTrigger as-child`)

## Test plan
- [x] Verify seed control button renders correctly on KSampler node's
seed widget
- [x] Verify popover opens on click and mode selection works
- [x] Verify all 4 modes display correct icon/text (shuffle, pencil-off,
+1, -1)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9744-feat-extract-SeedControlButton-component-3206d73d365081a3823cc19e48d205c1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-13 02:49:18 -07:00
Dante
c318cc4c14 feat: replace PrimeVue ColorPicker with custom component (#9647)
## Summary
- Replace PrimeVue `ColorPicker` with a custom component built on Reka
UI Popover
- New `ColorPicker` supports HSV saturation-value picking, hue/alpha
sliders, hex/rgba display toggle
- Simplify `WidgetColorPicker` by removing PrimeVue-specific
normalization logic
- Add Storybook stories for both `ColorPicker` and `WidgetColorPicker`

## Test plan
- [x] Unit tests pass (9 widget tests, 47 colorUtil tests)
- [x] Typecheck passes
- [x] Lint passes
- [ ] Verify color picker visually in Storybook
- [ ] Test color picking in node widgets with hex/rgb/hsb formats

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9647-feat-replace-PrimeVue-ColorPicker-with-custom-component-31e6d73d36508114bc54d958ff8d0448)
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-13 12:45:10 +09:00
Jin Yi
bfabf128ce fix: inline splash CSS to prevent SPA fallback breakage on cloud environments (#9849)
## Summary

Inline splash screen CSS into `index.html` to fix broken loading
animation on cloud/ephemeral environments.

## Changes

- **What**: On cloud/ephemeral environments (e.g.
`fe-pr-*.testenvs.comfy.org`), SPA fallback serves `index.html` for
unknown paths. The `<link href="splash.css">` request resolves to
`/cloud/splash.css`, which the server does not find as a static file —
so it returns `index.html` with `200 OK`. The browser receives HTML
instead of CSS, the CSS parser silently ignores it, and the splash
screen renders without any styles or animations.
- Inlined `splash.css` directly into `index.html` `<style>` block —
eliminates the external request entirely
- Moved `splash.css` to `src/assets/` for content-hashed Vite processing
as source of truth
  - Removed `public/splash.css`

## Review Focus

- The inline CSS is byte-for-byte identical to the original
`public/splash.css`
- `src/assets/splash.css` preserved as canonical source for future
changes

[screen-capture
(1).webm](https://github.com/user-attachments/assets/06729641-d1fd-47aa-9dd4-4acd28c2cfcf)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9849-fix-inline-splash-CSS-to-prevent-SPA-fallback-breakage-on-cloud-environments-3226d73d365081418741eb0944a74977)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-13 02:42:46 +00:00
Jin Yi
6a8f3ef1a1 fix: show download icon alongside file size in missing models dialog (#9850)
## Summary

Fix download icon not appearing when file size is successfully fetched
in the missing models dialog.

## Changes

- **What**: Restructured the `v-if/v-else-if` chain in
`MissingModelsContent.vue` so that file size and download icon render
together instead of being mutually exclusive. Previously, a successful
file size fetch would prevent the download button from rendering.

## Review Focus

The file size span and download/gated-link are now inside a shared
`<template v-else-if="model.isDownloadable">` block. File size uses
`v-if` (independent), while gated link and download button remain
`v-if/v-else` (mutually exclusive with each other).


[screen-capture.webm](https://github.com/user-attachments/assets/f2f04d52-265b-4d05-992e-0ffe9bf64026)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9850-fix-show-download-icon-alongside-file-size-in-missing-models-dialog-3226d73d365081fd943bcfdedda87c73)
by [Unito](https://www.unito.io)
2026-03-13 11:30:54 +09:00
Dante
649b9b3fe3 fix: standardize i18n pluralization to two-part format (#9371)
## Summary

- Converts redundant three-part pluralization patterns (`zero | singular
| plural`) to standard two-part format (`singular | plural`) across 11
locale files
- Only converts patterns where the zero form duplicates the singular or
plural form
- Retains three-part patterns where the zero form provides distinct
content (e.g. `"No items selected"`) or where the language
linguistically requires all three forms (Russian singular/paucal/plural)

## Context

Vue i18n selects plural forms based on the number of choices:

**3-part** `"{count} nodes | {count} node | {count} nodes"`:
| count | index | result |
|-------|-------|--------|
| 0 | 0 (zero) | "0 nodes" |
| 1 | 1 (singular) | "1 node" |
| 2+ | 2 (plural) | "N nodes" |

**2-part** `"{count} node | {count} nodes"`:
| count | index | result |
|-------|-------|--------|
| 0 | 1 (plural) | "0 nodes" |
| 1 | 0 (singular) | "1 node" |
| 2+ | 1 (plural) | "N nodes" |

Output is identical — the zero form was always a duplicate of the plural
form. This PR removes that redundancy.

## Changes

| Locale | Keys changed |
|--------|-------------|
| en, es, fr, pt-BR, ar, tr | 5 (`nodesCount`, `asset`, `errorCount`,
`downloadsFailed`, `exportFailed`) |
| ja, ko, fa | 3 (`asset`, `nodesCount`, `downloadsFailed`) |
| ru | 2 (`downloadsFailed`, `exportFailed`) |
| zh-TW | 2 (`nodesCount`, `downloadsFailed`) |

## Test plan

- [ ] Verify pluralization renders correctly for count=0, count=1,
count=2+ in affected UI areas
- [ ] Spot-check non-English locales (especially Russian which retains
3-part for linguistically distinct forms)

- Fixes #9277

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-13 10:16:59 +09:00
Hunter
d82bce90ea feat: bake frontend commit hash into build (#9832)
## Summary

Bake the frontend git commit hash into the build so it no longer needs
to be fetched from the server via `/api/system_stats`.

## Changes

- **What**: Add `__COMFYUI_FRONTEND_COMMIT__` build-time constant (via
Vite `define`) sourced from `git rev-parse HEAD` at build time. Falls
back to `"unknown"` if git is unavailable. `SystemStatsPanel` uses this
baked-in value for the "Frontend Version" row in cloud mode instead of
the server-provided `comfyui_frontend_version` field.

## Testing
Confirmed to display the actual commit.
<img width="1448" height="908" alt="Screenshot 2026-03-12 at 7 09 52 PM"
src="https://github.com/user-attachments/assets/2b42348a-5c3e-4509-aa84-1a259bba5f3f"
/>


## Review Focus

- The `getDisplayValue` override for `comfyui_frontend_version` —
cleanest way to swap the data source without restructuring the column
system.
- No cloud-side changes needed: the `sync-frontend-build` workflow
already checks out the frontend repo at the exact commit ref, so `git
rev-parse HEAD` returns the correct hash.
2026-03-12 21:07:07 -04:00
Luke Mino-Altherr
91e429a62f fix: use order-independent tag matching in asset browser categories (#9843)
## Summary

Fix asset browser sidebar missing categories because `typeCategories`
assumed a fixed tag order from the API.

## Changes

- **What**: Replace `tags[0] === 'models'` / `tags[1]` with
`tags.includes(MODELS_TAG)` and `flatMap`+`filter`, matching the pattern
used by `getAssetModelFolders` and `filterByCategory`.

## Review Focus

The API returns tags in arbitrary order (e.g. `['checkpoints',
'models']` instead of `['models', 'checkpoints']`). The old code
filtered out most assets, resulting in an empty sidebar. New test
validates arbitrary tag ordering.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9843-fix-use-order-independent-tag-matching-in-asset-browser-categories-3216d73d365081b886f3d5ab1790e19d)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-12 17:59:05 -07:00
John Haugeland
21edbd3ee5 fix: cap nodeProgressStatesByJob to prevent unbounded memory growth (#9249)
This has semi-significant performance impact if you use the same
workflow for more than a day. I left Comfy running with a mouse jiggler
and this reduced my instance to a crawl after about an hour.

`nodeProgressStatesByJob` accumulated an entry for every job that ever
executed during a session. In long-running sessions this grew without
bound, since entries were only removed for the active job on
`resetExecutionState`.

Add `MAX_PROGRESS_JOBS` (1000) and `evictOldProgressJobs()`, called
after each `handleProgressState` update. When the map exceeds the limit,
the oldest entries (by ES2015+ insertion order) are pruned — keeping
only the most recent 1000. This mirrors the pattern used by
assetsStore's `MAX_HISTORY_ITEMS`.

Also adds tests for:
- nodeLocationProgressStates computed reactivity (recomputes on
  wholesale replacement, produces new references)
- Eviction behavior (retains below limit, evicts oldest above limit,
  preserves most recent, no-op when updating existing job)
- API event handler wiring via captured apiEventHandlers map

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9249-fix-cap-nodeProgressStatesByJob-to-prevent-unbounded-memory-growth-3136d73d365081e49b36d8ade0d4dd6e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-12 17:51:14 -07:00
Christian Byrne
f5363e4028 fix: return undefined for muted node output resolution (#9302)
## Summary

Muted (NEVER mode) subgraph nodes throw "No inner node DTO found" during
prompt serialization because `resolveOutput()` falls through to subgraph
resolution for nodes whose inner DTOs were never registered.

## Changes

- **What**: Add early return in `ExecutableNodeDTO.resolveOutput()` for
`NEVER` mode nodes, matching the existing `BYPASS` mode guard. Add 5
tests covering muted, bypassed, and normal mode resolution.

## Review Focus

The fix is a single-line early return. The key insight is that
`graphToPrompt` in `executionUtil.ts` correctly skips `getInnerNodes()`
for muted/bypassed nodes, so their inner DTOs are never in the map — but
`resolveOutput()` was missing the corresponding guard for `NEVER` mode.

Fixes #8986

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9302-fix-return-undefined-for-muted-node-output-resolution-3156d73d3650811e9697c7281f11cf96)
by [Unito](https://www.unito.io)
2026-03-12 17:44:32 -07:00
Gregorius Bima Kharisma Wicaksana
4337b8d6c6 fix: prevent middle-click paste duplicating workflow on Linux (#8259)
## Summary

Adds `auxclick` event listener to prevent the browser's default
middle-click paste behavior on Linux systems.

**Problem:** On Linux, middle-clicking anywhere triggers a paste from
the PRIMARY clipboard. When middle-dragging to pan the canvas, this
causes the entire workflow to be duplicated as new nodes on mouse
release.

**Solution:** Add `auxclick` event listener with `preventDefault()` to
the graph canvas, blocking the paste while preserving pan functionality.

## Changes

- Add `auxclick` event listener in `bindEvents()` 
- Add corresponding `removeEventListener` in `unbindEvents()`

## Test Plan

- [ ] On Linux: Middle-drag to pan canvas - should pan without
duplicating nodes
- [ ] On Linux: Verify left/right click behaviors unchanged
- [ ] On Windows/macOS: Verify no regression (auxclick should have no
effect)

Fixes #4464

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8259-fix-prevent-middle-click-paste-duplicating-workflow-on-Linux-2f16d73d3650812b98f9cada699f5508)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-12 17:23:42 -07:00
Comfy Org PR Bot
113a2b5d92 1.42.4 (#9844)
Patch version increment to 1.42.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9844-1-42-4-3226d73d365081ffae69c3b809461de2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-12 17:21:20 -07:00
Christian Byrne
bb84dd202d fix: update workspace creation modal phrasing for credit pool clarity (#9811)
## Summary

Update workspace creation modal copy to clarify that creating a
workspace establishes a **new** credit pool, rather than sharing from
the owner's existing credits.

## Changes

- **What**: Changed i18n message from "Workspaces let members share a
single credits pool" to "Workspaces create a new credit pool that can be
shared among members"

## Review Focus

Copy change only — single i18n string update in
`src/locales/en/main.json`.

Fixes COM-16521

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9811-fix-update-workspace-creation-modal-phrasing-for-credit-pool-clarity-3216d73d36508186850ac5a8ad97461d)
by [Unito](https://www.unito.io)
2026-03-12 17:13:08 -07:00
210 changed files with 11078 additions and 1400 deletions

9
.claude/settings.json Normal file
View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(pnpx vitest run --testPathPattern=\"draftCacheV2.property\")",
"Bash(pnpx vitest run \"draftCacheV2.property\")",
"Bash(node -e \"const fc = require\\(''fast-check''\\); console.log\\(Object.keys\\(fc\\).filter\\(k => k.includes\\(''string''\\)\\).join\\('', ''\\)\\)\")"
]
}
}

View File

@@ -0,0 +1,179 @@
---
name: perf-fix-with-proof
description: 'Ships performance fixes with CI-proven improvement using stacked PRs. PR1 adds a @perf test (establishes baseline on main), PR2 adds the fix (CI shows delta). Use when implementing a perf optimization and wanting to prove it in CI.'
---
# Performance Fix with Proof
Ships perf fixes as two stacked PRs so CI automatically proves the improvement.
## Why Two PRs
The `ci-perf-report.yaml` workflow compares PR metrics against the **base branch baseline**. If you add a new `@perf` test in the same PR as the fix, that test doesn't exist on main yet — no baseline, no delta, no proof. Stacking solves this:
1. **PR1 (test-only)** — adds the `@perf` test that exercises the bottleneck. Merges to main. CI runs it on main → baseline established.
2. **PR2 (fix)** — adds the optimization. CI runs the same test → compares against PR1's baseline → delta shows improvement.
## Workflow
### Step 1: Create the test branch
```bash
git worktree add <worktree-path> -b perf/test-<name> origin/main
```
### Step 2: Write the `@perf` test
Add a test to `browser_tests/tests/performance.spec.ts` (or a new file with `@perf` tag). The test should stress the specific bottleneck.
**Test structure:**
```typescript
test('<descriptive name>', async ({ comfyPage }) => {
// 1. Load a workflow that exercises the bottleneck
await comfyPage.workflow.loadWorkflow('<workflow>')
// 2. Start measuring
await comfyPage.perf.startMeasuring()
// 3. Perform the action that triggers the bottleneck (at scale)
for (let i = 0; i < N; i++) {
// ... stress the hot path ...
await comfyPage.nextFrame()
}
// 4. Stop measuring and record
const m = await comfyPage.perf.stopMeasuring('<metric-name>')
recordMeasurement(m)
console.log(`<name>: ${m.styleRecalcs} recalcs, ${m.layouts} layouts`)
})
```
**Available metrics** (from `PerformanceHelper`):
- `m.styleRecalcs` / `m.styleRecalcDurationMs` — style recalculation count and time
- `m.layouts` / `m.layoutDurationMs` — forced layout count and time
- `m.taskDurationMs` — total main-thread JS execution time
- `m.heapDeltaBytes` — memory pressure delta
**Key helpers** (from `ComfyPage`):
- `comfyPage.perf.startMeasuring()` / `.stopMeasuring(name)` — CDP metrics capture
- `comfyPage.nextFrame()` — wait one animation frame
- `comfyPage.workflow.loadWorkflow(name)` — load a test workflow from `browser_tests/assets/`
- `comfyPage.canvas` — the canvas locator
- `comfyPage.page.mouse.move(x, y)` — mouse interaction
### Step 3: Add test workflow asset (if needed)
If the bottleneck needs a specific workflow (e.g., 50+ nodes, many DOM widgets), add it to `browser_tests/assets/`. Keep it minimal — only the structure needed to trigger the bottleneck.
### Step 4: Verify locally
```bash
pnpm exec playwright test --project=performance --grep "<test name>"
```
Confirm the test runs and produces reasonable metric values.
### Step 5: Create PR1 (test-only)
```bash
pnpm typecheck:browser
pnpm lint
git add browser_tests/
git commit -m "test: add perf test for <bottleneck description>"
git push -u origin perf/test-<name>
gh pr create --title "test: add perf test for <bottleneck>" \
--body "Adds a @perf test to establish a baseline for <bottleneck>.
This is PR 1 of 2. The fix will follow in a separate PR once this baseline is established on main.
## What
Adds \`<test-name>\` to the performance test suite measuring <metric> during <action>.
## Why
Needed to prove the improvement from the upcoming fix for backlog item #<N>." \
--base main
```
### Step 6: Get PR1 merged
Once PR1 merges, CI runs the test on main → baseline artifact saved.
### Step 7: Create PR2 (fix) on top of main
```bash
git worktree add <worktree-path> -b perf/fix-<name> origin/main
```
Implement the fix. The `@perf` test from PR1 is now on main and will run automatically. CI will:
1. Run the test on the PR branch
2. Download the baseline from main (which includes PR1's test results)
3. Post a PR comment showing the delta
### Step 8: Verify the improvement shows in CI
The `ci-perf-report.yaml` posts a comment like:
```markdown
## ⚡ Performance Report
| Metric | Baseline | PR (n=3) | Δ | Sig |
| --------------------- | -------- | -------- | ---- | --- |
| <name>: style recalcs | 450 | 12 | -97% | 🟢 |
```
If Δ is negative for the target metric, the fix is proven.
## Test Design Guidelines
1. **Stress the specific bottleneck** — don't measure everything, isolate the hot path
2. **Use enough iterations** — the test should run long enough that the metric difference is clear (100+ frames for idle tests, 50+ interactions for event tests)
3. **Keep it deterministic** — avoid timing-dependent assertions; measure counts not durations when possible
4. **Match the backlog entry** — reference the backlog item number in the test name or PR description
## Examples
**Testing DOM widget reactive mutations (backlog #8):**
```typescript
test('DOM widget positioning recalculations', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
// Idle for 120 frames — DOM widgets update position every frame
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('dom-widget-idle')
recordMeasurement(m)
})
```
**Testing measureText caching (backlog #4):**
```typescript
test('canvas text rendering with many nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('large-workflow-50-nodes')
await comfyPage.perf.startMeasuring()
for (let i = 0; i < 60; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('text-rendering-50-nodes')
recordMeasurement(m)
})
```
## Reference
| Resource | Path |
| ----------------- | ----------------------------------------------------- |
| Perf test file | `browser_tests/tests/performance.spec.ts` |
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
| Report generator | `scripts/perf-report.ts` |
| Stats utilities | `scripts/perf-stats.ts` |
| Backlog | `docs/perf/BACKLOG.md` (local only, not committed) |
| Playbook | `docs/perf/PLAYBOOK.md` (local only, not committed) |

View File

@@ -0,0 +1,361 @@
---
name: ticket-intake
description: 'Parse ticket URL (Notion or GitHub), extract all data, initialize pipeline run. Use when starting work on a new ticket or when asked to pick up a ticket.'
---
# Ticket Intake
Parses a ticket URL from supported sources (Notion or GitHub), extracts all relevant information, and creates a ticket in the pipeline API.
> **🚨 CRITICAL REQUIREMENT**: This skill MUST register the ticket in the Pipeline API and update the source (Notion/GitHub). If these steps are skipped, the entire pipeline breaks. See [Mandatory API Calls](#mandatory-api-calls-execute-all-three) below.
## Supported Sources
| Source | URL Pattern | Provider File |
| ------ | --------------------------------------------------- | --------------------- |
| Notion | `https://notion.so/...` `https://www.notion.so/...` | `providers/notion.md` |
| GitHub | `https://github.com/{owner}/{repo}/issues/{n}` | `providers/github.md` |
## Quick Start
When given a ticket URL:
1. **Detect source type** from URL pattern
2. **Load provider-specific logic** from `providers/` directory
3. Fetch ticket content via appropriate API
4. Extract and normalize properties to common schema
5. **Register ticket in pipeline API** ← MANDATORY
6. **Update source** (Notion status / GitHub comment) ← MANDATORY
7. **Run verification script** to confirm API registration
8. Output summary and handoff to `research-orchestrator`
## Configuration
Uses the **production API** by default. No configuration needed for read operations.
**Defaults (no setup required):**
- API URL: `https://api-gateway-856475788601.us-central1.run.app`
- Read-only endpoints at `/public/*` require no authentication
**For write operations** (transitions, creating tickets), set:
```bash
export PIPELINE_API_KEY="..." # Get from GCP Secret Manager or ask admin
```
**Optional (for local working artifacts):**
```bash
PIPELINE_DIR="${PIPELINE_DIR:-$HOME/repos/ticket-to-pr-pipeline}"
```
## Mandatory API Calls (Execute ALL Three)
**⚠️ These three API calls are the ENTIRE POINT of this skill. Without them, the ticket is invisible to the pipeline, downstream skills will fail, and Notion status won't update.**
**You MUST make these HTTP requests.** Use `curl` from bash — do not just read this as documentation.
### Call 1: Create Ticket
```bash
API_URL="${PIPELINE_API_URL:-https://api-gateway-856475788601.us-central1.run.app}"
API_KEY="${PIPELINE_API_KEY}"
curl -s -X POST "${API_URL}/v1/tickets" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"notion_page_id": "NOTION_PAGE_UUID_HERE",
"title": "TICKET_TITLE_HERE",
"source": "notion",
"metadata": {
"description": "DESCRIPTION_HERE",
"priority": "High",
"labels": [],
"acceptanceCriteria": []
}
}'
```
Save the returned `id` — you need it for the next two calls.
### Call 2: Transition to RESEARCH
```bash
TICKET_ID="id-from-step-1"
curl -s -X POST "${API_URL}/v1/tickets/${TICKET_ID}/transition" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"to_state": "RESEARCH",
"reason": "Intake complete, starting research"
}'
```
### Call 3: Queue Source Update
```bash
curl -s -X POST "${API_URL}/v1/sync/queue" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
-d '{
"ticket_id": "TICKET_ID_HERE",
"action": "update_status",
"payload": { "status": "In Progress" },
"priority": "normal"
}'
```
> **Note:** The action MUST be `"update_status"` (not `"UPDATE_NOTION_STATUS"`). Valid actions: `update_status`, `update_pr_url`, `mark_done`.
### TypeScript Equivalent (if using pipeline client)
```typescript
import { PipelineClient } from '@pipeline/client'
const client = new PipelineClient({
apiUrl:
process.env.PIPELINE_API_URL ||
'https://api-gateway-856475788601.us-central1.run.app',
agentId: process.env.AGENT_ID!
})
const ticket = await client.createTicket({
notion_page_id: pageId,
title: ticketTitle,
source: 'notion',
metadata: { description, priority, labels, acceptanceCriteria }
})
await client.transitionState(
ticket.id,
'RESEARCH',
'Intake complete, starting research'
)
await client.queueSync(ticket.id, 'update_status', { status: 'In Progress' })
```
## Workflow
### Step 1: Detect Source Type
Parse the URL to determine source:
```javascript
if (url.includes('notion.so')) {
source = 'notion'
// Load providers/notion.md
} else if (url.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/)) {
source = 'github'
// Load providers/github.md
} else {
// Error: Unsupported URL format
}
```
### Step 2: Load Provider and Fetch Data
Read the appropriate provider file for source-specific instructions:
- **Notion**: `providers/notion.md` - Uses Notion MCP, handles Slack links
- **GitHub**: `providers/github.md` - Uses `gh` CLI, handles Dosu comments
Follow the provider's instructions for:
- Fetching content
- Extracting properties
- **Updating the source** (Notion status → "In Progress", Assignee → pipeline owner)
### Step 3: Normalize to Common Schema
All providers must extract normalized ticket data following `schema.md`:
```json
{
"id": "abc12345",
"url": "https://...",
"source": "notion | github",
"title": "Ticket title",
"description": "Full description",
"status": "Not Started",
"assignee": "username",
"priority": "High",
"area": "UI",
"labels": ["bug", "frontend"],
"acceptanceCriteria": ["Criterion 1", "Criterion 2"],
"fetchedAt": "2024-01-15T10:30:00Z"
}
```
### Step 4: Register Ticket in Pipeline API (MANDATORY — DO NOT SKIP)
**Execute all three API calls from [Mandatory API Calls](#mandatory-api-calls-execute-all-three) above.**
This is not optional. This is not documentation. You MUST make these HTTP requests right now.
1. `createTicket()` → save the returned ticket ID
2. `transitionState(id, 'RESEARCH')` → confirm state changed
3. `queueSync(id, 'update_status', { status: 'In Progress' })` → confirm queued
**If any call fails**, retry once. If it still fails, report the error prominently — do NOT silently continue.
### Step 5: Run Verification Script
After making the API calls, run the verification script to confirm everything worked:
```bash
bash scripts/verify-intake.sh TICKET_ID_OR_NOTION_PAGE_ID
```
**If the script is not available locally**, verify manually via the public API:
```bash
curl -s "${API_URL}/public/tickets/${TICKET_ID}" | jq '{id, state, title, notion_page_id}'
```
Expected output:
```json
{
"id": "...",
"state": "RESEARCH",
"title": "...",
"notion_page_id": "..."
}
```
**If `state` is not `RESEARCH`, go back to Step 4 and complete the missing calls.**
### Step 6: Output Summary and Handoff
Print a clear summary:
```markdown
## Ticket Intake Complete
**Source:** Notion | GitHub
**Title:** [Ticket title]
**ID:** abc12345
**Status:** In Progress (queued)
**Priority:** High
**Area:** UI
### Description
[Brief description or first 200 chars]
### Acceptance Criteria
- [ ] Criterion 1
- [ ] Criterion 2
### Links
- **Ticket:** [Original URL]
- **Slack:** [Slack thread content fetched via slackdump] (Notion only)
### Pipeline
- **API Ticket ID:** abc12345
- **State:** RESEARCH
- **Verified:** ✅ (via verify-intake.sh or public API)
```
**After printing the summary, immediately handoff** to continue the pipeline. Use the `handoff` tool with all necessary context (ticket ID, source, title, description, slack context if any):
> **Handoff goal:** "Continue pipeline for ticket {ID} ({title}). Ticket is in RESEARCH state. Load skill: `research-orchestrator` to begin research phase. Ticket data: source={source}, notion_page_id={pageId}, priority={priority}. {slack context summary if available}"
**Do NOT wait for human approval to proceed.** The intake phase is complete — handoff immediately.
## Error Handling
### Unsupported URL
```
❌ Unsupported ticket URL format.
Supported formats:
- Notion: https://notion.so/... or https://www.notion.so/...
- GitHub: https://github.com/{owner}/{repo}/issues/{number}
Received: [provided URL]
```
### Provider-Specific Errors
See individual provider files for source-specific error handling:
- `providers/notion.md` - Authentication, page not found
- `providers/github.md` - Auth, rate limits, issue not found
### Missing Properties
Continue with available data and note what's missing:
```
⚠️ Some properties unavailable:
- Priority: not found (using default: Medium)
- Area: not found
Proceeding with available data...
```
### API Call Failures
```
❌ Pipeline API call failed: {method} {endpoint}
Status: {status}
Error: {message}
Retrying once...
❌ Retry also failed. INTAKE IS INCOMPLETE.
The ticket was NOT registered in the pipeline.
Downstream skills will not work until this is fixed.
```
## Notes
- This skill focuses ONLY on intake — it does not do research
- Slack thread content is fetched automatically via the `slackdump` skill — no manual copy-paste needed
- ALL API calls (createTicket, transitionState, queueSync) are MANDATORY — never skip them
- The `queueSync` action must be `"update_status"`, NOT `"UPDATE_NOTION_STATUS"`
- Pipeline state is tracked via the API, not local files
- Working artifacts (research-report.md, plan.md) can be saved locally to `$PIPELINE_DIR/runs/{ticket-id}/`
- The `source` field in the ticket determines which research strategies to use
## API Client Reference
### Available Methods
| Method | Description |
| ----------------------------------------------------------- | ------------------------------------------------------------------- |
| `createTicket({ notion_page_id, title, source, metadata })` | Create a new ticket in the API |
| `getTicket(id)` | Retrieve a ticket by ID |
| `findByNotionId(notionPageId)` | Look up a ticket by its Notion page ID |
| `listTickets({ state, agent_id, limit, offset })` | List tickets with optional filters |
| `transitionState(id, state, reason)` | Move ticket to a new state (e.g., `'RESEARCH'`) |
| `setPRCreated(id, prUrl)` | Mark ticket as having a PR created |
| `queueSync(id, action, payload)` | Queue a sync action (`update_status`, `update_pr_url`, `mark_done`) |
| `registerBranch(id, branch, repo)` | Register working branch for automatic PR detection |
### Error Handling
```typescript
import { PipelineClient, PipelineAPIError } from '@pipeline/client';
try {
await client.createTicket({ ... });
} catch (error) {
if (error instanceof PipelineAPIError) {
console.error(`API Error ${error.status}: ${error.message}`);
}
throw error;
}
```

View File

@@ -0,0 +1,194 @@
# GitHub Provider - Ticket Intake
Provider-specific logic for ingesting tickets from GitHub Issues.
## URL Pattern
```
https://github.com/{owner}/{repo}/issues/{number}
https://www.github.com/{owner}/{repo}/issues/{number}
```
Extract: `owner`, `repo`, `issue_number` from URL.
## Prerequisites
- `gh` CLI authenticated (`gh auth status`)
- Access to the repository
## Fetch Issue Content
Use `gh` CLI to fetch issue details:
```bash
# Get issue details in JSON
gh issue view {number} --repo {owner}/{repo} --json title,body,state,labels,assignees,milestone,author,createdAt,comments,linkedPRs
# Get comments separately if needed
gh issue view {number} --repo {owner}/{repo} --comments
```
## Extract Ticket Data
Map GitHub issue fields to normalized ticket data (stored via API):
| GitHub Field | ticket.json Field | Notes |
| ------------ | ----------------- | -------------------------- |
| title | title | Direct mapping |
| body | description | Issue body/description |
| state | status | Map: open → "Not Started" |
| labels | labels | Array of label names |
| assignees | assignee | First assignee login |
| author | author | Issue author login |
| milestone | milestone | Milestone title if present |
| comments | comments | Array of comment objects |
| linkedPRs | linkedPRs | PRs linked to this issue |
### Priority Mapping
Infer priority from labels:
- `priority:critical`, `P0` → "Critical"
- `priority:high`, `P1` → "High"
- `priority:medium`, `P2` → "Medium"
- `priority:low`, `P3` → "Low"
- No priority label → "Medium" (default)
### Area Mapping
Infer area from labels:
- `area:ui`, `frontend`, `component:*` → "UI"
- `area:api`, `backend` → "API"
- `area:docs`, `documentation` → "Docs"
- `bug`, `fix` → "Bug"
- `enhancement`, `feature` → "Feature"
## Update Source
**For GitHub issues, update is optional but recommended.**
Add a comment to indicate work has started:
```bash
gh issue comment {number} --repo {owner}/{repo} --body "🤖 Pipeline started processing this issue."
```
Optionally assign to self:
```bash
gh issue edit {number} --repo {owner}/{repo} --add-assignee @me
```
Log any updates via the Pipeline API:
```typescript
await client.updateTicket(ticketId, {
metadata: {
...ticket.metadata,
githubWrites: [
...(ticket.metadata?.githubWrites || []),
{
action: 'comment',
issueNumber: 123,
at: new Date().toISOString(),
skill: 'ticket-intake',
success: true
}
]
}
})
```
## GitHub-Specific Ticket Fields
Store via API using `client.createTicket()`:
```json
{
"source": "github",
"githubOwner": "Comfy-Org",
"githubRepo": "ComfyUI_frontend",
"githubIssueNumber": 123,
"githubIssueUrl": "https://github.com/Comfy-Org/ComfyUI_frontend/issues/123",
"labels": ["bug", "area:ui", "priority:high"],
"linkedPRs": [456, 789],
"dosuComment": "..." // Extracted Dosu bot analysis if present
}
```
## Dosu Bot Detection
Many repositories use Dosu bot for automated issue analysis. Check comments for Dosu:
```bash
gh issue view {number} --repo {owner}/{repo} --comments | grep -A 100 "dosu"
```
Look for comments from:
- `dosu[bot]`
- `dosu-bot`
Extract Dosu analysis which typically includes:
- Root cause analysis
- Suggested files to modify
- Related issues/PRs
- Potential solutions
Store in ticket data via API:
```json
{
"dosuComment": {
"found": true,
"analysis": "...",
"suggestedFiles": ["src/file1.ts", "src/file2.ts"],
"relatedIssues": [100, 101]
}
}
```
## Extract Linked Issues/PRs
Parse issue body and comments for references:
- `#123` → Issue or PR reference
- `fixes #123`, `closes #123` → Linked issue
- `https://github.com/.../issues/123` → Full URL reference
Store in ticket data via API for research phase:
```json
{
"referencedIssues": [100, 101, 102],
"referencedPRs": [200, 201]
}
```
## Error Handling
### Authentication Error
```
⚠️ GitHub CLI not authenticated.
Run: gh auth login
```
### Issue Not Found
```
❌ GitHub issue not found or inaccessible.
- Check the URL is correct
- Ensure you have access to this repository
- Run: gh auth status
```
### Rate Limiting
```
⚠️ GitHub API rate limited.
Wait a few minutes and try again.
Check status: gh api rate_limit
```

View File

@@ -0,0 +1,202 @@
# Notion Provider - Ticket Intake
Provider-specific logic for ingesting tickets from Notion.
## URL Pattern
```
https://www.notion.so/workspace/Page-Title-abc123def456...
https://notion.so/Page-Title-abc123def456...
https://www.notion.so/abc123def456...
```
Page ID is the 32-character hex string (with or without hyphens).
## Prerequisites
- Notion MCP connected and authenticated
- If not setup: `claude mcp add --transport http notion https://mcp.notion.com/mcp`
- Authenticate via `/mcp` command if prompted
## Fetch Ticket Content
Use `Notion:notion-fetch` with the page URL or ID:
```
Fetch the full page content including all properties
```
## Extract Ticket Data
Extract these properties (names may vary):
| Property | Expected Name | Type |
| ------------- | ------------------------- | ------------ |
| Title | Name / Title | Title |
| Status | Status | Select |
| Assignee | Assignee / Assigned To | Person |
| Description | - | Page content |
| Slack Link | Slack Link / Slack Thread | URL |
| GitHub PR | GitHub PR / PR Link | URL |
| Priority | Priority | Select |
| Area | Area / Category | Select |
| Related Tasks | Related Tasks | Relation |
**If properties are missing**: Note what's unavailable and continue with available data.
## Update Source (REQUIRED)
**⚠️ DO NOT SKIP THIS STEP. This is a required action, not optional.**
**⚠️ Notion Write Safety rules apply (see `$PIPELINE_DIR/docs/notion-write-safety.md` for full reference):**
- **Whitelist**: Only `Status`, `GitHub PR`, and `Assignee` fields may be written
- **Valid transitions**: Not Started → In Progress, In Progress → In Review, In Review → Done
- **Logging**: Every write attempt MUST be logged with timestamp, field, value, previous value, skill name, and success status
Use `Notion:notion-update-page` to update the ticket:
1. **Status**: Set to "In Progress" (only valid from "Not Started")
2. **Assignee**: Assign to pipeline owner (Notion ID: `175d872b-594c-81d4-ba5a-0002911c5966`)
```json
{
"page_id": "{page_id_from_ticket}",
"command": "update_properties",
"properties": {
"Status": "In Progress",
"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"
}
}
```
**After the update succeeds**, log the write via the Pipeline API:
```typescript
await client.updateTicket(ticketId, {
metadata: {
...ticket.metadata,
notionWrites: [
...(ticket.metadata?.notionWrites || []),
{
field: 'Status',
value: 'In Progress',
previousValue: 'Not Started',
at: new Date().toISOString(),
skill: 'ticket-intake',
success: true
}
]
}
})
```
If update fails, log with `success: false` and continue.
## Notion-Specific Ticket Fields
Store via API using `client.createTicket()`:
```json
{
"source": "notion",
"notionPageId": "abc123def456...",
"slackLink": "https://slack.com/...",
"relatedTasks": ["page-id-1", "page-id-2"]
}
```
## Slack Thread Handling
If a Slack link exists, use the `slackdump` skill to fetch the thread content programmatically.
### Slack URL Conversion
Notion stores Slack links in `slackMessage://` format:
```
slackMessage://comfy-organization.slack.com/CHANNEL_ID/THREAD_TS/MESSAGE_TS
```
Convert to browser-clickable format:
```
https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS_NO_DOT
```
**Example:**
- Input: `slackMessage://comfy-organization.slack.com/C075ANWQ8KS/1766022478.450909/1764772881.854829`
- Output: `https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1764772881854829`
(Remove the dot from the last timestamp and prefix with `p`)
### Fetching Thread Content
Load the `slackdump` skill and use the **export-thread** workflow:
```bash
# Export thread by URL
slackdump dump "https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS"
# Or by colon notation (channel_id:thread_ts)
slackdump dump CHANNEL_ID:THREAD_TS
```
Save the thread content to `$RUN_DIR/slack-context.md` and include it in the ticket metadata.
> **No manual action required.** The slackdump CLI handles authentication via stored credentials at `~/.cache/slackdump/comfy-organization.bin`.
## Database Reference: Comfy Tasks
The "Comfy Tasks" database has these properties (verify via `notion-search`):
- **Status values**: Not Started, In Progress, In Review, Done
- **Team assignment**: "Frontend Team" for unassigned tickets
- **Filtering note**: Team filtering in Notion may have quirks - handle gracefully
### Pipeline Owner Details
When assigning tickets, use these identifiers:
| Platform | Identifier |
| --------------- | -------------------------------------- |
| Notion User ID | `175d872b-594c-81d4-ba5a-0002911c5966` |
| Notion Name | Christian Byrne |
| Notion Email | cbyrne@comfy.org |
| Slack User ID | U087MJCDHHC |
| GitHub Username | christian-byrne |
**To update Assignee**, use the Notion User ID (not name):
```
properties: {"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"}
```
### Finding Active Tickets
To list your active tickets:
```
Use Notion:notion-search for "Comfy Tasks"
Filter by Assignee = current user OR Team = "Frontend Team"
```
## Error Handling
### Authentication Error
```
⚠️ Notion authentication required.
Run: claude mcp add --transport http notion https://mcp.notion.com/mcp
Then authenticate via /mcp command.
```
### Page Not Found
```
❌ Notion page not found or inaccessible.
- Check the URL is correct
- Ensure you have access to this page
- Try re-authenticating via /mcp
```

View File

@@ -0,0 +1,81 @@
# Ticket Schema
Common schema for normalized ticket data across all sources. This data is stored and retrieved via the Pipeline API, not local files.
## Ticket Data Schema
```json
{
// Required fields (all sources)
"id": "string", // Unique identifier (short form)
"url": "string", // Original URL
"source": "notion | github", // Source type
"title": "string", // Ticket title
"description": "string", // Full description/body
"fetchedAt": "ISO8601", // When ticket was fetched
// Common optional fields
"status": "string", // Current status
"assignee": "string", // Assigned user
"priority": "string", // Priority level
"area": "string", // Category/area
"labels": ["string"], // Tags/labels
"acceptanceCriteria": ["string"] // List of AC items
// Source-specific fields (see providers)
// Notion: notionPageId, slackLink, relatedTasks, notionWrites
// GitHub: githubOwner, githubRepo, githubIssueNumber, linkedPRs, dosuComment, referencedIssues
}
```
## Ticket State Schema (via API)
State is managed via the Pipeline API using `client.transitionState()`:
```json
{
"ticketId": "string",
"state": "intake | research | planning | implementation | pr_created | done | failed",
"stateChangedAt": "ISO8601",
// Timestamps tracked by API
"createdAt": "ISO8601",
"updatedAt": "ISO8601"
}
```
## Priority Normalization
All sources should normalize to these values:
| Normalized | Description |
| ---------- | ------------------------- |
| Critical | Production down, security |
| High | Blocking work, urgent |
| Medium | Normal priority (default) |
| Low | Nice to have, backlog |
## Status Normalization
Pipeline tracks these statuses internally:
| Status | Description |
| -------------- | ---------------------------- |
| research | Gathering context |
| planning | Creating implementation plan |
| implementation | Writing code |
| review | Code review in progress |
| qa | Quality assurance |
| done | PR merged or completed |
## ID Generation
IDs are generated by the API when creating tickets. For reference:
- **Notion**: First 8 characters of page ID
- **GitHub**: `gh-{owner}-{repo}-{issue_number}` (sanitized)
Examples:
- Notion: `abc12345`
- GitHub: `gh-comfy-org-frontend-123`

View File

@@ -0,0 +1,143 @@
---
name: writing-storybook-stories
description: 'Write or update Storybook stories for Vue components in ComfyUI_frontend. Use when adding, modifying, reviewing, or debugging `.stories.ts` files, Storybook docs, component demos, or visual catalog entries in `src/` or `apps/desktop-ui/`.'
---
# Write Storybook Stories for ComfyUI_frontend
## Workflow
1. !!!!IMPORTANT Confirm the worktree is on a `feat/*` or `fix/*` branch. Base PRs on the local `main`, not a fork branch.
2. Read the component source first. Understand props, emits, slots, exposed methods, and any supporting types or composables.
3. Read nearby stories before writing anything.
- Search stories: `rg --files src apps | rg '\.stories\.ts$'`
- Inspect title patterns: `rg -n "title:\\s*'" src apps --glob '*.stories.ts'`
4. If a Figma link is provided, list the states you need to cover before writing stories.
5. Co-locate the story file with the component: `ComponentName.stories.ts`.
6. Add each variation on separate stories, except hover state. this should be automatically applied by the implementation and not require a separate story.
7. Run Storybook and validation checks before handing off.
## Match Local Conventions
- Copy the closest neighboring story instead of forcing one universal template.
- Most repo stories use `@storybook/vue3-vite`. Some stories under `apps/desktop-ui` still use `@storybook/vue3`; keep the local convention for that area.
- Add `tags: ['autodocs']` unless the surrounding stories in that area intentionally omit it.
- Use `ComponentPropsAndSlots<typeof Component>` when it helps with prop and slot typing.
- Keep `render` functions stateful when needed. Use `ref()`, `computed()`, and `toRefs(args)` instead of mutating Storybook args directly.
- Use `args.default` or other slot-shaped args when the component content is provided through slots.
- Use `ComponentExposed` only when a component's exposed API breaks the normal Storybook typing.
- Add decorators for realistic width or background context when the component needs it.
## Title Patterns
Do not invent titles from scratch when a close sibling story already exists. Match the nearest domain pattern.
| Component area | Typical title pattern |
| ------------------------------------------------------- | ------------------------------------ |
| `src/components/ui/button/Button.vue` | `Components/Button/Button` |
| `src/components/ui/input/Input.vue` | `Components/Input` |
| `src/components/ui/search-input/SearchInput.vue` | `Components/Input/SearchInput` |
| `src/components/common/SearchBox.vue` | `Components/Input/SearchBox` |
| `src/renderer/extensions/vueNodes/widgets/components/*` | `Widgets/<WidgetName>` |
| `src/platform/assets/components/*` | `Platform/Assets/<ComponentName>` |
| `apps/desktop-ui/src/components/*` | `Desktop/Components/<ComponentName>` |
| `apps/desktop-ui/src/views/*` | `Desktop/Views/<ViewName>` |
If multiple patterns seem plausible, follow the closest sibling story in the same folder tree.
## Common Story Shapes
### Stateful input or `v-model`
```typescript
export const Default: Story = {
render: (args) => ({
components: { MyComponent },
setup() {
const { disabled, size } = toRefs(args)
const value = ref('Hello world')
return { value, disabled, size }
},
template:
'<MyComponent v-model="value" :disabled="disabled" :size="size" />'
})
}
```
### Slot-driven content
```typescript
const meta: Meta<ComponentPropsAndSlots<typeof Button>> = {
argTypes: {
default: { control: 'text' }
},
args: {
default: 'Button'
}
}
export const SingleButton: Story = {
render: (args) => ({
components: { Button },
setup() {
return { args }
},
template: '<Button v-bind="args">{{ args.default }}</Button>'
})
}
```
### Variants or edge cases grid
```typescript
export const AllVariants: Story = {
render: () => ({
components: { MyComponent },
template: `
<div class="grid gap-4 sm:grid-cols-2">
<MyComponent />
<MyComponent disabled />
<MyComponent loading />
<MyComponent invalid />
</div>
`
})
}
```
## Figma Mapping
- Extract the named states from the design first.
- Prefer explicit prop-driven stories such as `Disabled`, `Loading`, `Invalid`, `WithPlaceholder`, `AllSizes`, or `EdgeCases`.
- Add an aggregate story such as `AllVariants`, `AllSizes`, or `EdgeCases` when side-by-side comparison is useful.
- Use pseudo-state parameters only if the addon is already configured in this repo.
- If a Figma state cannot be represented exactly, capture the closest prop-driven version and explain the gap in the story docs.
## Component-Specific Notes
- Widget components often need a minimal `SimplifiedWidget` object. Build it in `setup()` and use `computed()` when `args` change `widget.options`.
- Input and search components often need a width-constrained wrapper so they render at realistic sizes.
- Asset and platform cards often need background decorators such as `bg-base-background` and fixed-width containers.
- Desktop installer stories may need custom `backgrounds` parameters and may intentionally keep the older Storybook import style used by neighboring files.
- Use semantic tokens such as `bg-base-background` and `bg-node-component-surface` instead of `dark:` variants or hardcoded theme assumptions.
## Checklist
- [ ] Read the component source and any supporting types or composables
- [ ] Match the nearest local title pattern and story style
- [ ] Include a baseline story; name it `Default` only when that matches nearby conventions
- [ ] Add focused stories for meaningful states
- [ ] Add `tags: ['autodocs']`
- [ ] Keep the story co-located with the component
- [ ] Run `pnpm storybook`
- [ ] Run `pnpm typecheck`
- [ ] Run `pnpm lint`
## Avoid
- Do not guess props, emits, slots, or exposed methods.
- Do not force one generic title convention across the repo.
- Do not mutate Storybook args directly for `v-model` components.
- Do not introduce `dark:` Tailwind variants in story wrappers.
- Do not create barrel files.
- Do not assume every story needs `layout: 'centered'` or a `Default` export; follow the nearest existing pattern.

View File

@@ -0,0 +1,4 @@
interface:
display_name: 'ComfyUI Storybook Stories'
short_description: 'Write Vue Storybook stories for ComfyUI'
default_prompt: 'Use $writing-storybook-stories to add or update a Storybook story for this ComfyUI_frontend component.'

View File

@@ -12,3 +12,14 @@ reviews:
- comfy-pr-bot
- github-actions
- github-actions[bot]
pre_merge_checks:
custom_checks:
- name: End-to-end regression coverage for fixes
mode: warning
instructions: |
Pass if at least one of the following is true:
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes at least one file under `browser_tests/`.
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.

View File

@@ -19,7 +19,7 @@ runs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
cache-dependency-path: './pnpm-lock.yaml'

View File

@@ -61,6 +61,22 @@ jobs:
git commit -m "[automated] Apply ESLint and Oxfmt fixes"
git push
- name: Fail for fork PRs with unfixed lint/format issues
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "::error::Linting/formatting issues found. Since this PR is from a fork, auto-fix cannot be applied automatically."
echo ""
echo "Please run these commands locally and push the changes:"
echo " pnpm lint:fix"
echo " pnpm stylelint:fix"
echo " pnpm format"
echo ""
echo "Or set up pre-commit hooks to automatically format on every commit:"
echo " pnpm prepare"
echo ""
echo "See CONTRIBUTING.md for more details."
exit 1
- name: Final validation
run: |
pnpm lint
@@ -84,16 +100,3 @@ jobs:
repo: context.repo.repo,
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting'
})
- name: Comment on PR about manual fix needed
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
continue-on-error: true
uses: actions/github-script@v8
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## ⚠️ Linting/Formatting Issues Found\n\nThis PR has linting or formatting issues that need to be fixed.\n\n**Since this PR is from a fork, auto-fix cannot be applied automatically.**\n\n### Option 1: Set up pre-commit hooks (recommended)\nRun this once to automatically format code on every commit:\n```bash\npnpm prepare\n```\n\n### Option 2: Fix manually\nRun these commands and push the changes:\n```bash\npnpm lint:fix\npnpm format\n```\n\nSee [CONTRIBUTING.md](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/CONTRIBUTING.md#git-pre-commit-hooks) for more details.'
})

1
.nxignore Normal file
View File

@@ -0,0 +1 @@
.claude/worktrees

View File

@@ -328,9 +328,11 @@ test.describe('Settings', () => {
})
await newBlankWorkflowRow.click()
// Click edit button
const editKeybindingButton = newBlankWorkflowRow.locator('.pi-pencil')
await editKeybindingButton.click()
// Click add keybinding button (New Blank Workflow has no default keybinding)
const addKeybindingButton = newBlankWorkflowRow.locator(
'.icon-\\[lucide--plus\\]'
)
await addKeybindingButton.click()
// Set new keybinding
const input = comfyPage.page.getByPlaceholder('Enter your keybind')

View File

@@ -0,0 +1,123 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
/**
* Returns the client-space position of a group's title bar (for clicking).
*/
async function getGroupTitlePosition(
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number }> {
const pos = await comfyPage.page.evaluate((title) => {
const app = window.app!
const group = app.graph.groups.find(
(g: { title: string }) => g.title === title
)
if (!group) return null
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
/**
* Returns {selectedNodeCount, selectedGroupCount, selectedItemCount}
* from the canvas in the browser.
*/
async function getSelectionCounts(comfyPage: ComfyPage) {
return comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
let selectedNodeCount = 0
let selectedGroupCount = 0
for (const item of canvas.selectedItems) {
if ('inputs' in item || 'outputs' in item) selectedNodeCount++
else selectedGroupCount++
}
return {
selectedNodeCount,
selectedGroupCount,
selectedItemCount: canvas.selectedItems.size
}
})
}
test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('Setting enabled: clicking outer group selects nested group and inner node', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'LiteGraph.Group.SelectChildrenOnClick',
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
await comfyPage.nextFrame()
const counts = await getSelectionCounts(comfyPage)
// Outer Group + Inner Group + 1 node = 3 items
expect(counts.selectedItemCount).toBe(3)
expect(counts.selectedGroupCount).toBe(2)
expect(counts.selectedNodeCount).toBe(1)
})
test('Setting disabled: clicking outer group selects only the group', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'LiteGraph.Group.SelectChildrenOnClick',
false
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
await comfyPage.nextFrame()
const counts = await getSelectionCounts(comfyPage)
expect(counts.selectedItemCount).toBe(1)
expect(counts.selectedGroupCount).toBe(1)
expect(counts.selectedNodeCount).toBe(0)
})
test('Deselecting outer group deselects all children', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'LiteGraph.Group.SelectChildrenOnClick',
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
// Select the outer group (cascades to children)
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
await comfyPage.nextFrame()
let counts = await getSelectionCounts(comfyPage)
expect(counts.selectedItemCount).toBe(3)
// Deselect all via page.evaluate to avoid UI overlay interception
await comfyPage.page.evaluate(() => {
window.app!.canvas.deselectAll()
})
await comfyPage.nextFrame()
counts = await getSelectionCounts(comfyPage)
expect(counts.selectedItemCount).toBe(0)
})
})

View File

@@ -0,0 +1,104 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { Position } from '../fixtures/types'
type NodeSnapshot = { id: number } & Position
async function getAllNodePositions(
comfyPage: ComfyPage
): Promise<NodeSnapshot[]> {
return comfyPage.page.evaluate(() =>
window.app!.graph.nodes.map((n) => ({
id: n.id as number,
x: n.pos[0],
y: n.pos[1]
}))
)
}
async function getNodePosition(
comfyPage: ComfyPage,
nodeId: number
): Promise<Position | undefined> {
return comfyPage.page.evaluate((targetNodeId) => {
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)
if (!node) return
return {
x: node.pos[0],
y: node.pos[1]
}
}, nodeId)
}
async function expectNodePositionStable(
comfyPage: ComfyPage,
initial: NodeSnapshot,
mode: string
) {
await expect
.poll(
async () => {
const current = await getNodePosition(comfyPage, initial.id)
return current?.x ?? Number.NaN
},
{ message: `node ${initial.id} x drifted in ${mode} mode` }
)
.toBeCloseTo(initial.x, 1)
await expect
.poll(
async () => {
const current = await getNodePosition(comfyPage, initial.id)
return current?.y ?? Number.NaN
},
{ message: `node ${initial.id} y drifted in ${mode} mode` }
)
.toBeCloseTo(initial.y, 1)
}
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
if (enabled) {
await comfyPage.vueNodes.waitForNodes()
}
await comfyPage.nextFrame()
}
test.describe(
'Renderer toggle stability',
{ tag: ['@node', '@canvas'] },
() => {
test('node positions do not drift when toggling between Vue and LiteGraph renderers', async ({
comfyPage
}) => {
const TOGGLE_COUNT = 5
const initialPositions = await getAllNodePositions(comfyPage)
expect(initialPositions.length).toBeGreaterThan(0)
for (let i = 0; i < TOGGLE_COUNT; i++) {
await setVueMode(comfyPage, true)
for (const initial of initialPositions) {
await expectNodePositionStable(
comfyPage,
initial,
`Vue toggle ${i + 1}`
)
}
await setVueMode(comfyPage, false)
for (const initial of initialPositions) {
await expectNodePositionStable(
comfyPage,
initial,
`LiteGraph toggle ${i + 1}`
)
}
}
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -0,0 +1,102 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Advanced Widget Visibility', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
// Add a ModelSamplingFlux node which has both advanced (max_shift,
// base_shift) and non-advanced (width, height) widgets.
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('ModelSamplingFlux')!
node.pos = [500, 200]
window.app!.graph.add(node)
})
await comfyPage.vueNodes.waitForNodes()
})
function getNode(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
}
function getWidgets(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return getNode(comfyPage).locator('.lg-node-widget')
}
test('should hide advanced widgets by default', async ({ comfyPage }) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
// Non-advanced widgets (width, height) should be visible
await expect(widgets).toHaveCount(2)
await expect(node.getByLabel('width', { exact: true })).toBeVisible()
await expect(node.getByLabel('height', { exact: true })).toBeVisible()
// Advanced widgets should not be rendered
await expect(
node.getByLabel('max_shift', { exact: true })
).not.toBeVisible()
await expect(
node.getByLabel('base_shift', { exact: true })
).not.toBeVisible()
// "Show advanced inputs" button should be present
await expect(node.getByText('Show advanced inputs')).toBeVisible()
})
test('should show advanced widgets when per-node toggle is clicked', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Click the toggle button to show advanced widgets
await node.getByText('Show advanced inputs').click()
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// Button text should change to "Hide advanced inputs"
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
// Click again to hide
await node.getByText('Hide advanced inputs').click()
await expect(widgets).toHaveCount(2)
})
test('should show advanced widgets when global setting is enabled', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Enable the global setting
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
true
)
// All 4 widgets should now be visible
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// The toggle button should not be shown when global setting is active
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
})
})

View File

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

1
global.d.ts vendored
View File

@@ -1,4 +1,5 @@
declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __COMFYUI_FRONTEND_COMMIT__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string

View File

@@ -47,7 +47,60 @@
border: 0;
}
</style>
<link rel="stylesheet" href="splash.css" />
<style>
/* Pre-Vue splash loader — inlined to avoid SPA fallback serving
index.html instead of CSS on cloud/ephemeral environments */
#splash-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
contain: strict;
}
#splash-loader svg {
width: min(200px, 50vw);
height: auto;
transform: translateZ(0);
}
#splash-loader .wave-group {
animation: splash-rise 4s ease-in-out infinite alternate;
will-change: transform;
transform: translateZ(0);
}
#splash-loader .wave-path {
animation: splash-wave 1.2s linear infinite;
will-change: transform;
transform: translateZ(0);
}
@keyframes splash-rise {
from {
transform: translateY(280px);
}
to {
transform: translateY(-80px);
}
}
@keyframes splash-wave {
from {
transform: translateX(0);
}
to {
transform: translateX(-880px);
}
}
@media (prefers-reduced-motion: reduce) {
#splash-loader .wave-group,
#splash-loader .wave-path {
animation: none;
}
#splash-loader .wave-group {
transform: translateY(-80px);
}
}
</style>
<link rel="manifest" href="manifest.json" />
</head>

View File

@@ -11,7 +11,7 @@ const config: KnipConfig = {
'src/types/index.ts',
'src/storybook/mocks/**/*.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
},
'apps/desktop-ui': {
entry: ['src/main.ts', 'src/i18n.ts'],

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.42.3",
"version": "1.42.4",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -154,6 +154,7 @@
"eslint-plugin-storybook": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
"eslint-plugin-vue": "catalog:",
"fast-check": "catalog:",
"fs-extra": "^11.2.0",
"globals": "catalog:",
"happy-dom": "catalog:",

19
pnpm-lock.yaml generated
View File

@@ -204,6 +204,9 @@ catalogs:
eslint-plugin-vue:
specifier: ^10.6.2
version: 10.6.2
fast-check:
specifier: ^4.5.3
version: 4.5.3
firebase:
specifier: ^11.6.0
version: 11.6.0
@@ -672,6 +675,9 @@ importers:
eslint-plugin-vue:
specifier: 'catalog:'
version: 10.6.2(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.1(jiti@2.6.1)))
fast-check:
specifier: 'catalog:'
version: 4.5.3
fs-extra:
specifier: ^11.2.0
version: 11.3.2
@@ -5547,6 +5553,10 @@ packages:
extendable-media-recorder@9.2.27:
resolution: {integrity: sha512-2X+Ixi1cxLek0Cj9x9atmhQ+apG+LwJpP2p3ypP8Pxau0poDnicrg7FTfPVQV5PW/3DHFm/eQ16vbgo5Yk3HGQ==}
fast-check@4.5.3:
resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==}
engines: {node: '>=12.17.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -7348,6 +7358,9 @@ packages:
resolution: {integrity: sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==}
engines: {node: '>=12.20'}
pure-rand@7.0.1:
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
qified@0.5.3:
resolution: {integrity: sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==}
engines: {node: '>=20'}
@@ -13886,6 +13899,10 @@ snapshots:
subscribable-things: 2.1.53
tslib: 2.8.1
fast-check@4.5.3:
dependencies:
pure-rand: 7.0.1
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -16109,6 +16126,8 @@ snapshots:
dependencies:
escape-goat: 4.0.0
pure-rand@7.0.1: {}
qified@0.5.3:
dependencies:
hookified: 1.14.0

View File

@@ -69,6 +69,7 @@ catalog:
eslint-plugin-storybook: ^10.2.10
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
fast-check: ^4.5.3
firebase: ^11.6.0
glob: ^13.0.6
globals: ^16.5.0

View File

@@ -9,15 +9,19 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, 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 { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { electronAPI } from '@/utils/envUtil'
import { isDesktop } from '@/platform/distribution/types'
import { app } from '@/scripts/app'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
@@ -45,6 +49,19 @@ const showContextMenu = (event: MouseEvent) => {
}
}
function handleResourceError(url: string, tagName: string) {
console.error('[resource:loadError]', { url, tagName })
if (__DISTRIBUTION__ === 'cloud') {
captureException(new Error(`Resource load failed: ${url}`), {
tags: {
error_type: 'resource_load_error',
tag_name: tagName
}
})
}
}
onMounted(() => {
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
@@ -56,15 +73,56 @@ onMounted(() => {
// See: https://vite.dev/guide/build#load-error-handling
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
const info = parsePreloadError(event.payload)
console.error('[vite:preloadError]', {
url: info.url,
fileType: info.fileType,
chunkName: info.chunkName,
message: info.message
})
if (__DISTRIBUTION__ === 'cloud') {
captureException(event.payload, {
tags: { error_type: 'vite_preload_error' }
tags: {
error_type: 'vite_preload_error',
file_type: info.fileType,
chunk_name: info.chunkName ?? undefined
},
contexts: {
preload: {
url: info.url,
fileType: info.fileType,
chunkName: info.chunkName
}
}
})
} else {
console.error('[vite:preloadError]', event.payload)
}
useToastStore().add({
severity: 'error',
summary: t('g.preloadErrorTitle'),
detail: t('g.preloadError'),
life: 10000
})
})
// Capture resource load failures (CSS, scripts) in non-localhost distributions
if (__DISTRIBUTION__ !== 'localhost') {
window.addEventListener(
'error',
(event) => {
const target = event.target
if (target instanceof HTMLScriptElement) {
handleResourceError(target.src, 'script')
} else if (
target instanceof HTMLLinkElement &&
target.rel === 'stylesheet'
) {
handleResourceError(target.href, 'link')
}
},
true
)
}
// Initialize conflict detection in background
// This runs async and doesn't block UI setup
void conflictDetection.initializeConflictDetection()

View File

@@ -78,6 +78,14 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
vi.mock('@/scripts/app', () => ({
app: {
menu: {
element: document.createElement('div')
}
}
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
@@ -131,6 +139,18 @@ function createWrapper({
})
}
function getLegacyCommandsContainer(
wrapper: ReturnType<typeof createWrapper>
): HTMLElement {
const legacyContainer = wrapper.find(
'[data-testid="legacy-topbar-container"]'
).element
if (!(legacyContainer instanceof HTMLElement)) {
throw new Error('Expected legacy commands container to be present')
}
return legacyContainer
}
function createJob(id: string, status: JobStatus): JobListItem {
return {
id,
@@ -515,4 +535,69 @@ describe('TopMenuSection', () => {
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
})
it('coalesces legacy topbar mutation scans to one check per frame', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const rafCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCallbacks.push(cb)
return rafCallbacks.length
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.UseNewMenu') return 'Top'
if (key === 'Comfy.UI.TabBarLayout') return 'Integrated'
if (key === 'Comfy.RightSidePanel.IsOpen') return true
return undefined
})
const wrapper = createWrapper({ pinia, attachTo: document.body })
try {
await nextTick()
const actionbarContainer = wrapper.find('.actionbar-container')
expect(actionbarContainer.classes()).toContain('w-0')
const legacyContainer = getLegacyCommandsContainer(wrapper)
const querySpy = vi.spyOn(legacyContainer, 'querySelector')
if (rafCallbacks.length > 0) {
const initialCallbacks = [...rafCallbacks]
rafCallbacks.length = 0
initialCallbacks.forEach((callback) => callback(0))
await nextTick()
}
querySpy.mockClear()
querySpy.mockReturnValue(document.createElement('div'))
for (let index = 0; index < 3; index++) {
const outer = document.createElement('div')
const inner = document.createElement('div')
inner.textContent = `legacy-${index}`
outer.appendChild(inner)
legacyContainer.appendChild(outer)
}
await vi.waitFor(() => {
expect(rafCallbacks.length).toBeGreaterThan(0)
})
expect(querySpy).not.toHaveBeenCalled()
const callbacks = [...rafCallbacks]
rafCallbacks.length = 0
callbacks.forEach((callback) => callback(0))
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer.classes()).toContain('px-2')
} finally {
wrapper.unmount()
vi.unstubAllGlobals()
}
})
})

View File

@@ -39,6 +39,7 @@
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
@@ -116,7 +117,7 @@
<script setup lang="ts">
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
@@ -264,6 +265,7 @@ const rightSidePanelTooltipConfig = computed(() =>
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)
let legacyContentCheckRafId: number | null = null
function checkLegacyContent() {
const el = legacyCommandsContainerRef.value
@@ -276,19 +278,35 @@ function checkLegacyContent() {
el.querySelector(':scope > * > *:not(:empty)') !== null
}
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
function scheduleLegacyContentCheck() {
if (legacyContentCheckRafId !== null) return
legacyContentCheckRafId = requestAnimationFrame(() => {
legacyContentCheckRafId = null
checkLegacyContent()
})
}
useMutationObserver(legacyCommandsContainerRef, scheduleLegacyContentCheck, {
childList: true,
subtree: true,
characterData: true
subtree: true
})
onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'
legacyCommandsContainerRef.value.appendChild(app.menu.element)
checkLegacyContent()
}
})
onBeforeUnmount(() => {
if (legacyContentCheckRafId === null) return
cancelAnimationFrame(legacyContentCheckRafId)
legacyContentCheckRafId = null
})
const openCustomNodeManager = async () => {
try {
await managerState.openManager({

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed, provide, ref, toValue } from 'vue'
import { computed, ref, toValue } from 'vue'
import type { MaybeRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
@@ -30,7 +30,6 @@ import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -46,19 +45,8 @@ const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
workflowStore.activeWorkflow?.changeTracker?.reset()
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return node ? { nodeId, widgetName, node, widget } : null
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
@@ -201,7 +189,9 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</script>
<template>
<div class="flex h-full flex-col">
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
<div
class="flex h-12 items-center border-b border-border-subtle px-4 font-bold"
>
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
@@ -209,34 +199,10 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
class="overflow-x-clip"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
<div
v-else
class="pointer-events-none p-1 text-sm text-muted-foreground"
>
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
</div>
</div>
<AppModeWidgetList builder-mode />
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, provide, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
}
action: { widget: IBaseWidget; node: LGraphNode }
}
const { mobile = false, builderMode = false } = defineProps<{
mobile?: boolean
builderMode?: boolean
}>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
provide(HideLayoutFieldKey, true)
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(
app.rootGraph.events,
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
)
const mappedSelections = computed((): WidgetEntry[] => {
void graphNodes.value
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
}
const fullNodeData = nodeDataByNode.get(node)!
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return (
isPromotedWidgetView(widget) &&
widget.sourceNodeId == storeNodeId &&
widget.sourceWidgetName === vueWidget.storeName
)
})
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = String(node.id)
return [
{
key: `${nodeId}:${widgetName}`,
nodeData: {
...fullNodeData,
widgets: [matchingWidget]
},
action: { widget, node }
}
]
})
})
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const filename = node.widgets?.[0]?.value
const resultItem = { type: 'input', filename: `${filename}` }
const buildImageUrl = () => {
if (!filename) return undefined
const params = new URLSearchParams(resultItem)
appendCloudResParam(params, resultItem.filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
}
return {
iconClass: 'icon-[lucide--image]',
imageUrl: buildImageUrl(),
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined)
}
}
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
return {
...nodeData,
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
dropIndicator,
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver
}
}
</script>
<template>
<div
v-for="{ key, nodeData, action } in mappedSelections"
:key
:class="
cn(
builderMode &&
'draggable-item drag-handle pointer-events-auto relative cursor-grab [&.is-draggable]:cursor-grabbing'
)
"
:aria-label="
builderMode
? `${action.widget.label ?? action.widget.name} ${action.node.title}`
: undefined
"
>
<div
:class="
cn(
'mt-1.5 flex min-h-8 items-center gap-1 px-3',
builderMode && 'drag-handle'
)
"
>
<span
:class="cn('truncate text-sm/8', builderMode && 'pointer-events-none')"
>
{{ action.widget.label || action.widget.name }}
</span>
<span
v-if="builderMode"
class="pointer-events-none mx-1 min-w-10 flex-1 truncate text-right text-xs text-muted-foreground"
>
{{ action.node.title }}
</span>
<div v-else class="flex-1" />
<Popover
:class="cn('shrink-0', builderMode && 'pointer-events-auto')"
:entries="[
{
label: t('g.rename'),
icon: 'icon-[lucide--pencil]',
command: () => promptRenameWidget(action.widget, action.node, t)
},
{
label: t('g.remove'),
icon: 'icon-[lucide--x]',
command: () =>
appModeStore.removeSelectedInput(action.widget, action.node)
}
]"
>
<template #button>
<Button variant="textonly" size="icon">
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>
</Popover>
</div>
<div
:class="builderMode && 'pointer-events-none'"
:inert="builderMode || undefined"
>
<DropZone
:on-drag-over="nodeData.onDragOver"
:on-drag-drop="nodeData.onDragDrop"
:drop-indicator="nodeData.dropIndicator"
class="text-muted-foreground"
>
<NodeWidgets
:node-data
:class="
cn(
'gap-y-3 rounded-lg py-1 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
)
"
/>
</DropZone>
</div>
</div>
</template>

View File

@@ -59,6 +59,8 @@ import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
const props = defineProps<{
stats: SystemStats
}>()
@@ -77,6 +79,7 @@ type SystemInfoKey = keyof SystemStats['system']
type ColumnDef = {
field: SystemInfoKey
header: string
getValue?: () => string
format?: (value: string) => string
formatNumber?: (value: number) => string
}
@@ -104,6 +107,7 @@ const cloudColumns: ColumnDef[] = [
{
field: 'comfyui_frontend_version',
header: 'Frontend Version',
getValue: () => frontendCommit,
format: formatCommitHash
},
{ field: 'workflow_templates_version', header: 'Templates Version' }
@@ -119,7 +123,9 @@ function isOutdated(column: ColumnDef): boolean {
}
function getDisplayValue(column: ColumnDef) {
const value = systemInfo.value[column.field]
const value = column.getValue
? column.getValue()
: systemInfo.value[column.field]
if (column.formatNumber && typeof value === 'number') {
return column.formatNumber(value)
}

View File

@@ -242,6 +242,106 @@ describe('VirtualGrid', () => {
wrapper.unmount()
})
it('renders a single item correctly', async () => {
const items = createItems(1)
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 100,
defaultItemWidth: 100,
maxColumns: 4,
bufferRows: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems).toHaveLength(1)
expect(renderedItems[0].text()).toBe('Item 0')
wrapper.unmount()
})
it('renders all items when they exactly fill the viewport', async () => {
// 2 rows × 4 cols = 8 items, viewport = 200px, itemHeight = 100px → 2 rows
const items = createItems(8)
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 100,
defaultItemWidth: 100,
maxColumns: 4,
bufferRows: 0
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
expect(renderedItems).toHaveLength(8)
wrapper.unmount()
})
it('renders only visible items when items overflow the viewport', async () => {
// 4 cols, itemHeight=100, viewport=200 → 2 visible rows = 8 visible items
// With bufferRows=0, only those 8 should render out of 100 total
const items = createItems(100)
mockedWidth.value = 400
mockedHeight.value = 200
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: defaultGridStyle,
defaultItemHeight: 100,
defaultItemWidth: 100,
maxColumns: 4,
bufferRows: 0
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
const renderedItems = wrapper.findAll('.test-item')
// viewRows = ceil(200/100) = 2, cols = 4 → 8 items
expect(renderedItems).toHaveLength(8)
expect(renderedItems[0].text()).toBe('Item 0')
expect(renderedItems[7].text()).toBe('Item 7')
wrapper.unmount()
})
it('forces cols to maxColumns when maxColumns is finite', async () => {
mockedWidth.value = 100
mockedHeight.value = 200

View File

@@ -30,31 +30,33 @@
</div>
<div class="flex shrink-0 items-center gap-2">
<Skeleton v-if="showSkeleton(model)" class="ml-1.5 h-4 w-12" />
<span
v-else-if="model.isDownloadable && fileSizes.get(model.url)"
class="pl-1.5 text-xs text-muted-foreground"
>
{{ formatSize(fileSizes.get(model.url)) }}
</span>
<a
v-else-if="gatedModelUrls.has(model.url)"
:href="gatedModelUrls.get(model.url)"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline"
>
{{ $t('missingModelsDialog.acceptTerms') }}
</a>
<Button
v-else-if="model.isDownloadable"
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.download')"
@click="downloadModel(model, paths)"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<template v-else-if="model.isDownloadable">
<span
v-if="fileSizes.get(model.url)"
class="pl-1.5 text-xs text-muted-foreground"
>
{{ formatSize(fileSizes.get(model.url)) }}
</span>
<a
v-if="gatedModelUrls.has(model.url)"
:href="gatedModelUrls.get(model.url)"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline"
>
{{ $t('missingModelsDialog.acceptTerms') }}
</a>
<Button
v-else
variant="textonly"
size="icon"
:title="model.url"
:aria-label="$t('g.download')"
@click="downloadModel(model, paths)"
>
<i class="icon-[lucide--download] size-4" />
</Button>
</template>
<Button
v-else
variant="textonly"

View File

@@ -5,101 +5,257 @@
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
/>
<DataTable
v-model:selection="selectedCommandData"
:value="commandsData"
:global-filter-fields="['id', 'label']"
:filters="filters"
selection-mode="single"
striped-rows
:pt="{
header: 'px-0'
}"
@row-dblclick="editKeybinding($event.data)"
>
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
<template #body="slotProps">
<div class="actions flex flex-row">
<Button
variant="textonly"
size="icon"
:aria-label="$t('g.edit')"
@click="editKeybinding(slotProps.data)"
>
<i class="pi pi-pencil" />
</Button>
<Button
variant="textonly"
size="icon"
:aria-label="$t('g.reset')"
:disabled="
!keybindingStore.isCommandKeybindingModified(slotProps.data.id)
"
@click="resetKeybinding(slotProps.data)"
>
<i class="pi pi-replay" />
</Button>
<Button
variant="textonly"
size="icon"
:aria-label="$t('g.delete')"
:disabled="!slotProps.data.keybinding"
@click="removeKeybinding(slotProps.data)"
>
<i class="pi pi-trash" />
</Button>
</div>
</template>
</Column>
<Column
field="id"
:header="$t('g.command')"
sortable
class="max-w-64 2xl:max-w-full"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div
class="flex items-center gap-1.5 truncate"
:title="slotProps.data.id"
<ContextMenuRoot>
<ContextMenuTrigger as-child>
<div @contextmenu.capture="clearContextMenuTarget">
<DataTable
v-model:selection="selectedCommandData"
v-model:expanded-rows="expandedRows"
:value="commandsData"
data-key="id"
:global-filter-fields="['id', 'label']"
:filters="filters"
selection-mode="single"
context-menu
striped-rows
:pt="{
header: 'px-0'
}"
@row-click="handleRowClick($event)"
@row-dblclick="handleRowDblClick($event.data)"
@row-contextmenu="handleRowContextMenu($event)"
>
<i
v-if="slotProps.data.keybinding?.combo.isBrowserReserved"
v-tooltip="$t('g.browserReservedKeybindingTooltip')"
class="icon-[lucide--triangle-alert] shrink-0 text-warning-background"
/>
{{ slotProps.data.label }}
</div>
</template>
</Column>
<Column
field="keybinding"
:header="$t('g.keybinding')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<KeyComboDisplay
v-if="slotProps.data.keybinding"
:key-combo="slotProps.data.keybinding.combo"
:is-modified="
keybindingStore.isCommandKeybindingModified(slotProps.data.id)
<Column
field="id"
:header="$t('g.command')"
sortable
class="max-w-64 2xl:max-w-full"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div
class="flex items-center gap-1 truncate"
:class="slotProps.data.keybindings.length < 2 && 'pl-5'"
:title="slotProps.data.id"
>
<i
v-if="slotProps.data.keybindings.length >= 2"
class="icon-[lucide--chevron-right] size-4 shrink-0 text-muted-foreground transition-transform"
:class="
expandedCommandIds.has(slotProps.data.id) && 'rotate-90'
"
/>
<i
v-if="
slotProps.data.keybindings.some(
(b: KeybindingImpl) => b.combo.isBrowserReserved
)
"
v-tooltip="$t('g.browserReservedKeybindingTooltip')"
class="icon-[lucide--triangle-alert] shrink-0 text-warning-background"
/>
{{ slotProps.data.label }}
</div>
</template>
</Column>
<Column
field="keybindings"
:header="$t('g.keybinding')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<div
v-if="slotProps.data.keybindings.length > 0"
class="flex items-center gap-1"
>
<template
v-for="(binding, idx) in (
slotProps.data as ICommandData
).keybindings.slice(0, 2)"
:key="binding.combo.serialize()"
>
<span v-if="idx > 0" class="text-muted-foreground">,</span>
<KeyComboDisplay
:key-combo="binding.combo"
:is-modified="
keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
/>
</template>
<span
v-if="slotProps.data.keybindings.length > 2"
class="rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground"
>
{{
$t('g.nMoreKeybindings', {
count: slotProps.data.keybindings.length - 2
})
}}
</span>
</div>
<span v-else>-</span>
</template>
</Column>
<Column
field="source"
:header="$t('g.source')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<span class="overflow-hidden text-ellipsis">{{
slotProps.data.source || '-'
}}</span>
</template>
</Column>
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
<template #body="slotProps">
<div class="actions flex flex-row justify-end">
<Button
v-if="slotProps.data.keybindings.length === 1"
v-tooltip="$t('g.edit')"
variant="textonly"
size="icon"
:aria-label="$t('g.edit')"
@click="
editKeybinding(
slotProps.data,
slotProps.data.keybindings[0]
)
"
>
<i class="icon-[lucide--pencil]" />
</Button>
<Button
v-tooltip="$t('g.addNewKeybinding')"
variant="textonly"
size="icon"
:aria-label="$t('g.addNewKeybinding')"
@click="addKeybinding(slotProps.data)"
>
<i class="icon-[lucide--plus]" />
</Button>
<Button
v-tooltip="$t('g.reset')"
variant="textonly"
size="icon"
:aria-label="$t('g.reset')"
:disabled="
!keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
@click="resetKeybinding(slotProps.data)"
>
<i class="icon-[lucide--rotate-ccw]" />
</Button>
<Button
v-tooltip="$t('g.delete')"
variant="textonly"
size="icon"
:aria-label="$t('g.delete')"
:disabled="slotProps.data.keybindings.length === 0"
@click="handleRemoveKeybindingFromMenu(slotProps.data)"
>
<i class="icon-[lucide--trash-2]" />
</Button>
</div>
</template>
</Column>
<template #expansion="slotProps">
<div class="pl-4">
<div
v-for="(binding, idx) in (slotProps.data as ICommandData)
.keybindings"
:key="binding.combo.serialize()"
class="flex items-center justify-between border-b border-border-subtle py-1.5 last:border-b-0"
>
<div class="flex items-center gap-4">
<span class="text-muted-foreground">{{
slotProps.data.label
}}</span>
<KeyComboDisplay
:key-combo="binding.combo"
:is-modified="
keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
/>
</div>
<div class="flex flex-row">
<Button
v-tooltip="$t('g.edit')"
variant="textonly"
size="icon"
:aria-label="$t('g.edit')"
@click="editKeybinding(slotProps.data, binding)"
>
<i class="icon-[lucide--pencil]" />
</Button>
<Button
v-tooltip="$t('g.removeKeybinding')"
variant="textonly"
size="icon"
:aria-label="$t('g.removeKeybinding')"
@click="removeSingleKeybinding(slotProps.data, idx)"
>
<i class="icon-[lucide--trash-2]" />
</Button>
</div>
</div>
</div>
</template>
</DataTable>
</div>
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
:disabled="
!contextMenuTarget || contextMenuTarget.keybindings.length === 0
"
/>
<span v-else>-</span>
</template>
</Column>
<Column
field="source"
:header="$t('g.source')"
:pt="{ bodyCell: 'p-1 min-h-8' }"
>
<template #body="slotProps">
<span class="overflow-hidden text-ellipsis">{{
slotProps.data.source || '-'
}}</span>
</template>
</Column>
</DataTable>
@select="ctxChangeKeybinding"
>
<i class="icon-[lucide--pencil] size-4" />
{{ $t('g.changeKeybinding') }}
</ContextMenuItem>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered"
@select="ctxAddKeybinding"
>
<i class="icon-[lucide--plus] size-4" />
{{ $t('g.addNewKeybinding') }}
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border-subtle" />
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
:disabled="
!contextMenuTarget ||
!keybindingStore.isCommandKeybindingModified(contextMenuTarget.id)
"
@select="ctxResetToDefault"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
{{ $t('g.resetToDefault') }}
</ContextMenuItem>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
:disabled="
!contextMenuTarget || contextMenuTarget.keybindings.length === 0
"
@select="ctxRemoveKeybinding"
>
<i class="icon-[lucide--trash-2] size-4" />
{{ $t('g.removeKeybinding') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
<Button
v-tooltip="$t('g.resetAllKeybindingsTooltip')"
@@ -107,7 +263,7 @@
variant="destructive-textonly"
@click="resetAllKeybindings"
>
<i class="pi pi-replay" />
<i class="icon-[lucide--rotate-ccw]" />
{{ $t('g.resetAll') }}
</Button>
</div>
@@ -118,16 +274,26 @@ import { FilterMatchMode } from '@primevue/core/api'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuSeparator,
ContextMenuTrigger
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
@@ -139,11 +305,12 @@ const filters = ref({
const keybindingStore = useKeybindingStore()
const keybindingService = useKeybindingService()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
interface ICommandData {
id: string
keybinding: KeybindingImpl | null
keybindings: KeybindingImpl[]
label: string
source?: string
}
@@ -155,31 +322,166 @@ const commandsData = computed<ICommandData[]>(() => {
`commands.${normalizeI18nKey(command.id)}.label`,
command.label ?? ''
),
keybinding: keybindingStore.getKeybindingByCommandId(command.id),
keybindings: keybindingStore.getKeybindingsByCommandId(command.id),
source: command.source
}))
})
const expandedCommandIds = ref<Set<string>>(new Set())
const expandedRows = computed({
get() {
const result: Record<string, boolean> = {}
for (const id of expandedCommandIds.value) {
result[id] = true
}
return result
},
set(value: Record<string, boolean>) {
expandedCommandIds.value = new Set(Object.keys(value))
}
})
function toggleExpanded(commandId: string) {
if (expandedCommandIds.value.has(commandId)) {
expandedCommandIds.value.delete(commandId)
} else {
expandedCommandIds.value.add(commandId)
}
}
watch(filters, () => expandedCommandIds.value.clear(), { deep: true })
const selectedCommandData = ref<ICommandData | null>(null)
const editKeybindingDialog = useEditKeybindingDialog()
function editKeybinding(commandData: ICommandData) {
const contextMenuTarget = ref<ICommandData | null>(null)
function editKeybinding(commandData: ICommandData, binding: KeybindingImpl) {
editKeybindingDialog.show({
commandId: commandData.id,
commandLabel: commandData.label,
currentCombo: commandData.keybinding?.combo ?? null
currentCombo: binding.combo,
mode: 'edit',
existingBinding: binding
})
}
async function removeKeybinding(commandData: ICommandData) {
if (commandData.keybinding) {
keybindingStore.unsetKeybinding(commandData.keybinding)
function addKeybinding(commandData: ICommandData) {
editKeybindingDialog.show({
commandId: commandData.id,
commandLabel: commandData.label,
currentCombo: null,
mode: 'add'
})
}
function handleRowClick(event: { originalEvent: Event; data: ICommandData }) {
const target = event.originalEvent.target as HTMLElement
if (target.closest('.actions')) return
const commandData = event.data
if (
commandData.keybindings.length >= 2 ||
expandedCommandIds.value.has(commandData.id)
) {
toggleExpanded(commandData.id)
}
}
function handleRowDblClick(commandData: ICommandData) {
if (commandData.keybindings.length === 0) {
addKeybinding(commandData)
} else if (commandData.keybindings.length === 1) {
editKeybinding(commandData, commandData.keybindings[0])
}
}
function handleRowContextMenu(event: {
originalEvent: Event
data: ICommandData
}) {
contextMenuTarget.value = event.data
}
function clearContextMenuTarget() {
contextMenuTarget.value = null
}
async function removeSingleKeybinding(
commandData: ICommandData,
index: number
) {
const binding = commandData.keybindings[index]
if (binding) {
keybindingStore.unsetKeybinding(binding)
if (commandData.keybindings.length <= 2) {
expandedCommandIds.value.delete(commandData.id)
}
await keybindingService.persistUserKeybindings()
}
}
function handleRemoveAllKeybindings(commandData: ICommandData) {
const dialog = showConfirmDialog({
headerProps: { title: t('g.removeAllKeybindingsTitle') },
props: { promptText: t('g.removeAllKeybindingsMessage') },
footerProps: {
confirmText: t('g.removeAll'),
confirmVariant: 'destructive',
onCancel: () => dialogStore.closeDialog(dialog),
onConfirm: async () => {
keybindingStore.removeAllKeybindingsForCommand(commandData.id)
await keybindingService.persistUserKeybindings()
dialogStore.closeDialog(dialog)
}
}
})
}
function handleRemoveKeybindingFromMenu(commandData: ICommandData) {
if (commandData.keybindings.length >= 2) {
handleRemoveAllKeybindings(commandData)
} else {
removeSingleKeybinding(commandData, 0)
}
}
function ctxChangeKeybinding() {
if (!contextMenuTarget.value) return
const target = contextMenuTarget.value
if (target.keybindings.length === 1) {
editKeybinding(target, target.keybindings[0])
} else if (target.keybindings.length >= 2) {
if (!expandedCommandIds.value.has(target.id)) {
toggleExpanded(target.id)
}
}
}
function ctxAddKeybinding() {
if (contextMenuTarget.value) {
addKeybinding(contextMenuTarget.value)
}
}
function ctxResetToDefault() {
if (contextMenuTarget.value) {
resetKeybinding(contextMenuTarget.value)
}
}
function ctxRemoveKeybinding() {
if (
contextMenuTarget.value &&
contextMenuTarget.value.keybindings.length > 0
) {
handleRemoveKeybindingFromMenu(contextMenuTarget.value)
}
}
async function resetKeybinding(commandData: ICommandData) {
if (keybindingStore.resetKeybindingForCommand(commandData.id)) {
expandedCommandIds.value.delete(commandData.id)
await keybindingService.persistUserKeybindings()
} else {
console.warn(
@@ -189,14 +491,33 @@ async function resetKeybinding(commandData: ICommandData) {
}
const toast = useToast()
async function resetAllKeybindings() {
keybindingStore.resetAllKeybindings()
await keybindingService.persistUserKeybindings()
toast.add({
severity: 'info',
summary: 'Info',
detail: 'All keybindings reset',
life: 3000
function resetAllKeybindings() {
const dialog = showConfirmDialog({
headerProps: {
title: t('g.resetAllKeybindingsTitle')
},
props: {
promptText: t('g.resetAllKeybindingsMessage')
},
footerProps: {
confirmText: t('g.resetAll'),
confirmVariant: 'destructive',
onCancel: () => {
dialogStore.closeDialog(dialog)
},
onConfirm: async () => {
keybindingStore.resetAllKeybindings()
await keybindingService.persistUserKeybindings()
dialogStore.closeDialog(dialog)
toast.add({
severity: 'info',
summary: t('g.info'),
detail: t('g.allKeybindingsReset'),
life: 3000
})
}
}
})
}
</script>

View File

@@ -64,9 +64,18 @@ async function handleSave() {
dialogStore.closeDialog({ key: DIALOG_KEY })
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
if (updated) await keybindingService.persistUserKeybindings()
if (dialogState.mode === 'add') {
keybindingStore.addUserKeybinding(new KeybindingImpl({ commandId, combo }))
} else if (dialogState.existingBinding) {
keybindingStore.updateSpecificKeybinding(
dialogState.existingBinding,
new KeybindingImpl({ commandId, combo })
)
} else {
keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
}
await keybindingService.persistUserKeybindings()
}
</script>

View File

@@ -1,7 +1,10 @@
<template>
<span>
<template v-for="(sequence, index) in keySequences" :key="index">
<Tag :severity="isModified ? 'info' : 'secondary'">
<Tag
class="bg-interface-menu-keybind-surface-default text-base-foreground"
:severity="isModified ? 'info' : 'secondary'"
>
{{ sequence }}
</Tag>
<span v-if="index < keySequences.length - 1" class="px-2">+</span>

View File

@@ -143,6 +143,7 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
@@ -245,6 +246,16 @@ const { shouldRenderVueNodes } = useVueFeatureFlags()
// Vue node system
const vueNodeLifecycle = useVueNodeLifecycle()
// Error-clearing hooks run regardless of rendering mode (Vue or legacy canvas).
let cleanupErrorHooks: (() => void) | null = null
watch(
() => canvasStore.currentGraph,
(graph) => {
cleanupErrorHooks?.()
cleanupErrorHooks = graph ? installErrorClearingHooks(graph) : null
}
)
const handleVueNodeLifecycleReset = async () => {
if (shouldRenderVueNodes.value) {
vueNodeLifecycle.disposeNodeManagerAndSyncs()
@@ -363,7 +374,14 @@ watch(
}
)
// Update the progress of executing nodes
/**
* Propagates execution progress from the store to LiteGraph node objects
* and triggers a canvas redraw.
*
* No `deep: true` needed — `nodeLocationProgressStates` is a computed that
* returns a new `Record` object on every progress event (the underlying
* `nodeProgressStates` ref is replaced wholesale by the WebSocket handler).
*/
watch(
() =>
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
@@ -381,43 +399,15 @@ watch(
// Force canvas redraw to ensure progress updates are visible
canvas.setDirty(true, false)
},
{ deep: true }
}
)
// Update node slot errors for LiteGraph nodes
// (Vue nodes read from store directly)
// Repaint canvas when node errors change.
// Slot error flags are reconciled by reconcileNodeErrorFlags in executionErrorStore.
watch(
() => executionErrorStore.lastNodeErrors,
(lastNodeErrors) => {
if (!comfyApp.graph) return
forEachNode(comfyApp.rootGraph, (node) => {
// Clear existing errors
for (const slot of node.inputs) {
delete slot.hasErrors
}
for (const slot of node.outputs) {
delete slot.hasErrors
}
const nodeErrors = lastNodeErrors?.[node.id]
if (!nodeErrors) return
const validErrors = nodeErrors.errors.filter(
(error) => error.extra_info?.input_name !== undefined
)
validErrors.forEach((error) => {
const inputName = error.extra_info!.input_name!
const inputIndex = node.findInputSlot(inputName)
if (inputIndex !== -1) {
node.inputs[inputIndex].hasErrors = true
}
})
})
comfyApp.canvas.setDirty(true, true)
() => {
comfyApp.canvas?.setDirty(true, true)
}
)
@@ -520,6 +510,11 @@ onMounted(async () => {
comfyAppReady.value = true
// Install error-clearing hooks on the initial graph
if (comfyApp.canvas?.graph) {
cleanupErrorHooks = installErrorClearingHooks(comfyApp.canvas.graph)
}
vueNodeLifecycle.setupEmptyGraphListener()
} finally {
workspaceStore.spinner = false
@@ -563,6 +558,8 @@ onMounted(async () => {
})
onUnmounted(() => {
cleanupErrorHooks?.()
cleanupErrorHooks = null
vueNodeLifecycle.cleanup()
})
function forwardPanEvent(e: PointerEvent) {

View File

@@ -1,20 +1,5 @@
<template>
<div class="flex h-full flex-col">
<!-- Assets Header -->
<div v-if="assets.length" class="px-2 2xl:px-4">
<div
class="flex items-center py-2 font-inter text-sm/normal font-normal text-muted-foreground"
>
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
</div>
</div>
<!-- Assets Grid -->
<VirtualGrid
class="flex-1"
@@ -40,22 +25,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const {
assets,
isSelected,
assetType = 'output',
showOutputCount,
getOutputCount
} = defineProps<{
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
}>()
@@ -68,8 +45,6 @@ const emit = defineEmits<{
(e: 'output-count-click', asset: AssetItem): void
}>()
const { t } = useI18n()
type AssetGridItem = { key: string; asset: AssetItem }
const assetItems = computed<AssetGridItem[]>(() =>

View File

@@ -50,8 +50,7 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {},
assetType: 'output'
toggleStack: async () => {}
},
global: {
stubs: {
@@ -61,18 +60,6 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
})
describe('AssetsSidebarListView', () => {
it('shows generated assets header when there are assets', () => {
const wrapper = mountListView([buildOutputItem(buildAsset('a1', 'x.png'))])
expect(wrapper.text()).toContain('sideToolbar.generatedAssetsHeader')
})
it('does not show assets header when there are no assets', () => {
const wrapper = mountListView([])
expect(wrapper.text()).not.toContain('sideToolbar.generatedAssetsHeader')
})
it('marks mp4 assets as video previews', () => {
const videoAsset = {
...buildAsset('video-asset', 'clip.mp4'),

View File

@@ -1,19 +1,5 @@
<template>
<div class="flex h-full flex-col">
<div v-if="assetItems.length" class="px-2">
<div
class="flex items-center p-2 font-inter text-sm/normal font-normal text-muted-foreground"
>
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
</div>
</div>
<VirtualGrid
class="flex-1"
:items="assetItems"
@@ -106,15 +92,13 @@ const {
selectableAssets,
isSelected,
isStackExpanded,
toggleStack,
assetType = 'output'
toggleStack
} = defineProps<{
assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
isSelected: (assetId: string) => boolean
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => Promise<void>
assetType?: 'input' | 'output'
}>()
const assetsStore = useAssetsStore()

View File

@@ -24,17 +24,6 @@
</div>
</div>
</template>
<template #tool-buttons>
<!-- Normal Tab View -->
<TabList v-if="!isInFolderView" v-model="activeTab">
<Tab class="font-inter" value="output">{{
$t('sideToolbar.labels.generated')
}}</Tab>
<Tab class="font-inter" value="input">{{
$t('sideToolbar.labels.imported')
}}</Tab>
</TabList>
</template>
<template #header>
<!-- Job Detail View Header -->
<div v-if="isInFolderView" class="px-2 2xl:px-4">
@@ -50,15 +39,24 @@
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
v-model:media-type-filters="mediaTypeFilters"
class="px-2 pb-1 2xl:px-4"
bottom-divider
:show-generation-time-sort="activeTab === 'output'"
/>
<Divider type="dashed" class="my-2" />
<!-- Tab list -->
<div
v-if="!isInFolderView"
class="border-b border-comfy-input p-2 2xl:px-4"
>
<TabList v-model="activeTab">
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
</TabList>
</div>
</template>
<template #body>
<div
v-if="showLoadingState"
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 px-2"
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 p-2"
>
<div
v-for="n in skeletonCount"
@@ -85,7 +83,11 @@
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<div
v-else
class="relative size-full py-2"
@click="handleEmptySpaceClick"
>
<AssetsSidebarListView
v-if="isListView"
:asset-items="listViewAssetItems"
@@ -93,7 +95,6 @@
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@@ -103,7 +104,6 @@
v-else
:assets="displayAssets"
:is-selected="isSelected"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@@ -203,7 +203,6 @@ import {
useStorage,
useTimeoutFn
} from '@vueuse/core'
import Divider from 'primevue/divider'
import { useToast } from 'primevue/usetoast'
import {
computed,

View File

@@ -20,15 +20,14 @@
</Button>
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SidebarTopArea>
<SearchInput
ref="searchBoxRef"
v-model:model-value="searchQuery"
class="workflows-search-box"
:placeholder="$t('g.searchPlaceholder', { subject: searchSubject })"
@search="handleSearch"
/>
</div>
</SidebarTopArea>
</template>
<template #body>
<div v-if="!isSearching" class="comfyui-workflows-panel">
@@ -147,6 +146,7 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import TextDivider from '@/components/common/TextDivider.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'

View File

@@ -21,7 +21,7 @@
</Button>
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SidebarTopArea>
<SearchInput
ref="searchBoxRef"
v-model:model-value="searchQuery"
@@ -32,7 +32,7 @@
"
@search="handleSearch"
/>
</div>
</SidebarTopArea>
</template>
<template #body>
<ElectronDownloadItems v-if="isDesktop" />
@@ -57,6 +57,7 @@ import { Divider } from 'primevue'
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'

View File

@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -96,12 +95,6 @@ describe('NodeLibrarySidebarTabV2', () => {
return mount(NodeLibrarySidebarTabV2, {
global: {
plugins: [createTestingPinia({ stubActions: false }), i18n],
components: {
TabsRoot,
TabsList,
TabsTrigger,
TabsContent
},
stubs: {
teleport: true
}
@@ -112,7 +105,7 @@ describe('NodeLibrarySidebarTabV2', () => {
it('should render with tabs', () => {
const wrapper = mountComponent()
const triggers = wrapper.findAllComponents(TabsTrigger)
const triggers = wrapper.findAll('[role="tab"]')
expect(triggers).toHaveLength(3)
})

View File

@@ -1,22 +1,23 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.nodes')">
<template #header>
<TabsRoot v-model="selectedTab" class="flex flex-col">
<div class="flex items-center justify-between gap-2 px-2 pb-2 2xl:px-4">
<SearchInput
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@search="handleSearch"
/>
<SidebarTopArea bottom-divider>
<SearchInput
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@search="handleSearch"
/>
<template #actions>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
<Button
variant="secondary"
size="icon"
:aria-label="$t('g.sort')"
class="hover:bg-comfy-input-hover flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-comfy-input"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
</button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
@@ -42,12 +43,13 @@
</DropdownMenuRoot>
<DropdownMenuRoot v-if="selectedTab === 'all'">
<DropdownMenuTrigger as-child>
<button
<Button
variant="secondary"
size="icon"
:aria-label="$t('sideToolbar.nodeLibraryTab.filter')"
class="hover:bg-comfy-input-hover flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-comfy-input"
>
<i class="icon-[lucide--list-filter] size-4" />
</button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
@@ -102,65 +104,55 @@
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<Separator decorative class="border border-dashed border-comfy-input" />
<!-- Tab list in header (fixed) -->
<TabsList
class="bg-background flex gap-4 border-b border-comfy-input p-4"
>
<TabsTrigger
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
:class="
cn(
'cursor-pointer rounded-lg border-none px-3 py-2 outline-none select-none',
'text-foreground text-sm transition-colors',
selectedTab === tab.value
? 'bg-comfy-input font-bold'
: 'bg-transparent font-normal'
)
"
>
</template>
</SidebarTopArea>
<div class="border-b border-comfy-input p-2 2xl:px-4">
<TabList v-model="selectedTab">
<Tab v-for="tab in tabs" :key="tab.value" :value="tab.value">
{{ tab.label }}
</TabsTrigger>
</TabsList>
</TabsRoot>
</Tab>
</TabList>
</div>
</template>
<template #body>
<NodeDragPreview />
<!-- Tab content (scrollable) -->
<TabsRoot v-model="selectedTab" class="h-full">
<EssentialNodesPanel
v-if="
flags.nodeLibraryEssentialsEnabled && selectedTab === 'essentials'
"
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
<AllNodesPanel
v-if="selectedTab === 'all'"
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
<BlueprintsPanel
v-if="selectedTab === 'blueprints'"
v-model:expanded-keys="expandedKeys"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabsRoot>
<div class="flex h-full flex-col">
<div class="min-h-0 flex-1 overflow-y-auto py-2">
<TabPanel
v-if="flags.nodeLibraryEssentialsEnabled"
:model-value="selectedTab"
value="essentials"
>
<EssentialNodesPanel
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
</TabPanel>
<TabPanel :model-value="selectedTab" value="all">
<AllNodesPanel
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
</TabPanel>
<TabPanel :model-value="selectedTab" value="blueprints">
<BlueprintsPanel
v-model:expanded-keys="expandedKeys"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabPanel>
</div>
</div>
</template>
</SidebarTabTemplate>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import { useLocalStorage } from '@vueuse/core'
import {
DropdownMenuCheckboxItem,
@@ -170,17 +162,18 @@ import {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRoot,
DropdownMenuTrigger,
Separator,
TabsList,
TabsRoot,
TabsTrigger
DropdownMenuTrigger
} from 'reka-ui'
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import TabPanel from '@/components/tab/TabPanel.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'

View File

@@ -7,9 +7,9 @@
)
"
>
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<div class="comfy-vue-side-bar-header flex flex-col">
<Toolbar
class="min-h-16 rounded-none border-x-0 border-t-0 bg-transparent px-2 2xl:px-4"
class="min-h-16 rounded-none border-x-0 border-t-0 bg-transparent px-3 2xl:px-4"
:pt="sidebarPt"
>
<template #start>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex items-center gap-2 p-2 2xl:px-4">
<div class="min-w-0 flex-1">
<slot />
</div>
<div v-if="$slots.actions" class="flex shrink-0 items-center gap-2">
<slot name="actions" />
</div>
</div>
<div v-if="bottomDivider" class="border-t border-dashed border-comfy-input" />
</template>
<script setup lang="ts">
defineProps<{
bottomDivider?: boolean
}>()
</script>

View File

@@ -1,5 +1,5 @@
<template>
<TabsContent value="all" class="h-full flex-1 overflow-y-auto">
<div class="h-full flex-1 overflow-y-auto">
<!-- Favorites section -->
<h3
class="mb-0 px-4 py-2 text-xs font-medium tracking-wide text-muted-foreground uppercase"
@@ -34,11 +34,10 @@
@add-to-favorites="handleAddToFavorites"
/>
</div>
</TabsContent>
</div>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import { computed } from 'vue'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'

View File

@@ -1,5 +1,5 @@
<template>
<TabsContent value="blueprints" class="h-full flex-1 overflow-y-auto">
<div class="h-full flex-1 overflow-y-auto">
<div v-for="(section, index) in sections" :key="section.title ?? index">
<h3
v-if="section.title"
@@ -14,12 +14,10 @@
@node-click="(node) => emit('nodeClick', node)"
/>
</div>
</TabsContent>
</div>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type {

View File

@@ -1,5 +1,5 @@
<template>
<TabsContent value="custom" class="flex h-full flex-1 flex-col">
<div class="flex h-full flex-1 flex-col">
<div
v-for="(section, index) in sections"
:key="section.title ?? index"
@@ -30,12 +30,10 @@
{{ $t('g.manageExtensions') }}
</Button>
</div>
</TabsContent>
</div>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'

View File

@@ -1,9 +1,5 @@
<template>
<TabsContent
ref="panelEl"
value="essentials"
class="h-full flex-1 overflow-y-auto px-3"
>
<div ref="panelEl" class="h-full flex-1 overflow-y-auto px-3">
<div class="flex flex-col gap-2 pb-6">
<!-- Flat sorted grid when alphabetical -->
<div
@@ -57,29 +53,26 @@
</CollapsibleRoot>
</template>
</div>
</TabsContent>
</div>
</template>
<script setup lang="ts">
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger,
TabsContent
CollapsibleTrigger
} from 'reka-ui'
import type { ComponentPublicInstance } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
const panelEl = ref<ComponentPublicInstance | null>(null)
const panelRef = computed(() => panelEl.value?.$el as HTMLElement | null)
provide('essentialsPanelRef', panelRef)
import EssentialNodeCard from './EssentialNodeCard.vue'
const panelEl = ref<HTMLDivElement | null>(null)
provide('essentialsPanelRef', panelEl)
const { root, flatNodes = [] } = defineProps<{
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
flatNodes?: RenderedTreeExplorerNode<ComfyNodeDefImpl>[]

View File

@@ -1,48 +1,74 @@
<template>
<button
:id="tabId"
:class="tabClasses"
:id="`tab-${props.value}`"
role="tab"
type="button"
:aria-selected="isActive"
:aria-controls="panelId"
:tabindex="0"
:aria-controls="`tabpanel-${props.value}`"
:data-state="isActive ? 'active' : 'inactive'"
:tabindex="isActive ? 0 : -1"
:class="
cn(
'flex shrink-0 items-center justify-center',
'cursor-pointer rounded-lg border-none px-2.5 py-2 text-sm transition-all duration-200',
'focus-visible:ring-ring/20 outline-hidden focus-visible:ring-1',
isActive
? 'bg-interface-menu-component-surface-hovered text-text-primary'
: 'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface',
props.class
)
"
@click="handleClick"
@keydown="handleKeydown"
>
<slot />
</button>
</template>
<script setup lang="ts" generic="T extends string = string">
import type { Ref } from 'vue'
import type { HTMLAttributes } from 'vue'
import { computed, inject } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { value, panelId } = defineProps<{
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const props = defineProps<{
value: T
panelId?: string
class?: HTMLAttributes['class']
}>()
const currentValue = inject<Ref<T>>('tabs-value')
const updateValue = inject<(value: T) => void>('tabs-update')
const context = inject(TAB_LIST_INJECTION_KEY)
const tabId = computed(() => `tab-${value}`)
const isActive = computed(() => currentValue?.value === value)
const isActive = computed(() => context?.modelValue.value === props.value)
const tabClasses = computed(() => {
return cn(
// Base styles from TextButton
'flex shrink-0 items-center justify-center',
'cursor-pointer rounded-lg px-2.5 py-2 text-sm transition-all duration-200',
'border-none outline-hidden',
// State styles with semantic tokens
isActive.value
? 'text-bold bg-interface-menu-component-surface-hovered text-text-primary'
: 'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
)
})
function handleClick() {
context?.select(props.value)
}
const handleClick = () => {
updateValue?.(value)
function handleKeydown(event: KeyboardEvent) {
const tablist = (event.currentTarget as HTMLElement).parentElement
if (!tablist) return
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]'))
const currentIndex = tabs.indexOf(event.currentTarget as HTMLElement)
let targetIndex = -1
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
targetIndex = (currentIndex + 1) % tabs.length
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
targetIndex = (currentIndex - 1 + tabs.length) % tabs.length
} else if (event.key === 'Home') {
targetIndex = 0
} else if (event.key === 'End') {
targetIndex = tabs.length - 1
}
if (targetIndex !== -1) {
event.preventDefault()
tabs[targetIndex].focus()
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div role="tablist" class="flex w-full items-center gap-2 pb-1">
<div role="tablist" class="flex w-full items-center gap-2">
<slot />
</div>
</template>
@@ -7,11 +7,16 @@
<script setup lang="ts" generic="T extends string = string">
import { provide } from 'vue'
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const modelValue = defineModel<T>({ required: true })
// Provide for child Tab components
provide('tabs-value', modelValue)
provide('tabs-update', (value: T) => {
modelValue.value = value
function select(value: string) {
modelValue.value = value as T
}
provide(TAB_LIST_INJECTION_KEY, {
modelValue,
select
})
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div
v-if="isActive"
:id="`tabpanel-${value}`"
role="tabpanel"
tabindex="0"
:aria-labelledby="`tab-${value}`"
>
<slot />
</div>
</template>
<script setup lang="ts" generic="T extends string = string">
import { computed, inject } from 'vue'
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const { value, modelValue } = defineProps<{
value: T
modelValue?: T
}>()
const context = inject(TAB_LIST_INJECTION_KEY, undefined)
const isActive = computed(() =>
modelValue !== undefined
? modelValue === value
: context?.modelValue.value === value
)
</script>

View File

@@ -0,0 +1,9 @@
import type { InjectionKey, Ref } from 'vue'
interface TabListContext {
modelValue: Ref<string>
select: (value: string) => void
}
export const TAB_LIST_INJECTION_KEY: InjectionKey<TabListContext> =
Symbol('TabListContext')

View File

@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ChartBar from './ChartBar.vue'
const meta: Meta<typeof ChartBar> = {
title: 'Components/Chart/ChartBar',
component: ChartBar,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
ariaLabel: 'Bar chart example',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'BarName1',
data: [10, 50, 35, 75],
backgroundColor: '#ff8000'
}
]
}
}
}
export const MultipleDatasets: Story = {
args: {
ariaLabel: 'Bar chart with multiple datasets',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'Series 1',
data: [30, 60, 45, 80],
backgroundColor: '#ff8000'
},
{
label: 'Series 2',
data: [50, 40, 70, 20],
backgroundColor: '#4ade80'
}
]
}
}
}

View File

@@ -0,0 +1,34 @@
<template>
<div
:class="
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
"
>
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
</div>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed, ref, toRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useChart } from './useChart'
const props = defineProps<{
data: ChartData<'bar'>
options?: ChartOptions<'bar'>
ariaLabel?: string
class?: string
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
useChart(
canvasRef,
ref('bar'),
toRef(() => props.data),
computed(() => props.options as ChartOptions | undefined)
)
</script>

View File

@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ChartLine from './ChartLine.vue'
const meta: Meta<typeof ChartLine> = {
title: 'Components/Chart/ChartLine',
component: ChartLine,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-[413px] bg-neutral-900 p-4 rounded-lg"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
ariaLabel: 'Line chart example',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'LineName1',
data: [10, 45, 25, 80],
borderColor: '#4ade80',
borderDash: [5, 5],
fill: true,
backgroundColor: '#4ade8033',
tension: 0.4
}
]
}
}
}
export const MultipleLines: Story = {
args: {
ariaLabel: 'Line chart with multiple lines',
data: {
labels: ['A', 'B', 'C', 'D'],
datasets: [
{
label: 'LineName1',
data: [10, 45, 25, 80],
borderColor: '#4ade80',
borderDash: [5, 5],
fill: true,
backgroundColor: '#4ade8033',
tension: 0.4
},
{
label: 'LineName2',
data: [80, 60, 40, 10],
borderColor: '#ff8000',
fill: true,
backgroundColor: '#ff800033',
tension: 0.4
},
{
label: 'LineName3',
data: [60, 70, 35, 40],
borderColor: '#ef4444',
fill: true,
backgroundColor: '#ef444433',
tension: 0.4
}
]
}
}
}

View File

@@ -0,0 +1,34 @@
<template>
<div
:class="
cn('rounded-lg bg-component-node-widget-background p-6', props.class)
"
>
<canvas ref="canvasRef" :aria-label="ariaLabel" role="img" />
</div>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions } from 'chart.js'
import { computed, ref, toRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useChart } from './useChart'
const props = defineProps<{
data: ChartData<'line'>
options?: ChartOptions<'line'>
ariaLabel?: string
class?: string
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
useChart(
canvasRef,
ref('line'),
toRef(() => props.data),
computed(() => props.options as ChartOptions | undefined)
)
</script>

View File

@@ -0,0 +1,196 @@
import type { ChartData, ChartOptions, ChartType } from 'chart.js'
import {
BarController,
BarElement,
CategoryScale,
Chart,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip
} from 'chart.js'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
Chart.register(
BarController,
BarElement,
CategoryScale,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip
)
function getCssVar(name: string): string {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim()
}
function getDefaultOptions(type: ChartType): ChartOptions {
const foreground = getCssVar('--color-base-foreground') || '#ffffff'
const muted = getCssVar('--color-muted-foreground') || '#8a8a8a'
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
align: 'start',
labels: {
color: foreground,
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 8,
boxHeight: 8,
padding: 16,
font: { family: 'Inter', size: 11 },
generateLabels(chart) {
const datasets = chart.data.datasets
return datasets.map((dataset, i) => {
const color =
(dataset as { borderColor?: string }).borderColor ??
(dataset as { backgroundColor?: string }).backgroundColor ??
'#888'
return {
text: dataset.label ?? '',
fillStyle: color as string,
strokeStyle: color as string,
lineWidth: 0,
pointStyle: 'circle' as const,
hidden: !chart.isDatasetVisible(i),
datasetIndex: i
}
})
}
}
},
tooltip: {
enabled: true
}
},
elements: {
point: {
radius: 0,
hoverRadius: 4
}
},
scales: {
x: {
ticks: {
color: muted,
font: { family: 'Inter', size: 11 },
padding: 8
},
grid: {
display: true,
color: muted + '33',
drawTicks: false
},
border: { display: true, color: muted }
},
y: {
ticks: {
color: muted,
font: { family: 'Inter', size: 11 },
padding: 4
},
grid: {
display: false,
drawTicks: false
},
border: { display: true, color: muted }
}
},
...(type === 'bar' && {
datasets: {
bar: {
borderRadius: { topLeft: 4, topRight: 4 },
borderSkipped: false,
barPercentage: 0.6,
categoryPercentage: 0.8
}
}
})
}
}
export function useChart(
canvasRef: Ref<HTMLCanvasElement | null>,
type: Ref<ChartType>,
data: Ref<ChartData>,
options?: Ref<ChartOptions | undefined>
) {
const chartInstance = ref<Chart | null>(null)
function createChart() {
if (!canvasRef.value) return
chartInstance.value?.destroy()
const defaults = getDefaultOptions(type.value)
const merged = options?.value
? deepMerge(defaults, options.value)
: defaults
chartInstance.value = new Chart(canvasRef.value, {
type: type.value,
data: data.value,
options: merged
})
}
onMounted(createChart)
watch([type, data, options ?? ref(undefined)], () => {
if (chartInstance.value) {
chartInstance.value.data = data.value
chartInstance.value.options = options?.value
? deepMerge(getDefaultOptions(type.value), options.value)
: getDefaultOptions(type.value)
chartInstance.value.update()
}
})
onBeforeUnmount(() => {
chartInstance.value?.destroy()
chartInstance.value = null
})
return { chartInstance }
}
function deepMerge<T extends Record<string, unknown>>(
target: T,
source: Record<string, unknown>
): T {
const result = { ...target } as Record<string, unknown>
for (const key of Object.keys(source)) {
const srcVal = source[key]
const tgtVal = result[key]
if (
srcVal &&
typeof srcVal === 'object' &&
!Array.isArray(srcVal) &&
tgtVal &&
typeof tgtVal === 'object' &&
!Array.isArray(tgtVal)
) {
result[key] = deepMerge(
tgtVal as Record<string, unknown>,
srcVal as Record<string, unknown>
)
} else {
result[key] = srcVal
}
}
return result as T
}

View File

@@ -0,0 +1,68 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref } from 'vue'
import ColorPicker from './ColorPicker.vue'
const meta: Meta<ComponentPropsAndSlots<typeof ColorPicker>> = {
title: 'Components/ColorPicker',
component: ColorPicker,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
(story) => ({
components: { story },
template: '<div class="w-60"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#e06cbd')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}
export const Red: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#ff0000')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}
export const Black: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#000000')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}
export const White: Story = {
render: () => ({
components: { ColorPicker },
setup() {
const color = ref('#ffffff')
return { color }
},
template: '<ColorPicker v-model="color" />'
})
}

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import {
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import type { HSVA } from '@/utils/colorUtil'
import { hexToHsva, hsbToRgb, hsvaToHex, rgbToHex } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
import ColorPickerPanel from './ColorPickerPanel.vue'
defineProps<{
class?: string
}>()
const modelValue = defineModel<string>({ default: '#000000' })
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
const displayMode = ref<'hex' | 'rgba'>('hex')
watch(modelValue, (newVal) => {
const current = hsvaToHex(hsva.value)
if (newVal !== current) {
hsva.value = hexToHsva(newVal || '#000000')
}
})
watch(
hsva,
(newHsva) => {
const hex = hsvaToHex(newHsva)
if (hex !== modelValue.value) {
modelValue.value = hex
}
},
{ deep: true }
)
const baseRgb = computed(() =>
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
)
const previewColor = computed(() => {
const hex = rgbToHex(baseRgb.value)
const a = hsva.value.a / 100
if (a < 1) {
const alphaHex = Math.round(a * 255)
.toString(16)
.padStart(2, '0')
return `${hex}${alphaHex}`
}
return hex
})
const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase())
const isOpen = ref(false)
</script>
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<button
type="button"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-node-component-surface pr-2 outline-none hover:bg-component-node-widget-background-hovered',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-node-component-slot-text"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="bottom"
align="start"
:side-offset="7"
:collision-padding="10"
class="z-1700"
>
<ColorPickerPanel
v-model:hsva="hsva"
v-model:display-mode="displayMode"
/>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import type { HSVA } from '@/utils/colorUtil'
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
import ColorPickerSlider from './ColorPickerSlider.vue'
const hsva = defineModel<HSVA>('hsva', { required: true })
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
required: true
})
const rgb = computed(() =>
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
)
const hexString = computed(() => rgbToHex(rgb.value).toLowerCase())
const { t } = useI18n()
</script>
<template>
<div
class="flex w-[211px] flex-col gap-2 rounded-lg border border-border-subtle bg-base-background p-2 shadow-md"
>
<ColorPickerSaturationValue
v-model:saturation="hsva.s"
v-model:value="hsva.v"
:hue="hsva.h"
/>
<ColorPickerSlider v-model="hsva.h" type="hue" />
<ColorPickerSlider
v-model="hsva.a"
type="alpha"
:hue="hsva.h"
:saturation="hsva.s"
:brightness="hsva.v"
/>
<div class="flex items-center gap-1">
<Select v-model="displayMode">
<SelectTrigger
class="h-6 w-[58px] shrink-0 gap-0.5 overflow-clip rounded-sm border-0 px-1.5 py-0 text-xs [&>span]:overflow-visible"
>
<SelectValue />
</SelectTrigger>
<SelectContent class="min-w-16 p-1">
<SelectItem value="hex" class="px-2 py-1 text-xs">
{{ t('color.hex') }}
</SelectItem>
<SelectItem value="rgba" class="px-2 py-1 text-xs">
{{ t('color.rgba') }}
</SelectItem>
</SelectContent>
</Select>
<div
class="flex h-6 min-w-0 flex-1 items-center gap-1 rounded-sm bg-secondary-background px-1 text-xs text-node-component-slot-text"
>
<template v-if="displayMode === 'hex'">
<span class="min-w-0 flex-1 truncate text-center">{{
hexString
}}</span>
</template>
<template v-else>
<span class="w-6 shrink-0 text-center">{{ rgb.r }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
</template>
<span class="shrink-0 border-l border-border-subtle pl-1"
>{{ hsva.a }}%</span
>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { hue } = defineProps<{
hue: number
}>()
const saturation = defineModel<number>('saturation', { required: true })
const value = defineModel<number>('value', { required: true })
const containerRef = ref<HTMLElement | null>(null)
const hueBackground = computed(() => `hsl(${hue}, 100%, 50%)`)
const handleStyle = computed(() => ({
left: `${saturation.value}%`,
top: `${100 - value.value}%`
}))
function updateFromPointer(e: PointerEvent) {
const el = containerRef.value
if (!el) return
const rect = el.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
saturation.value = Math.round(x * 100)
value.value = Math.round((1 - y) * 100)
}
function handlePointerDown(e: PointerEvent) {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
updateFromPointer(e)
}
function handlePointerMove(e: PointerEvent) {
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
updateFromPointer(e)
}
</script>
<template>
<div
ref="containerRef"
role="slider"
:aria-label="t('color.saturationBrightness')"
:aria-valuetext="`${saturation}%, ${value}%`"
class="relative aspect-square w-full cursor-crosshair rounded-sm"
:style="{ backgroundColor: hueBackground, touchAction: 'none' }"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
>
<div
class="absolute inset-0 rounded-sm bg-linear-to-r from-white to-transparent"
/>
<div
class="absolute inset-0 rounded-sm bg-linear-to-b from-transparent to-black"
/>
<div
class="pointer-events-none absolute size-3.5 -translate-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
:style="handleStyle"
/>
</div>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
const { t } = useI18n()
const {
type,
hue = 0,
saturation = 100,
brightness = 100
} = defineProps<{
type: 'hue' | 'alpha'
hue?: number
saturation?: number
brightness?: number
}>()
const modelValue = defineModel<number>({ required: true })
const max = computed(() => (type === 'hue' ? 360 : 100))
const fraction = computed(() => modelValue.value / max.value)
const trackBackground = computed(() => {
if (type === 'hue') {
return 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)'
}
const rgb = hsbToRgb({ h: hue, s: saturation, b: brightness })
const hex = rgbToHex(rgb)
return `linear-gradient(to right, transparent, ${hex})`
})
const containerStyle = computed(() => {
if (type === 'alpha') {
return {
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '8px 8px',
touchAction: 'none'
}
}
return {
background: trackBackground.value,
touchAction: 'none'
}
})
function updateFromPointer(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
modelValue.value = Math.round(x * max.value)
}
function handlePointerDown(e: PointerEvent) {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
updateFromPointer(e)
}
function handlePointerMove(e: PointerEvent) {
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
updateFromPointer(e)
}
</script>
<template>
<div
role="slider"
:aria-label="type === 'hue' ? t('color.hue') : t('color.alpha')"
:aria-valuemin="0"
:aria-valuemax="max"
:aria-valuenow="modelValue"
class="relative flex h-4 cursor-pointer items-center rounded-full p-px"
:style="containerStyle"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
>
<div
v-if="type === 'alpha'"
class="absolute inset-0 rounded-full"
:style="{ background: trackBackground }"
/>
<div
class="pointer-events-none absolute aspect-square h-full -translate-x-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
:style="{ left: `${fraction * 100}%` }"
/>
</div>
</template>

View File

@@ -0,0 +1,352 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
function seedSimpleError(
store: ReturnType<typeof useExecutionErrorStore>,
executionId: string,
inputName: string
) {
store.lastNodeErrors = {
[executionId]: {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: inputName }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
}
describe('Connection error clearing via onConnectionsChange', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
function createGraphWithInput() {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
node.addInput('clip', 'CLIP')
graph.add(node)
return { graph, node }
}
it('clears simple node error when INPUT is connected', () => {
const { graph, node } = createGraphWithInput()
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedSimpleError(store, String(node.id), 'clip')
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
expect(store.lastNodeErrors).toBeNull()
})
it('does not clear errors on disconnection', () => {
const { graph, node } = createGraphWithInput()
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedSimpleError(store, String(node.id), 'clip')
node.onConnectionsChange!(
NodeSlotType.INPUT,
0,
false,
null,
node.inputs[0]
)
expect(store.lastNodeErrors).not.toBeNull()
})
it('does not clear errors on OUTPUT connection', () => {
const { graph, node } = createGraphWithInput()
node.addOutput('out', 'CLIP')
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedSimpleError(store, String(node.id), 'clip')
node.onConnectionsChange!(
NodeSlotType.OUTPUT,
0,
true,
null,
node.outputs[0]
)
expect(store.lastNodeErrors).not.toBeNull()
})
it('clears errors for pure input slots without widget property', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('model', 'MODEL')
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedSimpleError(store, String(node.id), 'model')
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
expect(store.lastNodeErrors).toBeNull()
})
})
describe('Widget change error clearing via onWidgetChanged', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('clears simple error when widget value changes to valid range', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {
min: 1,
max: 100
})
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
store.lastNodeErrors = {
[String(node.id)]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
expect(store.lastNodeErrors).toBeNull()
})
it('retains error when widget value is still out of range', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {
min: 1,
max: 100
})
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
store.lastNodeErrors = {
[String(node.id)]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
node.onWidgetChanged!.call(node, 'steps', 150, 20, node.widgets![0])
expect(store.lastNodeErrors).not.toBeNull()
})
it('does not clear errors when rootGraph is unavailable', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(
undefined as unknown as LGraph
)
store.lastNodeErrors = {
[String(node.id)]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
expect(store.lastNodeErrors).not.toBeNull()
})
it('uses interior node execution ID for promoted widget error clearing', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'model.safetensors',
() => undefined,
{ values: ['model.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
// with that name so the slot-name filter matches.
seedSimpleError(store, interiorExecId, promotedWidget!.name)
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'other_model.safetensors',
'model.safetensors',
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
})
describe('installErrorClearingHooks lifecycle', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('propagates hooks to nodes added after installation', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('value', 'INT')
graph.add(node)
installErrorClearingHooks(graph)
// Add a new node after hooks are installed
const lateNode = new LGraphNode('late')
lateNode.addInput('value', 'INT')
graph.add(lateNode)
// The late-added node should have error-clearing hooks
expect(lateNode.onConnectionsChange).toBeDefined()
expect(lateNode.onWidgetChanged).toBeDefined()
// Verify the hooks actually work
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedSimpleError(store, String(lateNode.id), 'value')
lateNode.onConnectionsChange!(
NodeSlotType.INPUT,
0,
true,
null,
lateNode.inputs[0]
)
expect(store.lastNodeErrors).toBeNull()
})
it('restores original onNodeAdded when cleanup is called', () => {
const graph = new LGraph()
const originalHook = vi.fn()
graph.onNodeAdded = originalHook
const cleanup = installErrorClearingHooks(graph)
expect(graph.onNodeAdded).not.toBe(originalHook)
cleanup()
expect(graph.onNodeAdded).toBe(originalHook)
})
})
describe('clearWidgetRelatedErrors parameter routing', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('passes widgetName (not errorInputName) for model lookup', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
const widget = node.addWidget('number', 'steps', 42, () => undefined, {
min: 0,
max: 100
})
graph.add(node)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const clearSpy = vi.spyOn(store, 'clearWidgetRelatedErrors')
node.onWidgetChanged!.call(node, 'steps', 42, 0, widget)
expect(clearSpy).toHaveBeenCalledWith(
String(node.id),
'steps',
'steps',
42,
{ min: 0, max: 100 }
)
clearSpy.mockRestore()
})
})

View File

@@ -0,0 +1,108 @@
/**
* Installs per-node error-clearing callbacks (onConnectionsChange,
* onWidgetChanged) on all current and future nodes in a graph.
*
* Decoupled from the Vue rendering lifecycle so that error auto-clearing
* works in legacy canvas mode as well.
*/
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
function resolvePromotedExecId(
rootGraph: LGraph,
node: LGraphNode,
widget: IBaseWidget,
hostExecId: string
): string {
if (!isPromotedWidgetView(widget)) return hostExecId
const result = resolveConcretePromotedWidget(
node,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (result.status === 'resolved' && result.resolved.node) {
return getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId
}
return hostExecId
}
const hookedNodes = new WeakSet<LGraphNode>()
function installNodeHooks(node: LGraphNode): void {
if (hookedNodes.has(node)) return
hookedNodes.add(node)
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
function (type, slotIndex, isConnected) {
if (type !== NodeSlotType.INPUT || !isConnected) return
if (!app.rootGraph) return
const slotName = node.inputs?.[slotIndex]?.name
if (!slotName) return
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return
useExecutionErrorStore().clearSimpleNodeErrors(execId, slotName)
}
)
node.onWidgetChanged = useChainCallback(
node.onWidgetChanged,
// _name is the LiteGraph callback arg; re-derive from the widget
// object to handle promoted widgets where sourceWidgetName differs.
function (_name, newValue, _oldValue, widget) {
if (!app.rootGraph) return
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
if (!hostExecId) return
const execId = resolvePromotedExecId(
app.rootGraph,
node,
widget,
hostExecId
)
const widgetName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
useExecutionErrorStore().clearWidgetRelatedErrors(
execId,
widget.name,
widgetName,
newValue,
{ min: widget.options?.min, max: widget.options?.max }
)
}
)
}
function installNodeHooksRecursive(node: LGraphNode): void {
installNodeHooks(node)
if (node.isSubgraphNode?.()) {
for (const innerNode of node.subgraph._nodes ?? []) {
installNodeHooksRecursive(innerNode)
}
}
}
export function installErrorClearingHooks(graph: LGraph): () => void {
for (const node of graph._nodes ?? []) {
installNodeHooksRecursive(node)
}
const originalOnNodeAdded = graph.onNodeAdded
graph.onNodeAdded = function (node: LGraphNode) {
installNodeHooksRecursive(node)
originalOnNodeAdded?.call(this, node)
}
return () => {
graph.onNodeAdded = originalOnNodeAdded || undefined
}
}

View File

@@ -11,6 +11,9 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -240,6 +243,78 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
})
})
describe('Subgraph output slot label reactivity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('updates output slot labels when node:slot-label:changed is triggered', async () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addOutput('original_name', 'STRING')
node.addOutput('other_name', 'STRING')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeId = String(node.id)
const nodeData = vueNodeData.get(nodeId)
if (!nodeData?.outputs) throw new Error('Expected output data to exist')
expect(nodeData.outputs[0].label).toBeUndefined()
expect(nodeData.outputs[1].label).toBeUndefined()
// Simulate what SubgraphNode does: set the label, then fire the trigger
node.outputs[0].label = 'custom_label'
graph.trigger('node:slot-label:changed', {
nodeId: node.id,
slotType: NodeSlotType.OUTPUT
})
await nextTick()
const updatedData = vueNodeData.get(nodeId)
expect(updatedData?.outputs?.[0]?.label).toBe('custom_label')
expect(updatedData?.outputs?.[1]?.label).toBeUndefined()
})
it('updates input slot labels when node:slot-label:changed is triggered', async () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('original_name', 'STRING')
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeId = String(node.id)
const nodeData = vueNodeData.get(nodeId)
if (!nodeData?.inputs) throw new Error('Expected input data to exist')
expect(nodeData.inputs[0].label).toBeUndefined()
node.inputs[0].label = 'custom_label'
graph.trigger('node:slot-label:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT
})
await nextTick()
const updatedData = vueNodeData.get(nodeId)
expect(updatedData?.inputs?.[0]?.label).toBe('custom_label')
})
it('ignores node:slot-label:changed for unknown node ids', () => {
const graph = new LGraph()
useGraphNodeManager(graph)
expect(() =>
graph.trigger('node:slot-label:changed', {
nodeId: 'missing-node',
slotType: NodeSlotType.OUTPUT
})
).not.toThrow()
})
})
describe('Subgraph Promoted Pseudo Widgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -316,3 +391,280 @@ describe('Nested promoted widget mapping', () => {
)
})
})
describe('Promoted widget sourceExecutionId', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('sets sourceExecutionId to the interior node execution ID for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'model.safetensors',
() => undefined,
{
values: ['model.safetensors']
}
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
// The interior node is inside subgraphNode (id=65),
// so its execution ID should be "65:<interiorNodeId>"
expect(promotedWidget?.sourceExecutionId).toBe(
`${subgraphNode.id}:${interiorNode.id}`
)
})
it('does not set sourceExecutionId for non-promoted widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
expect(widget).toBeDefined()
expect(widget?.sourceExecutionId).toBeUndefined()
})
})
describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function setupGraphWithStore() {
const graph = new LGraph()
const nodeA = new LGraphNode('KSampler')
nodeA.addInput('model', 'MODEL')
nodeA.addInput('steps', 'INT')
graph.add(nodeA)
const nodeB = new LGraphNode('LoadCheckpoint')
nodeB.addInput('ckpt_name', 'STRING')
graph.add(nodeB)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
// Initialize store (triggers watcher registration)
useGraphNodeManager(graph)
const store = useExecutionErrorStore()
return { graph, nodeA, nodeB, store }
}
it('sets has_errors on nodes referenced in lastNodeErrors', async () => {
const { nodeA, nodeB, store } = setupGraphWithStore()
store.lastNodeErrors = {
[String(nodeA.id)]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'KSampler'
}
}
await nextTick()
expect(nodeA.has_errors).toBe(true)
expect(nodeB.has_errors).toBeFalsy()
})
it('sets slot hasErrors for inputs matching error input_name', async () => {
const { nodeA, store } = setupGraphWithStore()
store.lastNodeErrors = {
[String(nodeA.id)]: {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'model' }
}
],
dependent_outputs: [],
class_type: 'KSampler'
}
}
await nextTick()
expect(nodeA.inputs[0].hasErrors).toBe(true)
expect(nodeA.inputs[1].hasErrors).toBe(false)
})
it('clears has_errors and slot hasErrors when errors are removed', async () => {
const { nodeA, store } = setupGraphWithStore()
store.lastNodeErrors = {
[String(nodeA.id)]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'KSampler'
}
}
await nextTick()
expect(nodeA.has_errors).toBe(true)
expect(nodeA.inputs[1].hasErrors).toBe(true)
store.lastNodeErrors = null
await nextTick()
expect(nodeA.has_errors).toBeFalsy()
expect(nodeA.inputs[1].hasErrors).toBe(false)
})
it('propagates has_errors to parent subgraph node', async () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('InnerNode')
interiorNode.addInput('value', 'INT')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 50 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
useGraphNodeManager(graph)
const store = useExecutionErrorStore()
// Error on interior node: execution ID = "50:<interiorNodeId>"
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
store.lastNodeErrors = {
[interiorExecId]: {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'value' }
}
],
dependent_outputs: [],
class_type: 'InnerNode'
}
}
await nextTick()
// Interior node should have the error
expect(interiorNode.has_errors).toBe(true)
expect(interiorNode.inputs[0].hasErrors).toBe(true)
// Parent subgraph node should also be flagged
expect(subgraphNode.has_errors).toBe(true)
})
it('sets has_errors on nodes with missing models', async () => {
const { nodeA, nodeB } = setupGraphWithStore()
const missingModelStore = useMissingModelStore()
missingModelStore.setMissingModels([
{
nodeId: String(nodeA.id),
nodeType: 'CheckpointLoader',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
}
])
await nextTick()
expect(nodeA.has_errors).toBe(true)
expect(nodeB.has_errors).toBeFalsy()
})
it('clears has_errors when missing models are removed', async () => {
const { nodeA } = setupGraphWithStore()
const missingModelStore = useMissingModelStore()
missingModelStore.setMissingModels([
{
nodeId: String(nodeA.id),
nodeType: 'CheckpointLoader',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
}
])
await nextTick()
expect(nodeA.has_errors).toBe(true)
missingModelStore.clearMissingModels()
await nextTick()
expect(nodeA.has_errors).toBeFalsy()
})
it('flags parent subgraph node when interior node has missing model', async () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('CheckpointLoader')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 50 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
useGraphNodeManager(graph)
useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
missingModelStore.setMissingModels([
{
nodeId: `${subgraphNode.id}:${interiorNode.id}`,
nodeType: 'CheckpointLoader',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
}
])
await nextTick()
expect(interiorNode.has_errors).toBe(true)
expect(subgraphNode.has_errors).toBe(true)
})
})

View File

@@ -36,6 +36,7 @@ import type {
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
export interface WidgetSlotMetadata {
index: number
@@ -80,6 +81,13 @@ export interface SafeWidgetData {
* which differs from the subgraph node's input slot widget name.
*/
slotName?: string
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the
* host subgraph node. Used for missing-model lookups that key by
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
}
export interface VueNodeData {
@@ -204,7 +212,7 @@ function safeWidgetMapper(
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.advanced,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
@@ -324,10 +332,21 @@ function safeWidgetMapper(
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
slotMetadata: slotInfo,
// For promoted widgets, name is sourceWidgetName while widget.name
// is the subgraph input slot name — store the slot name for lookups.
slotName: name !== widget.name ? widget.name : undefined,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
'[safeWidgetMapper] Failed to map widget:',
widget.name,
error
)
return {
name: widget.name || 'unknown',
type: widget.type || 'text'
@@ -377,7 +396,9 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
},
set(v) {
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
}
},
configurable: true,
enumerable: true
})
}
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
@@ -387,7 +408,20 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
},
set(v) {
reactiveInputs.splice(0, reactiveInputs.length, ...v)
}
},
configurable: true,
enumerable: true
})
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
Object.defineProperty(node, 'outputs', {
get() {
return reactiveOutputs
},
set(v) {
reactiveOutputs.splice(0, reactiveOutputs.length, ...v)
},
configurable: true,
enumerable: true
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
@@ -429,7 +463,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: reactiveInputs,
outputs: node.outputs ? [...node.outputs] : undefined,
outputs: reactiveOutputs,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
@@ -642,6 +676,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
title: String(propertyEvent.newValue)
})
break
case 'has_errors':
vueNodeData.set(nodeId, {
...currentData,
hasErrors: Boolean(propertyEvent.newValue)
})
break
case 'flags.collapsed':
vueNodeData.set(nodeId, {
...currentData,
@@ -721,6 +761,20 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(String(slotLinksEvent.nodeId))
}
},
'node:slot-label:changed': (slotLabelEvent) => {
const nodeId = String(slotLabelEvent.nodeId)
const nodeRef = nodeRefs.get(nodeId)
if (!nodeRef) return
// Force shallowReactive to detect the deep property change
// by re-assigning the affected array through the defineProperty setter.
if (slotLabelEvent.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) {
nodeRef.inputs = [...nodeRef.inputs]
}
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
}
}
@@ -735,6 +789,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
case 'node:slot-links:changed':
triggerHandlers['node:slot-links:changed'](event)
break
case 'node:slot-label:changed':
triggerHandlers['node:slot-label:changed'](event)
break
}
// Chain to original handler

View File

@@ -80,8 +80,12 @@ export function showNodeOptions(
}
/**
* Hide the node options popover
* Check if the node options menu is currently open
*/
export function isNodeOptionsOpen(): boolean {
return nodeOptionsInstance?.isOpen.value ?? false
}
interface NodeOptionsInstance {
toggle: (event: Event) => void
show: (event: MouseEvent) => void

View File

@@ -9,7 +9,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { app as comfyApp } from '@/scripts/app'
function useVueNodeLifecycleIndividual() {
@@ -17,7 +16,7 @@ function useVueNodeLifecycleIndividual() {
const layoutMutations = useLayoutMutations()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const nodeManager = shallowRef<GraphNodeManager | null>(null)
const { startSync } = useLayoutSync()
const { startSync, stopSync } = useLayoutSync()
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
@@ -55,11 +54,13 @@ function useVueNodeLifecycleIndividual() {
)
}
// Initialize layout sync (one-way: Layout Store → LiteGraph)
// Start sync AFTER seeding so bootstrap operations don't trigger
// the Layout→LiteGraph writeback loop redundantly.
startSync(canvasStore.canvas)
}
const disposeNodeManagerAndSyncs = () => {
stopSync()
if (!nodeManager.value) return
try {
@@ -76,9 +77,6 @@ function useVueNodeLifecycleIndividual() {
(enabled) => {
if (enabled) {
initializeNodeManager()
ensureCorrectLayoutScale(
comfyApp.canvas?.graph?.extra.workflowRendererVersion
)
}
},
{ immediate: true }
@@ -87,26 +85,17 @@ function useVueNodeLifecycleIndividual() {
whenever(
() => !shouldRenderVueNodes.value,
() => {
ensureCorrectLayoutScale(
comfyApp.canvas?.graph?.extra.workflowRendererVersion
)
disposeNodeManagerAndSyncs()
comfyApp.canvas?.setDirty(true, true)
}
)
// Consolidated watch for slot layout sync management
// Clear stale slot layouts when switching modes
watch(
() => shouldRenderVueNodes.value,
(vueMode, oldVueMode) => {
const modeChanged = vueMode !== oldVueMode
// Clear stale slot layouts when switching modes
if (modeChanged) {
layoutStore.clearAllSlotLayouts()
}
},
{ immediate: true, flush: 'sync' }
() => {
layoutStore.clearAllSlotLayouts()
}
)
// Handle case where Vue nodes are enabled but graph starts empty

View File

@@ -4,6 +4,7 @@ import EditKeybindingContent from '@/components/dialog/content/setting/keybindin
import EditKeybindingFooter from '@/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue'
import EditKeybindingHeader from '@/components/dialog/content/setting/keybinding/EditKeybindingHeader.vue'
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useDialogService } from '@/services/dialogService'
@@ -13,6 +14,8 @@ export interface EditKeybindingDialogState {
commandId: string
newCombo: KeyComboImpl | null
currentCombo: KeyComboImpl | null
mode: 'edit' | 'add'
existingBinding: KeybindingImpl | null
}
export function useEditKeybindingDialog() {
@@ -23,11 +26,15 @@ export function useEditKeybindingDialog() {
commandId: string
commandLabel: string
currentCombo: KeyComboImpl | null
mode?: 'edit' | 'add'
existingBinding?: KeybindingImpl | null
}) {
const dialogState = reactive<EditKeybindingDialogState>({
commandId: options.commandId,
newCombo: options.currentCombo,
currentCombo: options.currentCombo
currentCombo: options.currentCombo,
mode: options.mode ?? 'edit',
existingBinding: options.existingBinding ?? null
})
const existingKeybindingOnCombo = computed(() => {

View File

@@ -1,4 +1,5 @@
{
"supports_preview_metadata": true,
"supports_manager_v4_ui": true
"supports_manager_v4_ui": true,
"supports_progress_text_metadata": true
}

View File

@@ -534,6 +534,141 @@ describe('ensureGlobalIdUniqueness', () => {
})
})
describe('_removeDuplicateLinks', () => {
class TestNode extends LGraphNode {
constructor(title?: string) {
super(title ?? 'TestNode')
this.addInput('input_0', 'number')
this.addOutput('output_0', 'number')
}
}
function registerTestNodes() {
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
}
it('removes orphaned duplicate links from _links and output.links', () => {
registerTestNodes()
const graph = new LGraph()
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
graph.add(source)
graph.add(target)
source.connect(0, target, 0)
expect(graph._links.size).toBe(1)
const existingLink = graph._links.values().next().value!
for (let i = 0; i < 3; i++) {
const dupLink = new LLink(
++graph.state.lastLinkId,
existingLink.type,
existingLink.origin_id,
existingLink.origin_slot,
existingLink.target_id,
existingLink.target_slot
)
graph._links.set(dupLink.id, dupLink)
source.outputs[0].links!.push(dupLink.id)
}
expect(graph._links.size).toBe(4)
expect(source.outputs[0].links).toHaveLength(4)
graph._removeDuplicateLinks()
expect(graph._links.size).toBe(1)
expect(source.outputs[0].links).toHaveLength(1)
expect(target.inputs[0].link).toBe(source.outputs[0].links![0])
})
it('keeps the link referenced by input.link', () => {
registerTestNodes()
const graph = new LGraph()
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
graph.add(source)
graph.add(target)
source.connect(0, target, 0)
const keptLinkId = target.inputs[0].link!
const dupLink = new LLink(
++graph.state.lastLinkId,
'number',
source.id,
0,
target.id,
0
)
graph._links.set(dupLink.id, dupLink)
source.outputs[0].links!.push(dupLink.id)
graph._removeDuplicateLinks()
expect(graph._links.size).toBe(1)
expect(target.inputs[0].link).toBe(keptLinkId)
expect(graph._links.has(keptLinkId)).toBe(true)
expect(graph._links.has(dupLink.id)).toBe(false)
})
it('is a no-op when no duplicates exist', () => {
registerTestNodes()
const graph = new LGraph()
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
graph.add(source)
graph.add(target)
source.connect(0, target, 0)
const linksBefore = graph._links.size
graph._removeDuplicateLinks()
expect(graph._links.size).toBe(linksBefore)
})
it('cleans up duplicate links in subgraph during configure', () => {
const subgraphData = createTestSubgraphData()
const rootGraph = new LGraph()
const subgraph = rootGraph.createSubgraph(subgraphData)
const source = new LGraphNode('Source')
source.addOutput('out', 'number')
const target = new LGraphNode('Target')
target.addInput('in', 'number')
subgraph.add(source)
subgraph.add(target)
source.connect(0, target, 0)
expect(subgraph._links.size).toBe(1)
const existingLink = subgraph._links.values().next().value!
for (let i = 0; i < 3; i++) {
const dup = new LLink(
++subgraph.state.lastLinkId,
existingLink.type,
existingLink.origin_id,
existingLink.origin_slot,
existingLink.target_id,
existingLink.target_slot
)
subgraph._links.set(dup.id, dup)
source.outputs[0].links!.push(dup.id)
}
expect(subgraph._links.size).toBe(4)
// Serialize and reconfigure - should clean up during configure
const serialized = subgraph.asSerialisable()
subgraph.configure(serialized as never)
expect(subgraph._links.size).toBe(1)
})
})
describe('Subgraph Unpacking', () => {
class TestNode extends LGraphNode {
constructor(title?: string) {

View File

@@ -84,7 +84,7 @@ export type {
LGraphTriggerParam
} from './types/graphTriggers'
export type RendererType = 'LG' | 'Vue'
export type RendererType = 'LG' | 'Vue' | 'Vue-corrected'
export interface LGraphState {
lastGroupId: number
@@ -164,6 +164,11 @@ export class LGraph
static STATUS_STOPPED = 1
static STATUS_RUNNING = 2
/** Generates a unique string key for a link's connection tuple. */
static _linkTupleKey(link: LLink): string {
return `${link.origin_id}\0${link.origin_slot}\0${link.target_id}\0${link.target_slot}`
}
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
static readonly ConfigureProperties = new Set([
'nodes',
@@ -1331,7 +1336,8 @@ export class LGraph
const validEventTypes = new Set([
'node:slot-links:changed',
'node:slot-errors:changed',
'node:property:changed'
'node:property:changed',
'node:slot-label:changed'
])
if (validEventTypes.has(action) && param && typeof param === 'object') {
@@ -1611,6 +1617,52 @@ export class LGraph
link.disconnect(this)
}
/**
* Removes duplicate links that share the same connection tuple
* (origin_id, origin_slot, target_id, target_slot). Keeps the link
* referenced by input.link and removes orphaned duplicates from
* output.links and the graph's _links map.
*/
_removeDuplicateLinks(): void {
const seen = new Map<string, LinkId>()
const toRemove: LinkId[] = []
for (const [id, link] of this._links) {
const key = LGraph._linkTupleKey(link)
if (seen.has(key)) {
const existingId = seen.get(key)!
// Keep the link that the input side references
const node = this.getNodeById(link.target_id)
const input = node?.inputs?.[link.target_slot]
if (input?.link === id) {
toRemove.push(existingId)
seen.set(key, id)
} else {
toRemove.push(id)
}
} else {
seen.set(key, id)
}
}
for (const id of toRemove) {
const link = this._links.get(id)
if (!link) continue
// Remove from origin node's output.links array
const originNode = this.getNodeById(link.origin_id)
if (originNode) {
const output = originNode.outputs?.[link.origin_slot]
if (output?.links) {
const idx = output.links.indexOf(id)
if (idx !== -1) output.links.splice(idx, 1)
}
}
this._links.delete(id)
}
}
/**
* Creates a new subgraph definition, and adds it to the graph.
* @param data Exported data (typically serialised) to configure the new subgraph with
@@ -2072,7 +2124,7 @@ export class LGraph
// disconnect/reconnect cycles on widget inputs that can shift slot indices.
const seenLinks = new Set<string>()
const dedupedNewLinks = newLinks.filter((link) => {
const key = `${link.oid}:${link.oslot}:${link.tid}:${link.tslot}`
const key = `${link.oid}\0${link.oslot}\0${link.tid}\0${link.tslot}`
if (seenLinks.has(key)) return false
seenLinks.add(key)
return true
@@ -2568,6 +2620,12 @@ export class LGraph
}
}
// Remove duplicate links: links in output.links that share the same
// (origin_id, origin_slot, target_id, target_slot) tuple.
// This repairs corrupted data where extra link objects were created
// without proper cleanup of the previous connection.
this._removeDuplicateLinks()
// groups
this._groups.length = 0
const groupData = data.groups

View File

@@ -1,6 +1,7 @@
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { LGraphIcon } from './LGraphIcon'
import type { LGraphIconOptions } from './LGraphIcon'
import { cachedMeasureText } from './utils/textMeasureCache'
export enum BadgePosition {
TopLeft = 'top-left',
@@ -80,11 +81,11 @@ export class LGraphBadge {
iconWidth = this.icon.size + this.padding
} else if (this.icon.unicode) {
ctx.font = `${this.icon.fontSize}px '${this.icon.fontFamily}'`
iconWidth = ctx.measureText(this.icon.unicode).width + this.padding
iconWidth = cachedMeasureText(ctx, this.icon.unicode) + this.padding
}
}
ctx.font = `${this.fontSize}px sans-serif`
const textWidth = this.text ? ctx.measureText(this.text).width : 0
const textWidth = this.text ? cachedMeasureText(ctx, this.text) : 0
ctx.font = font
return iconWidth + textWidth + this.padding * 2
}

View File

@@ -1,6 +1,7 @@
import { LGraphBadge } from './LGraphBadge'
import type { LGraphBadgeOptions } from './LGraphBadge'
import { Rectangle } from './infrastructure/Rectangle'
import { cachedMeasureText } from './utils/textMeasureCache'
export interface LGraphButtonOptions extends LGraphBadgeOptions {
name?: string // To identify the button
@@ -22,7 +23,7 @@ export class LGraphButton extends LGraphBadge {
ctx.font = `${this.fontSize}px 'PrimeIcons'`
// For icon buttons, just measure the text width without padding
const textWidth = this.text ? ctx.measureText(this.text).width : 0
const textWidth = this.text ? cachedMeasureText(ctx, this.text) : 0
ctx.font = font
return textWidth

View File

@@ -0,0 +1,363 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import {
LGraph,
LGraphCanvas,
LGraphGroup,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
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()
}
}))
function createCanvas(graph: LGraph): LGraphCanvas {
const el = document.createElement('canvas')
el.width = 800
el.height = 600
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} satisfies Partial<CanvasRenderingContext2D>
el.getContext = vi
.fn()
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
el.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
return new LGraphCanvas(el, graph, { skip_render: true })
}
class TestNode extends LGraphNode {
constructor() {
super('test')
}
}
describe('LGraphCanvas group selection', () => {
let graph: LGraph
let canvas: LGraphCanvas
let group: LGraphGroup
let nodeA: TestNode
let nodeB: TestNode
beforeEach(() => {
vi.clearAllMocks()
graph = new LGraph()
canvas = createCanvas(graph)
group = new LGraphGroup('TestGroup')
group._bounding.set([0, 0, 500, 500])
graph.add(group)
nodeA = new TestNode()
nodeA.pos = [50, 50]
graph.add(nodeA)
nodeB = new TestNode()
nodeB.pos = [100, 100]
graph.add(nodeB)
group.recomputeInsideNodes()
})
describe('select with groupSelectChildren enabled', () => {
beforeEach(() => {
canvas.groupSelectChildren = true
})
it('selects all children when selecting a group', () => {
canvas.select(group)
expect(group.selected).toBe(true)
expect(nodeA.selected).toBe(true)
expect(nodeB.selected).toBe(true)
expect(canvas.selectedItems.has(group)).toBe(true)
expect(canvas.selectedItems.has(nodeA)).toBe(true)
expect(canvas.selectedItems.has(nodeB)).toBe(true)
})
it('recursively selects nested group children', () => {
const innerGroup = new LGraphGroup('InnerGroup')
innerGroup._bounding.set([40, 40, 200, 200])
graph.add(innerGroup)
const innerNode = new TestNode()
innerNode.pos = [60, 60]
graph.add(innerNode)
innerGroup.recomputeInsideNodes()
group.recomputeInsideNodes()
canvas.select(group)
expect(innerGroup.selected).toBe(true)
expect(innerNode.selected).toBe(true)
expect(canvas.selectedItems.has(innerGroup)).toBe(true)
expect(canvas.selectedItems.has(innerNode)).toBe(true)
})
it('selects descendants of already-selected nested groups', () => {
const innerGroup = new LGraphGroup('InnerGroup')
innerGroup._bounding.set([40, 40, 200, 200])
graph.add(innerGroup)
const innerNode = new TestNode()
innerNode.pos = [60, 60]
graph.add(innerNode)
innerGroup.recomputeInsideNodes()
group.recomputeInsideNodes()
// Pre-select the inner group before selecting the outer group
canvas.select(innerGroup)
expect(innerGroup.selected).toBe(true)
expect(innerNode.selected).toBeFalsy()
canvas.select(group)
expect(innerNode.selected).toBe(true)
expect(canvas.selectedItems.has(innerNode)).toBe(true)
})
it('handles deeply nested groups (depth 5)', () => {
const groups: LGraphGroup[] = [group]
const nodes: TestNode[] = [nodeA, nodeB]
for (let depth = 1; depth <= 5; depth++) {
const offset = depth * 10
const size = 500 - depth * 20
const nestedGroup = new LGraphGroup(`Depth${depth}`)
nestedGroup._bounding.set([offset, offset, size, size])
graph.add(nestedGroup)
groups.push(nestedGroup)
const nestedNode = new TestNode()
nestedNode.pos = [offset + 5, offset + 5]
graph.add(nestedNode)
nodes.push(nestedNode)
}
// Recompute from innermost to outermost
for (let i = groups.length - 1; i >= 0; i--) {
groups[i].recomputeInsideNodes()
}
canvas.select(group)
for (const g of groups) {
expect(g.selected).toBe(true)
expect(canvas.selectedItems.has(g)).toBe(true)
}
for (const n of nodes) {
expect(n.selected).toBe(true)
expect(canvas.selectedItems.has(n)).toBe(true)
}
})
})
describe('select with groupSelectChildren disabled', () => {
beforeEach(() => {
canvas.groupSelectChildren = false
})
it('does not select children when selecting a group', () => {
canvas.select(group)
expect(group.selected).toBe(true)
expect(nodeA.selected).toBeFalsy()
expect(nodeB.selected).toBeFalsy()
expect(canvas.selectedItems.has(group)).toBe(true)
expect(canvas.selectedItems.has(nodeA)).toBe(false)
})
})
describe('deselect with groupSelectChildren enabled', () => {
beforeEach(() => {
canvas.groupSelectChildren = true
})
it('deselects all children when deselecting a group', () => {
canvas.select(group)
expect(nodeA.selected).toBe(true)
canvas.deselect(group)
expect(group.selected).toBe(false)
expect(nodeA.selected).toBe(false)
expect(nodeB.selected).toBe(false)
expect(canvas.selectedItems.has(group)).toBe(false)
expect(canvas.selectedItems.has(nodeA)).toBe(false)
})
it('recursively deselects nested group children', () => {
const innerGroup = new LGraphGroup('InnerGroup')
innerGroup._bounding.set([40, 40, 200, 200])
graph.add(innerGroup)
const innerNode = new TestNode()
innerNode.pos = [60, 60]
graph.add(innerNode)
innerGroup.recomputeInsideNodes()
group.recomputeInsideNodes()
canvas.select(group)
expect(innerNode.selected).toBe(true)
canvas.deselect(group)
expect(innerGroup.selected).toBe(false)
expect(innerNode.selected).toBe(false)
})
it('handles deeply nested deselection (depth 5)', () => {
const groups: LGraphGroup[] = [group]
const nodes: TestNode[] = [nodeA, nodeB]
for (let depth = 1; depth <= 5; depth++) {
const offset = depth * 10
const size = 500 - depth * 20
const nestedGroup = new LGraphGroup(`Depth${depth}`)
nestedGroup._bounding.set([offset, offset, size, size])
graph.add(nestedGroup)
groups.push(nestedGroup)
const nestedNode = new TestNode()
nestedNode.pos = [offset + 5, offset + 5]
graph.add(nestedNode)
nodes.push(nestedNode)
}
for (let i = groups.length - 1; i >= 0; i--) {
groups[i].recomputeInsideNodes()
}
canvas.select(group)
canvas.deselect(group)
for (const g of groups) {
expect(g.selected).toBe(false)
expect(canvas.selectedItems.has(g)).toBe(false)
}
for (const n of nodes) {
expect(n.selected).toBe(false)
expect(canvas.selectedItems.has(n)).toBe(false)
}
})
})
describe('processSelect modifier-click deselect', () => {
beforeEach(() => {
canvas.groupSelectChildren = true
})
it('modifier-click deselects only the group, not its children', () => {
canvas.select(group)
expect(group.selected).toBe(true)
expect(nodeA.selected).toBe(true)
expect(nodeB.selected).toBe(true)
const shiftEvent = { shiftKey: true } as CanvasPointerEvent
canvas.processSelect(group, shiftEvent)
expect(group.selected).toBe(false)
expect(canvas.selectedItems.has(group)).toBe(false)
expect(nodeA.selected).toBe(true)
expect(nodeB.selected).toBe(true)
expect(canvas.selectedItems.has(nodeA)).toBe(true)
expect(canvas.selectedItems.has(nodeB)).toBe(true)
})
it('ctrl-click deselects only the group, not its children', () => {
canvas.select(group)
const ctrlEvent = { ctrlKey: true } as CanvasPointerEvent
canvas.processSelect(group, ctrlEvent)
expect(group.selected).toBe(false)
expect(nodeA.selected).toBe(true)
expect(nodeB.selected).toBe(true)
})
})
describe('deselect with groupSelectChildren disabled', () => {
it('does not deselect children when deselecting a group', () => {
canvas.groupSelectChildren = true
canvas.select(group)
canvas.groupSelectChildren = false
canvas.deselect(group)
expect(group.selected).toBe(false)
expect(nodeA.selected).toBe(true)
expect(nodeB.selected).toBe(true)
})
})
describe('deleteSelected with groupSelectChildren enabled', () => {
beforeEach(() => {
canvas.groupSelectChildren = true
// Attach canvas to DOM so checkPanels() can query parentNode
document.body.appendChild(canvas.canvas)
})
it('deletes group and all selected children', () => {
canvas.select(group)
expect(canvas.selectedItems.size).toBeGreaterThan(1)
canvas.deleteSelected()
expect(graph.nodes).not.toContain(nodeA)
expect(graph.nodes).not.toContain(nodeB)
expect(graph.groups).not.toContain(group)
})
})
})

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
describe('LGraphCanvas.renderInfo', () => {
let lgCanvas: LGraphCanvas
let ctx: CanvasRenderingContext2D
beforeEach(() => {
const canvasElement = document.createElement('canvas')
ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
font: '',
fillStyle: '',
textAlign: 'left' as CanvasTextAlign,
fillText: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
const graph = new LGraph()
lgCanvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true,
skip_events: true
})
})
it('does not access canvas.offsetHeight when y is provided', () => {
const spy = vi.spyOn(lgCanvas.canvas, 'offsetHeight', 'get')
lgCanvas.renderInfo(ctx, 10, 500)
expect(spy).not.toHaveBeenCalled()
})
it('uses canvas.height divided by devicePixelRatio as y fallback', () => {
lgCanvas.canvas.width = 1920
lgCanvas.canvas.height = 2160
const originalDPR = window.devicePixelRatio
Object.defineProperty(window, 'devicePixelRatio', {
value: 2,
configurable: true
})
try {
lgCanvas.renderInfo(ctx, 10, 0)
// lineCount = 5 (graph present, no info_text), lineHeight = 13
// y = canvas.height / DPR - (lineCount + 1) * lineHeight
expect(ctx.translate).toHaveBeenCalledWith(10, 2160 / 2 - 6 * 13)
} finally {
Object.defineProperty(window, 'devicePixelRatio', {
value: originalDPR,
configurable: true
})
}
})
})

View File

@@ -26,6 +26,10 @@ import type { RerouteId } from './Reroute'
import { LinkConnector } from './canvas/LinkConnector'
import { isOverNodeInput, isOverNodeOutput } from './canvas/measureSlots'
import { strokeShape } from './draw'
import {
cachedMeasureText,
clearTextMeasureCache
} from './utils/textMeasureCache'
import type {
CustomEventDispatcher,
ICustomEventTarget
@@ -565,6 +569,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
allow_dragnodes: boolean
allow_interaction: boolean
multi_select: boolean
groupSelectChildren: boolean
allow_searchbox: boolean
allow_reconnect_links: boolean
align_to_grid: boolean
@@ -933,6 +938,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.allow_interaction = true
// allow selecting multi nodes without pressing extra keys
this.multi_select = false
this.groupSelectChildren = false
this.allow_searchbox = true
// allows to change a connection with having to redo it again
this.allow_reconnect_links = true
@@ -1951,6 +1957,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
return false
}
/** Prevents default for middle-click auxclick only. */
_preventMiddleAuxClick(e: MouseEvent): void {
if (e.button === 1) e.preventDefault()
}
/** Captures an event and prevents default - returns true. */
_doReturnTrue(e: Event): boolean {
e.preventDefault()
@@ -1986,6 +1997,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
canvas.addEventListener('pointercancel', this._mousecancel_callback, true)
canvas.addEventListener('contextmenu', this._doNothing)
// Prevent middle-click paste (PRIMARY clipboard on Linux) - fixes #4464
canvas.addEventListener('auxclick', this._preventMiddleAuxClick)
// Keyboard
this._key_callback = this.processKey.bind(this)
@@ -2024,6 +2037,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
canvas.removeEventListener('keydown', this._key_callback!)
document.removeEventListener('keyup', this._key_callback!)
canvas.removeEventListener('contextmenu', this._doNothing)
canvas.removeEventListener('auxclick', this._preventMiddleAuxClick)
canvas.removeEventListener('dragenter', this._doReturnTrue)
this._mousedown_callback = undefined
@@ -4363,7 +4377,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!modifySelection) this.deselectAll(item)
this.select(item)
} else if (modifySelection && !sticky) {
this.deselect(item)
// Modifier-click toggles only the clicked item, not its children.
// Cascade on select is a convenience; cascade on deselect would
// remove the user's ability to keep children selected (e.g. for
// deletion) after toggling the group off.
if (item instanceof LGraphGroup && this.groupSelectChildren) {
item.selected = false
this.selectedItems.delete(item)
this.state.selectionChanged = true
} else {
this.deselect(item)
}
} else if (!sticky) {
this.deselectAll(item)
} else {
@@ -4388,6 +4412,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (item instanceof LGraphGroup) {
item.recomputeInsideNodes()
if (this.groupSelectChildren) {
this.#traverseGroupChildren(
item,
(child) => {
if (!child.selected || !this.selectedItems.has(child)) {
child.selected = true
this.selectedItems.add(child)
this.state.selectionChanged = true
}
},
(child) => this.select(child)
)
}
return
}
@@ -4426,6 +4463,22 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
item.selected = false
this.selectedItems.delete(item)
this.state.selectionChanged = true
if (item instanceof LGraphGroup && this.groupSelectChildren) {
this.#traverseGroupChildren(
item,
(child) => {
if (child.selected || this.selectedItems.has(child)) {
child.selected = false
this.selectedItems.delete(child)
this.state.selectionChanged = true
}
},
(child) => this.deselect(child)
)
return
}
if (!(item instanceof LGraphNode)) return
// Node-specific handling
@@ -4461,6 +4514,29 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
/**
* Iterative traversal of a group's descendants.
* Calls {@link groupAction} on nested groups and {@link leafAction} on
* non-group children. Always recurses into nested groups regardless of
* their current selection state.
*/
#traverseGroupChildren(
group: LGraphGroup,
groupAction: (child: LGraphGroup) => void,
leafAction: (child: Positionable) => void
): void {
const stack: Positionable[] = [...group._children]
while (stack.length > 0) {
const child = stack.pop()!
if (child instanceof LGraphGroup) {
groupAction(child)
for (const nested of child._children) stack.push(nested)
} else {
leafAction(child)
}
}
}
/** @deprecated See {@link LGraphCanvas.processSelect} */
processNodeSelected(item: LGraphNode, e: CanvasPointerEvent): void {
this.processSelect(
@@ -4593,7 +4669,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.emitBeforeChange()
graph.beforeChange()
for (const item of this.selectedItems) {
// Snapshot to prevent mutation during iteration (e.g. group deselect cascade)
const toDelete = [...this.selectedItems]
for (const item of toDelete) {
if (item instanceof LGraphNode) {
const node = item
if (node.block_delete) continue
@@ -4819,6 +4897,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* draws the front canvas (the one containing all the nodes)
*/
drawFrontCanvas(): void {
clearTextMeasureCache()
this.dirty_canvas = false
const { ctx, canvas, graph } = this
@@ -5185,7 +5264,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const lineHeight = 13
const lineCount = (this.graph ? 5 : 1) + (this.info_text ? 1 : 0)
x = x || 10
y = y || this.canvas.offsetHeight - (lineCount + 1) * lineHeight
y =
y ||
this.canvas.height /
((this.canvas.ownerDocument.defaultView ?? window).devicePixelRatio ||
1) -
(lineCount + 1) * lineHeight
ctx.save()
ctx.translate(x, y)
@@ -5548,8 +5632,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
text = text.substring(0, 30)
ctx.font = '14px Courier New'
const info = ctx.measureText(text)
const w = info.width + 20
const w = cachedMeasureText(ctx, text) + 20
const h = 24
ctx.shadowColor = 'black'
ctx.shadowOffsetX = 2

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