Compare commits

...

43 Commits

Author SHA1 Message Date
Jin Yi
540ca03f73 chore: style added 2026-01-20 10:25:27 +09:00
Johnpaul Chiwetelu
7ef4ea6f25 Road to No Explicit Any Part 7: Scripts and Dialog Cleanup (#8092)
## Summary

Continues the TypeScript strict typing improvements by removing `any`
types from core scripts and dialog components.

### Changes

**api.ts (6 instances)**
- Define `V1RawPrompt` and `CloudRawPrompt` tuple types for queue prompt
formats
- Export `QueueIndex`, `PromptInputs`, `ExtraData`, `OutputsToExecute`
from apiSchema
- Type `#postItem` body, `storeUserData` data, and `getCustomNodesI18n`
return

**groupNodeManage.ts (all @ts-expect-error removed)**
- Add `GroupNodeConfigEntry` interface to LGraph.ts
- Extend `GroupNodeWorkflowData` with `title`, `widgets_values`, and
typed `config`
- Type all class properties with definite assignment assertions
- Type all method parameters and event handlers
- Fix save button callback with proper generic types for node ordering

**changeTracker.ts (4 instances)**
- Type `nodeOutputs` as `Record<string, ExecutedWsMessage['output']>`
- Type prompt callback with `CanvasPointerEvent` and proper value types

**asyncDialog.ts and dialog.ts**
- Make `ComfyAsyncDialog` generic with `DialogAction<T>` type
- Type `ComfyDialog` constructor and show method parameters
- Update `ManageGroupDialog.show` signature to match base class

## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] Sourcegraph checks for external usage

---
Related: Continues from #8083

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8092-Road-to-No-Explicit-Any-Part-7-Scripts-and-Dialog-Cleanup-2ea6d73d365081fbb890e73646a6ad16)
by [Unito](https://www.unito.io)
2026-01-20 00:41:40 +00:00
Alexander Brown
d5f17f7d9f chore: migrate Vite config to Vite 8 beta (Rolldown) (#8152)
Prepares Vite config for Vite 8 by migrating from esbuild/Rollup to
Rolldown/Oxc.

## Changes
- Migrate `build.rollupOptions` → `build.rolldownOptions`
- Replace `manualChunks` with `codeSplitting.groups`
- Update Storybook config with `strictExecutionOrder` for module loading
compatibility

## Testing
- [x] `pnpm typecheck` passes
- [x] `pnpm build` succeeds
- [x] `pnpm test:unit` passes

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-19 16:36:16 -08:00
Johnpaul Chiwetelu
0d0576faab feat: optimize empty search to use cached /nodes endpoint (#8159)
## Summary

Optimizes the Manager dialog to use the cached `GET /nodes` endpoint
instead of `GET /nodes/search` for empty search queries (when the dialog
first opens). This significantly reduces Algolia usage since empty
searches account for the majority of search requests.

## Changes

- **registrySearchProvider.ts**: Modified `searchPacks()` to detect
empty queries and route them to `listAllPacks()` instead of `search()`
- **registrySearchProvider.test.ts**: Added 5 new test cases covering
empty query behavior
- Cache clearing now clears both `search` and `listAllPacks` caches

## Technical Details

**Empty Query Flow (NEW):**
- Query: `""` or whitespace
- Endpoint: `GET /nodes?limit=X&page=Y`
- Cache: Server-side cached (via omitting `latest` parameter)
- Result: Fast, cached node pack list

**Non-Empty Query Flow (UNCHANGED):**
- Query: Any non-empty string
- Endpoint: `GET /nodes/search?search=X` or `comfy_node_search=X`
- Result: Search results as before

## Testing

```bash
pnpm test:unit -- src/services/providers/registrySearchProvider.test.ts
pnpm typecheck
```
2026-01-20 00:03:17 +01:00
AustinMroz
b0d7a7f0f4 Control widget fixes (#8160)
#8112 updated control widgets to be disabled when the controlled widget
is disabled. However, some workflows already exist that contain a
promoted control widget which does not function. This widget wouldn't be
marked as disabled (and thus, demoted) until the interior subgraph was
entered as updating `computedDisabled` is tacked to node draw. This is
fixed by having subgraphs eagerly update the `computedDisabled` state on
each node when configured.

Additionally, when `createCopyForNode` was used, linkedWidget retained
pointers to widgets which no longer have relation to the newly cloned
widget. This is resolved by instead not copying linkedWidgets.
Functionally, linkedWidgets is only used for control widgets and not
copying has the effect of ensuring that seed widgets linked to a
subgraph input will not display a control popover button in vue mode
which does nothing.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8160-Control-widget-fixes-2ed6d73d3650816cb397f83f558471b3)
by [Unito](https://www.unito.io)
2026-01-19 12:59:01 -08:00
Comfy Org PR Bot
12ee5de73b 1.38.6 (#8154)
Patch version increment to 1.38.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8154-1-38-6-2ed6d73d36508100be3bee4c2639481e)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-01-18 19:15:37 -08:00
AustinMroz
6db4750d96 Fix crosshair cursor in vue mode (#8120)
When the mouse cursor is at the very edge of a a node in vue mode, a
crosshair cursor will sometimes display. This happens because the mouse
is over the canvas, and `LGraphCanvas.processMouseMove` determines the
cursor is still above the node.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8120-Fix-crosshair-cursor-in-vue-mode-2eb6d73d36508116a3cfdd407c5e1e9c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-18 11:00:00 -08:00
Rizumu Ayaka
30907f99f1 chore: move renameWidget function to widgetUtil.ts (#8042)
related:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7812#discussion_r2685121387

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8042-chore-move-renameWidget-function-to-widgetUtil-ts-2e86d73d3650813fa502d38b1ca53ab0)
by [Unito](https://www.unito.io)
2026-01-17 21:30:00 -07:00
AustinMroz
284bdce61b Add a slider indicator for number widgets in vue mode. (#8122)
Sometimes it's difficult to gauge the valid range of values for a
widget. Litegraph includes a "slider" widget which displays the distance
from the min and max values as a colored bar. However, this
implementation is rather strongly disliked because it prevents entering
an exact number. Vue mode makes it simple to add just the indicator onto
our existing widget.

In addition to requiring both min and max be set, not every widget would
want this functionality. It's not useful information for seed, but also
has potential to cause confusion on widgets like CFG, that allow
inputting numbers up to 100 even though values beyond ~15 are rarely
desirable.

As a proposed heuristic, the ratio of "step" to distance between min and
max is currently used, but this could fairly easily be changed to an
opt-in only system.

<img width="617" height="487" alt="image"
src="https://github.com/user-attachments/assets/9c5f2119-0a03-4b56-bcf5-e4a0d0250784"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8122-Add-a-slider-indicator-for-number-widgets-in-vue-mode-2eb6d73d365081218fc8e86f37001958)
by [Unito](https://www.unito.io)
2026-01-17 21:28:48 -07:00
Comfy Org PR Bot
7fcef2ba89 1.38.5 (#8138)
Patch version increment to 1.38.5

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8138-1-38-5-2ec6d73d365081b6bf57fd29cb56998c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-18 03:55:35 +00:00
Christian Byrne
54db655a23 feat: make subgraphs blueprints appear higher in node library sidebar (#8140)
## Summary

Changes insertion order so subgraph blueprints are inserted first and
therefore appear highest in node library sidebar (when using default
'original' ordering).

<img width="1003" height="725" alt="image"
src="https://github.com/user-attachments/assets/3f1ea61c-4191-4dd5-8c10-17cd91b6a732"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8140-feat-make-subgraphs-blueprints-appear-higher-in-node-library-sidebar-2ec6d73d3650816f8164f0991b81c116)
by [Unito](https://www.unito.io)
2026-01-17 20:43:24 -07:00
Terry Jia
82c3cd3cd2 add thumbnail for 3d generation (#8129)
## Summary

add thrumbnail for 3d genations, feature requested by @PabloWiedemann 

## Screenshots


https://github.com/user-attachments/assets/4fb9b88b-dd7b-4a69-a70c-e850472d3498

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8129-add-thumbnail-for-3d-generation-2eb6d73d365081f2a30bc698a4fde6e0)
by [Unito](https://www.unito.io)
2026-01-17 20:32:32 -07:00
AustinMroz
c9d74777ba Migrate parentIds when converting to subgraph (#5708)
The parentId property on links and reroutes was not handled at all in
the "Convert to Subgraph" code.
This needs to be addressed in 4 cases
- A new external input link must have parentId set to the first
non-migrated reroute
- A new external output link must have the parentId of it's eldest
remaining child set to undefined
- A new internal input link must have the parentId of it's eldest
remaining child set to undefined
- A new internal output link must have the parentId set to the first
migrated reroute

This is handled in two parts by adding logic where the boundry links is
created
- The change involves mutation of inputs (which isn't great) but the
function here was already mutating inputs into an invalid state
  - @DrJKL Do you see a quick way to better fix both these cases?

Looks like litegraph tests aren't enabled and cursory glance shows
multiple need to be updated to reflect recent changes. I'll still try to
add some tests anyways.
EDIT: Tests are non functional. Seems the subgraph conversion call
requires the rest of the frontend is running and has event listeners to
register the subgraph node def. More work than anticipated, best
revisited later

Resolves #5669

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5708-Migrate-parentIds-when-converting-to-subgraph-2746d73d365081f78acff4454092c74a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-17 19:13:05 -08:00
Terry Jia
be8916b4ce feat: Add visual crop preview widget for ImageCrop node - widget ImageCrop (#7825)
## Summary

Another implementation for image crop node, alternative for
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7014
As discussed with @christian-byrne and @DrJKL we could have single
widget - IMAGECROP with 4 ints and UI preview.

However, this solution requires changing the definition of image crop
node in BE (sent
[here](https://github.com/comfyanonymous/ComfyUI/pull/11594)), which
will break the exsiting workflow, also it would not allow connect
separate int node as input, I am not sure it is a good idea.

So I keep two PRs openned for references

## Screenshots


https://github.com/user-attachments/assets/fde6938c-4395-48f6-ac05-6282c5eb8157

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7825-feat-Add-visual-crop-preview-widget-for-ImageCrop-node-widget-ImageCrop-2dc6d73d3650812bb8a2cdff4615032b)
by [Unito](https://www.unito.io)
2026-01-17 17:09:16 -05:00
Alexander Brown
de2e37ec8e chore: merge vitest config into vite.config.mts (#8132)
Moves vitest configuration from `vitest.config.ts` into the `test`
section of `vite.config.mts` and deletes the separate vitest config
file.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8132-chore-merge-vitest-config-into-vite-config-mts-2eb6d73d365081ab81b5dca11fadf13a)
by [Unito](https://www.unito.io)
2026-01-17 13:02:55 -08:00
Alexander Brown
e5ff329008 feat: upgrade vite to v8.0.0-beta.8 (Rolldown-powered) (#8127)
## Summary

Upgrades Vite from v7.3.0 to v8.0.0-beta.8, which uses Rolldown
(Rust-based bundler) instead of Rollup.

## Changes

- Updated `vite` to `^8.0.0-beta.8` in pnpm-workspace.yaml catalog
- Added pnpm overrides to ensure all dependencies (including vitest) use
Vite 8

## Notes

- Vite 8 is still in **beta** - no stable release yet
- Uses [Rolldown](https://rolldown.rs/) instead of Rollup for production
builds
- Build, typecheck, and lint all pass
- Per the [Vite 8 migration
guide](https://vite.dev/blog/announcing-vite8-beta), pnpm overrides are
required for tools like Vitest that bundle their own Vite types

## Testing

- [x] `pnpm typecheck` passes
- [x] `pnpm build` succeeds (~13s build time)
- [x] `pnpm lint` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8127-feat-upgrade-vite-to-v8-0-0-beta-8-Rolldown-powered-2eb6d73d365081e3bdb6f500e140eb88)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-01-17 12:17:16 -08:00
AustinMroz
d3bd85db7f Fix roundness of slot error indicator in vue (#8123)
When a node has a missing input connection, the slot is highlighted with
a red outline. This PR makes the error indicator round like the slot
instead of an oval.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/2d5ebc93-fa74-492d-92a7-3a1b3af4754f"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/57c15503-d94a-48d7-8c35-7760f1b860e6"
/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8123-Fix-roundness-of-slot-error-indicator-in-vue-2eb6d73d3650810383a2f5532117c29f)
by [Unito](https://www.unito.io)
2026-01-16 22:07:54 -08:00
Brian Jemilo II
94706b5b04 Check for empty object (#8075)
## Summary

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

## Changes
- **What**: <!-- Core functionality added/modified -->
PNG images causes getWorkflowDataFromFile() to return an empty object,
added a check to handle it.

## Review Focus
<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->
I don't think it exists yet? From Slack Conversation. Just make sure to
use a PNG image and not a JPEG disguised as a PNG.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8075-Check-for-empty-object-2e96d73d3650816b8812eb7244b48f1a)
by [Unito](https://www.unito.io)
2026-01-16 22:03:54 -08:00
pythongosssss
851e8beb29 Fix dragging Vue nodes into canvas from library (#8118)
## Summary

Updates the node preview rendering to use the same app context as the
main app so it can access the same plugins

## Changes

Assigns manually created vnode app context to the current instances
context

## Review Focus

This is using somewhat advanced/almost-internal Vue functionality,
however I couldn't come up with a better alternative that didn't require
recreating an entirely new app and re-registering all dependencies or
redoing how draggable node previews are done.
The draggable image needs to be rendered synchronously, so rendering a
node in the active app and capturing that isn't possible to guarantee to
be done synchronously (afaik - suggestions welcome)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8118-Fix-dragging-Vue-nodes-into-canvas-from-library-2eb6d73d365081a0a956d8280e009592)
by [Unito](https://www.unito.io)
2026-01-16 22:50:54 -07:00
Comfy Org PR Bot
7b3a9b40a5 1.38.4 (#8116)
Patch version increment to 1.38.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8116-1-38-4-2eb6d73d36508140a132e7d14241e851)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-16 21:27:37 -08:00
pythongosssss
3bbae61763 Decouple node help between sidebar and right panel (#8110)
## Summary

When the node library is open and you click on the node toolbar info
button, this causes the node library info panel & right panel node info
to show the same details.

## Changes

- Extract useNodeHelpContent composable so NodeHelpContent fetches its
own content, allowing multiple panels to show help independently
- Remove sync behavior from NodeHelpPage that caused left sidebar to
change when selecting different graph nodes since we want to prioritise
right panel for this behavior
- Add telemetry tracking for node library help button to identify how
frequently this is used

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8110-Decouple-node-help-between-sidebar-and-right-panel-2ea6d73d365081a9b3afd25aa51b34bd)
by [Unito](https://www.unito.io)
2026-01-16 22:13:23 -07:00
AustinMroz
3fcebe758b Disable control widgets on link to parent (#8112)
When a link is made to a widget with control (like seed) , the control
widget can no longer be used to update it's state. To better communicate
this, the control widgets are now given the disabled property when their
parent widget is linked.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/9b6c6c02-2481-486a-bb07-c19d00abe36d"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/837000ac-8a12-4d51-879b-a58e0577ff10"
/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8112-Disable-control-widgets-on-link-to-parent-2ea6d73d365081afad77db6c5f56e085)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-16 22:11:00 -07:00
Christian Byrne
0439f744b9 feat: enable new queue progress by default (#8121)
## Summary

Enable the new queue progress UI/UX by default in order to test it and
be able to ask community members what they think (they can easily test
with `--front-end-version Comfy-Org/ComfyUI_frontend@latest`).

More context:
[thread](https://comfy-organization.slack.com/archives/C0A80028GTA/p1768534815533549)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8121-feat-enable-new-queue-progress-by-default-2eb6d73d365081a2ae23d71243369984)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-16 21:49:14 -07:00
AustinMroz
9d8e54a51c Fix dragndrop subgraph widgets sticking to cursor (#8114)
Under some circumstances, subgraph widgets in the properties panel would
have incorrect drag state
- Debouncing list state would cause a race condition where a double
click would initiate a drag on an element that no longer exists
  - This is solved by removing the debounce.
- Right clicking on widgets would initiate a drag
  - Dragging now explicity requires a primary button click.
Additionally, drag is now ended on `pointercancel`. This is purely for
safety and may not actually be required.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8114-Fix-dragndrop-subgraph-widgets-sticking-to-cursor-2ea6d73d365081f08515e8231bd87bdc)
by [Unito](https://www.unito.io)
2026-01-16 20:36:36 -08:00
AustinMroz
69512b9b28 Fix asset selection in litegraph (#8117)
#8074 included some refactoring to the asset dialogue to ensure that it
wouldn't pop up multiple times in vue mode

But moving the openModal function to be contained in options means that
`this` is no longer the widget, but instead the options object. This is
fixed by requiring that widget be explicitly passed as a parameter.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8117-Fix-asset-selection-in-litegraph-2eb6d73d36508176b5a3f6d21964be39)
by [Unito](https://www.unito.io)
2026-01-16 19:17:10 -07:00
Johnpaul Chiwetelu
afe8ce720e fix: adjust toolbox sizing to match Figma design (#8115)
## Summary
- Adjusts selection toolbox to match Figma design specifications
- Changes padding from `p-2` to `p-1` (4px)
- Changes height from `h-12` to `h-10` (40px)

## Test plan
- [ ] Verify toolbox appears with correct dimensions when selecting
nodes
- [ ] Check visual alignment matches Figma design

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8115-fix-adjust-toolbox-sizing-to-match-Figma-design-2ea6d73d365081c786e9d44388dba092)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-17 00:38:58 +01:00
Alexander Brown
e4308a7258 refactor: rename CLAUDE.md to AGENTS.md (#8052)
## Summary

Pure rename of CLAUDE.md files to AGENTS.md (no content changes).

## Changes

| Old Path | New Path |
|----------|----------|
| `.github/CLAUDE.md` | `.github/AGENTS.md` |
| `.storybook/CLAUDE.md` | `.storybook/AGENTS.md` |
| `browser_tests/CLAUDE.md` | `browser_tests/AGENTS.md` |
| `src/CLAUDE.md` | `src/AGENTS.md` |
| `src/components/CLAUDE.md` | `src/components/AGENTS.md` |
| `src/lib/litegraph/CLAUDE.md` | `src/lib/litegraph/AGENTS.md` |

Root `CLAUDE.md` deleted (content will be merged into `AGENTS.md` in
follow-up PR).

## Follow-up

A second PR will add glob-based guidance files and consolidate
redundancies.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-01-16 13:32:18 -08:00
AustinMroz
cd4999209b Improve linear compatibility with Safari, run button metadata (#8107)
I downloaded the oldest webkit based browser I could find after reports
of display issues. The changes here cause some minor styling tweaks, but
should be more compatible overall. A quick list of fixed issues
- Center panel placeholder had incorrect aspect ratio
<img height="500" alt="image"
src="https://github.com/user-attachments/assets/b75e3c49-20d9-4d6e-bca4-95cc8a73f821"
/>
- Image previews had incorrect aspect ratio (But the other way around)
<img height="500" alt="image"
src="https://github.com/user-attachments/assets/b8ebc58e-2655-41f8-a3b4-70ba65940612"
/>
- On mobile, output groups would flex incorrectly, resulting in a large
gap between them
<img height="500" alt="image"
src="https://github.com/user-attachments/assets/ed7e8e43-c34e-4ffc-a3ee-126b1a2ef4e0"
/>



Also moves the run button trigger source to a new 'linear' type


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8107-Improve-linear-compatibility-with-Safari-run-button-metadata-2ea6d73d3650814e89cbc190b6ca8f87)
by [Unito](https://www.unito.io)
2026-01-16 11:36:52 -08:00
Yoland Yan
7556e3ef39 Update beta message in linear mode (#8106)
This pull request makes a minor update to the user interface text for
Simple Mode. The "beta" label is now more descriptive, clarifying that
Simple Mode is in beta and inviting feedback.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8106-Update-beta-message-in-linear-mode-2ea6d73d3650812193a7cd8f625ea682)
by [Unito](https://www.unito.io)
2026-01-16 11:25:26 -08:00
Alexander Brown
d93c02c437 Revert "fix: run E2E tests after i18n completes on release PRs" (#8105)
Reverts Comfy-Org/ComfyUI_frontend#8091

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8105-Revert-fix-run-E2E-tests-after-i18n-completes-on-release-PRs-2ea6d73d365081f2bd26cdc932acf7fb)
by [Unito](https://www.unito.io)
2026-01-16 11:24:50 -08:00
Johnpaul Chiwetelu
b979ba8992 fix: run E2E tests after i18n completes on release PRs (#8091)
## Summary
- Fixes issue where locale commits would cancel in-progress E2E tests on
release PRs
- E2E tests now run **once** after i18n workflow completes for
version-bump PRs

## Changes
1. **Modified `ci-tests-e2e.yaml`**:
   - Added `workflow_call` trigger with `ref` and `pr_number` inputs
   - Added skip condition for version-bump PRs on `pull_request` trigger
- Updated checkout steps to use `inputs.ref` when called via
`workflow_call`

2. **Created `ci-tests-e2e-release.yaml`**:
   - Triggers on `workflow_run` completion of `i18n: Update Core`
   - Only runs for version-bump PRs from main repo
- Calls original E2E workflow via `workflow_call` (no job duplication)

## How it works
**Regular PRs:** `CI: Tests E2E` runs normally via `pull_request`
trigger

**Version-bump PRs:**
1. `CI: Tests E2E` skips (setup job condition fails)
2. `i18n: Update Core` runs and commits locale updates
3. `CI: Tests E2E (Release PRs)` triggers after i18n completes
4. Calls `CI: Tests E2E` via `workflow_call`
5. E2E tests run to completion without cancellation

## Test plan
- [x] Tested workflow_call chain on fork
- [x] Verify version-bump PR skips regular E2E workflow
- [x] Verify E2E runs after i18n completes on next release

Fixes the issue identified during v1.38.2 release where locale commits
caused E2E tests to restart multiple times.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8091-fix-run-E2E-tests-after-i18n-completes-on-release-PRs-2ea6d73d36508151a315ed1f415afcc6)
by [Unito](https://www.unito.io)
2026-01-16 17:40:27 +01:00
Jin Yi
0288b02113 [refactor] Manager dialog simplification (#8041)
## Summary
Simplifies the Manager dialog by consolidating components and using
BaseModalLayout with v-model support for right panel state.

## Changes
- **Consolidation**: Merged ManagerDialogContent, ManagerHeader,
ManagerNavSidebar, RegistrySearchBar, and SearchFilterDropdown into
single ManagerDialog component
- **Right panel**: Added v-model:rightPanelOpen to BaseModalLayout for
external panel state control; clicking a node card now auto-opens the
info panel
- **Cleanup**: Removed unused useResponsiveCollapse composable, TabItem
and SearchOption types
- **UI tweaks**: Moved action buttons (Install All/Update All) from
header-right-area to contentFilter area


[manager-capture.webm](https://github.com/user-attachments/assets/2dd6092a-965d-4885-8ba6-6a2cc51f024a)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8041-refactor-Manager-dialog-simplification-2e86d73d3650815ba699e49a2748b682)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-15 23:32:02 -08:00
pythongosssss
ddac3dca1d Allow users to drag actionbar over tabs (#8068)
## Summary

A common bit of feedback is that users want to be able to drag the
actionbar into the very top of their window. Currently the actionbar is
clamped to not allow it to overlap the tabs, this update removes that
restriction & fixes padding for the top menu section when the UI
elements are hidden within it adding additional gaps.

## Changes

- Remove clamping on actionbar position
- Fix padding on top menu section

## Screenshots (if applicable)

Before:
<img width="192" height="119" alt="image"
src="https://github.com/user-attachments/assets/c35c89ed-ec30-45ff-8ebd-8ad68dbd4212"
/>

After:
<img width="134" height="120" alt="image"
src="https://github.com/user-attachments/assets/adc32c43-e3ab-4c7b-a8ff-360fd39d6bf1"
/>

Actionbar over tabs:
<img width="465" height="173" alt="image"
src="https://github.com/user-attachments/assets/d1502911-1e15-4082-ba2e-b8906e329b70"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8068-Allow-users-to-drag-actionbar-over-tabs-2e96d73d365081708491f3c54968df3a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-16 00:00:09 -07:00
Christian Byrne
6714b958c7 chore: remove dead browser test fixture after Jobs API migration (#8099)
## Summary

Removes  which is dead code left over from the Jobs API migration.

## Details

This fixture file:
- References legacy and types that were removed in the Jobs API
migration
- Is not referenced anywhere in the codebase
- Cannot be used since the types it imports no longer exist

## Related PRs

Follow-up cleanup to the Jobs API migration:
- #7169 - Add Jobs API infrastructure (PR 1 of 3)
- #7170 - Migrate to Jobs API (PR 2 of 3)  
- #7650 - Encapsulate error extraction in TaskItemImpl getters

## Testing

-  Typecheck passes
-  No references to this file in the codebase
-  File imports types that no longer exist (cannot be used)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8099-chore-remove-dead-browser-test-fixture-after-Jobs-API-migration-2ea6d73d36508172be89c9c5a74b33ee)
by [Unito](https://www.unito.io)
2026-01-15 23:12:37 -07:00
AustinMroz
a6b6857e37 Fix copypasted primitives inside subgraphs (#8094)
Copying a subgraph which contains primitive nodes would cause the
primitives to fail to initialize. This was caused because they did not
have their `onGraphConfigured` and `onAfterGraphConfigured` callbacks
applied.

There's already a copy of `forEachNode` in
`@/utils/graphTraversalUtil.ts`, but the method is small and I want to
avoid litegraph referencing outside code.

See also #6606, where a similar fix was needed

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8094-Fix-copypasted-primitives-inside-subgraphs-2ea6d73d365081f189f1ea4c9248f5ed)
by [Unito](https://www.unito.io)
2026-01-15 21:36:39 -07:00
ric-yu
c0a649ef43 refactor: encapsulate error extraction in TaskItemImpl getters (#7650)
## Summary
- Add `errorMessage` and `executionError` getters to `TaskItemImpl` that
extract error info from status messages
- Update `useJobErrorReporting` composable to use these getters instead
of standalone function
- Remove the standalone `extractExecutionError` function

This encapsulates error extraction within `TaskItemImpl`, preparing for
the Jobs API migration where the underlying data format will change but
the getter interface will remain stable.

## Test plan
- [x] All existing tests pass
- [x] New tests added for `TaskItemImpl.errorMessage` and
`TaskItemImpl.executionError` getters
- [x] TypeScript, lint, and knip checks pass

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7650-refactor-encapsulate-error-extraction-in-TaskItemImpl-getters-2ce6d73d365081caae33dcc7e1e07720)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-01-15 21:11:22 -07:00
Comfy Org PR Bot
089295606a 1.38.3 (#8085)
Patch version increment to 1.38.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8085-1-38-3-2ea6d73d365081779d52dea9c48371a7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-15 18:14:27 -08:00
Christian Byrne
6048fab239 feat: add per-tab workspace authentication infrastructure (#8073)
## Summary
Add workspace authentication composables and types for per-tab workspace
isolation. This infrastructure enables users to work in different
workspaces in different browser tabs.

## Changes
- **useWorkspaceAuth composable** - workspace token management
- Exchange Firebase token for workspace-scoped JWT via `POST
/api/auth/token`
  - Auto-refresh tokens 5 minutes before expiry
  - Per-tab sessionStorage caching
- **useWorkspaceSwitch composable** - workspace switching with unsaved
changes confirmation
- **WorkspaceWithRole/WorkspaceTokenResponse types** - aligned with
backend API
- **firebaseAuthStore.getAuthHeader()** - prioritizes workspace tokens
over Firebase tokens
- **useSessionCookie** - uses Firebase token directly (getIdToken())
since getAuthHeader() now returns workspace token

## Backend Dependency
- `POST /api/auth/token` - exchange Firebase token for workspace token
- `GET /api/workspaces` - list user's workspaces

## Related
- https://github.com/Comfy-Org/ComfyUI_frontend/pull/6295

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8073-feat-add-per-tab-workspace-authentication-infrastructure-2e96d73d3650816c8cf9dae9c330aebb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: anthropic/claude <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
2026-01-15 17:24:48 -08:00
AustinMroz
5409bf86a9 Make sure toggle visibility checks remote config (#8086)
The previous fix, with `.featureFlag()` does not actually result in
remoteConfig being checked

This time, I did extra manual testing to verify that remoteConfig is
populated on cloud and that the visibility of the toggle is actually
reactive to changes to remoteConfig.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8086-Make-sure-toggle-visibility-checks-remote-config-2ea6d73d36508182807bce3b0bb52868)
by [Unito](https://www.unito.io)
2026-01-15 16:11:53 -08:00
Johnpaul Chiwetelu
c56e8425d4 Road to No Explicit Any Part 6: Composables and Extensions (#8083)
## Summary
- Type `onExecuted` callbacks with `NodeExecutionOutput` in saveMesh.ts
and uploadAudio.ts
- Type composable parameters and return values properly
(useLoad3dViewer, useImageMenuOptions, useJobMenu, useResultGallery,
useContextMenuTranslation)
- Type `taskRef` as `TaskItemImpl` with updated test mocks
- Fix error catch and index signature patterns without `any`
- Add `NodeOutputWith<T>` generic helper for typed access to passthrough
properties on `NodeExecutionOutput`

## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] Unit tests pass for affected files
- [x] Sourcegraph checks confirm no external usage of modified types

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8083-Road-to-No-Explicit-Any-Part-6-Composables-and-Extensions-2e96d73d3650810fb033d745bf88a22b)
by [Unito](https://www.unito.io)
2026-01-16 00:27:28 +01:00
AustinMroz
0d5ca96a2b Further linear fixes (#8074)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8074-Further-linear-fixes-2e96d73d365081efb74bf150982c7a66)
by [Unito](https://www.unito.io)
2026-01-15 14:45:19 -08:00
Terry Jia
aff7f2a296 fix: prevent Record Audio waveform from overflowing node bounds (#8070)
## Summary

Add min-w-0 to flex containers to allow shrinking, reduce gaps and
padding, and use shrink-0 on fixed-size elements to ensure the waveform
area clips properly when the node is at minimum width.

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/215455aa-555b-4ade-9b36-9e89ac7c14aa


after

https://github.com/user-attachments/assets/bf56028b-ae02-4388-be83-460c7b1f14e1

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8070-fix-prevent-Record-Audio-waveform-from-overflowing-node-bounds-2e96d73d3650816b9660e5532ffa0bc3)
by [Unito](https://www.unito.io)
2026-01-15 15:49:32 -05:00
Alexander Brown
23694f37bf feat(ui): add TagsInput component with click-to-edit behavior (#8066)
## Summary

Add TagsInput component based on shadcn-vue/Reka UI primitives with a
click-to-edit UX pattern.

## Features

- **Click-to-edit behavior**: Starts in read-only state; clicking
enables editing and focuses input; clicking outside exits edit mode
- **Disabled state**: When `disabled=true`, component is completely
inert
- **Empty state placeholder**: Shows input placeholder when tag list is
empty
- **Animated transitions**: Delete button animates when toggling edit
mode

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-14 21:17:04 -08:00
242 changed files with 7749 additions and 5831 deletions

View File

@@ -1,21 +0,0 @@
---
description: Creating unit tests
globs:
alwaysApply: false
---
# Creating unit tests
- This project uses `vitest` for unit testing
- Tests are stored in the `test/` directory
- Tests should be cross-platform compatible; able to run on Windows, macOS, and linux
- e.g. the use of `path.resolve`, or `path.join` and `path.sep` to ensure that tests work the same on all platforms
- Tests should be mocked properly
- Mocks should be cleanly written and easy to understand
- Mocks should be re-usable where possible
## Unit test style
- Prefer the use of `test.extend` over loose variables
- To achieve this, import `test as baseTest` from `vitest`
- Never use `it`; `test` should be used in place of this

14
.github/AGENTS.md vendored Normal file
View File

@@ -0,0 +1,14 @@
# PR Review Context
Context for automated PR review system.
## Review Scope
This automated review performs comprehensive analysis:
- Architecture and design patterns
- Security vulnerabilities
- Performance implications
- Code quality and maintainability
- Integration concerns
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.

39
.github/CLAUDE.md vendored
View File

@@ -1,36 +1,3 @@
# ComfyUI Frontend - Claude Review Context
This file provides additional context for the automated PR review system.
## Quick Reference
### PrimeVue Component Migrations
When reviewing, flag these deprecated components:
- `Dropdown` → Use `Select` from 'primevue/select'
- `OverlayPanel` → Use `Popover` from 'primevue/popover'
- `Calendar` → Use `DatePicker` from 'primevue/datepicker'
- `InputSwitch` → Use `ToggleSwitch` from 'primevue/toggleswitch'
- `Sidebar` → Use `Drawer` from 'primevue/drawer'
- `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
- `TabMenu` → Use `Tabs` without panels
- `Steps` → Use `Stepper` without panels
- `InlineMessage` → Use `Message` component
### API Utilities Reference
- `api.apiURL()` - Backend API calls (/prompt, /queue, /view, etc.)
- `api.fileURL()` - Static file access (templates, extensions)
- `$t()` / `i18n.global.t()` - Internationalization
- `DOMPurify.sanitize()` - HTML sanitization
## Review Scope
This automated review performs comprehensive analysis including:
- Architecture and design patterns
- Security vulnerabilities
- Performance implications
- Code quality and maintainability
- Integration concerns
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.
<!-- A rose by any other name would smell as sweet,
But Claude insists on files named for its own conceit. -->
@AGENTS.md

17
.storybook/AGENTS.md Normal file
View File

@@ -0,0 +1,17 @@
# Storybook Guidelines
See `@docs/guidance/storybook.md` for story patterns (auto-loaded for `*.stories.ts`).
## Available Context
Stories have access to:
- All ComfyUI stores
- PrimeVue with ComfyUI theming
- i18n system
- CSS variables and styling
## Troubleshooting
1. **Import Errors**: Verify `@/` alias works
2. **Missing Styles**: Check CSS imports in `preview.ts`
3. **Store Errors**: Check store initialization in setup

View File

@@ -1,197 +1,3 @@
# Storybook Development Guidelines for Claude
## Quick Commands
- `pnpm storybook`: Start Storybook development server
- `pnpm build-storybook`: Build static Storybook
- `pnpm test:unit`: Run unit tests (includes Storybook components)
## Development Workflow for Storybook
1. **Creating New Stories**:
- Place `*.stories.ts` files alongside components
- Follow the naming pattern: `ComponentName.stories.ts`
- Use realistic mock data that matches ComfyUI schemas
2. **Testing Stories**:
- Verify stories render correctly in Storybook UI
- Test different component states and edge cases
- Ensure proper theming and styling
3. **Code Quality**:
- Run `pnpm typecheck` to verify TypeScript
- Run `pnpm lint` to check for linting issues
- Follow existing story patterns and conventions
## Story Creation Guidelines
### Basic Story Structure
```typescript
import type { Meta, StoryObj } from '@storybook/vue3'
import ComponentName from './ComponentName.vue'
const meta: Meta<typeof ComponentName> = {
title: 'Category/ComponentName',
component: ComponentName,
parameters: {
layout: 'centered' // or 'fullscreen', 'padded'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
// Component props
}
}
```
### Mock Data Patterns
For ComfyUI components, use realistic mock data:
```typescript
// Node definition mock
const mockNodeDef = {
input: {
required: {
prompt: ["STRING", { multiline: true }]
}
},
output: ["CONDITIONING"],
output_is_list: [false],
category: "conditioning"
}
// Component instance mock
const mockComponent = {
id: "1",
type: "CLIPTextEncode",
// ... other properties
}
```
### Common Story Variants
Always include these story variants when applicable:
- **Default**: Basic component with minimal props
- **WithData**: Component with realistic data
- **Loading**: Component in loading state
- **Error**: Component with error state
- **LongContent**: Component with edge case content
- **Empty**: Component with no data
### Storybook-Specific Code Patterns
#### Store Access
```typescript
// In stories, access stores through the setup function
export const WithStore: Story = {
render: () => ({
setup() {
const store = useMyStore()
return { store }
},
template: '<MyComponent :data="store.data" />'
})
}
```
#### Event Testing
```typescript
export const WithEvents: Story = {
args: {
onUpdate: fn() // Use Storybook's fn() for action logging
}
}
```
## Configuration Notes
### Vue App Setup
The Storybook preview is configured with:
- Pinia stores initialized
- PrimeVue with ComfyUI theme
- i18n internationalization
- All necessary CSS imports
### Build Configuration
- Vite integration with proper alias resolution
- Manual chunking for better performance
- TypeScript support with strict checking
- CSS processing for Vue components
## Troubleshooting
### Common Issues
1. **Import Errors**: Verify `@/` alias is working correctly
2. **Missing Styles**: Ensure CSS imports are in `preview.ts`
3. **Store Errors**: Check store initialization in setup
4. **Type Errors**: Use proper TypeScript types for story args
### Debug Commands
```bash
# Check TypeScript issues
pnpm typecheck
# Lint Storybook files
pnpm lint .storybook/
# Build to check for production issues
pnpm build-storybook
```
## File Organization
```
.storybook/
├── main.ts # Core configuration
├── preview.ts # Global setup and decorators
├── README.md # User documentation
└── CLAUDE.md # This file - Claude guidelines
src/
├── components/
│ └── MyComponent/
│ ├── MyComponent.vue
│ └── MyComponent.stories.ts
```
## Integration with ComfyUI
### Available Context
Stories have access to:
- All ComfyUI stores (widgetStore, colorPaletteStore, etc.)
- PrimeVue components with ComfyUI theming
- Internationalization system
- ComfyUI CSS variables and styling
### Testing Components
When testing ComfyUI-specific components:
1. Use realistic node definitions and data structures
2. Test with different node types (sampling, conditioning, etc.)
3. Verify proper CSS theming and dark/light modes
4. Check component behavior with various input combinations
### Performance Considerations
- Use manual chunking for large dependencies
- Minimize bundle size by avoiding unnecessary imports
- Leverage Storybook's lazy loading capabilities
- Profile build times and optimize as needed
## Best Practices
1. **Keep Stories Focused**: Each story should demonstrate one specific use case
2. **Use Descriptive Names**: Story names should clearly indicate what they show
3. **Document Complex Props**: Use JSDoc comments for complex prop types
4. **Test Edge Cases**: Create stories for unusual but valid use cases
5. **Maintain Consistency**: Follow established patterns in existing stories
<!-- Though standards bloom in open fields so wide,
Anthropic walks a path of lonely pride. -->
@AGENTS.md

View File

@@ -96,15 +96,15 @@ const config: StorybookConfig = {
}
]
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main
minifyIdentifiers: false,
keepNames: true
},
build: {
rollupOptions: {
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
rolldownOptions: {
experimental: {
strictExecutionOrder: true
},
treeshake: false,
output: {
keepNames: true
},
onwarn: (warning, warn) => {
// Suppress specific warnings
if (

View File

@@ -1,5 +1,7 @@
# Repository Guidelines
See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob).
## Project Structure & Module Organization
- Source: `src/`
@@ -46,6 +48,21 @@ The project uses **Nx** for build orchestration and task management
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
- `pnpm format` / `pnpm format:check`: Prettier
- `pnpm typecheck`: Vue TSC type checking
- `pnpm storybook`: Start Storybook development server
## Development Workflow
1. Make code changes
2. Run relevant tests
3. Run `pnpm typecheck`, `pnpm lint`, `pnpm format`
4. Check if README updates are needed
5. Suggest docs.comfy.org updates for user-facing changes
## Git Conventions
- Use `prefix:` format: `feat:`, `fix:`, `test:`
- Add "Fixes #n" to PR descriptions
- Never mention Claude/AI in commits
## Coding Style & Naming Conventions

View File

@@ -1,30 +1 @@
# Claude Code specific instructions
@Agents.md
## Repository Setup
For first-time setup, use the Claude command:
```sh
/setup_repo
```
This bootstraps the monorepo with dependencies, builds, tests, and dev server verification.
**Prerequisites:** Node.js >= 24, Git repository, available ports for dev server, storybook, etc.
## Development Workflow
1. **First-time setup**: Run `/setup_repo` Claude command
2. Make code changes
3. Run tests (see subdirectory CLAUDE.md files)
4. Run typecheck, lint, format
5. Check README updates
6. Consider docs.comfy.org updates
## Git Conventions
- Use `prefix:` format: `feat:`, `fix:`, `test:`
- Add "Fixes #n" to PR descriptions
- Never mention Claude/AI in commits
@AGENTS.md

View File

@@ -64,7 +64,7 @@ export default defineConfig(() => {
})
],
build: {
minify: SHOULD_MINIFY ? ('esbuild' as const) : false,
minify: SHOULD_MINIFY,
target: 'es2022',
sourcemap: true
}

8
browser_tests/AGENTS.md Normal file
View File

@@ -0,0 +1,8 @@
# E2E Testing Guidelines
See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded for `*.spec.ts`).
## Directory Structure
- `assets/` - Test data (JSON workflows, fixtures)
- Tests use premade JSON workflows to load desired graph state

View File

@@ -1,17 +1,3 @@
# E2E Testing Guidelines
## Browser Tests
- Test user workflows
- Use Playwright fixtures
- Follow naming conventions
## Best Practices
- Check assets/ for test data
- Prefer specific selectors
- Test across viewports
## Testing Process
After code changes:
1. Create browser tests as appropriate
2. Run tests until passing
3. Then run typecheck, lint, format
<!-- In gardens where the agents freely play,
One stubborn flower turns the other way. -->
@AGENTS.md

View File

@@ -21,7 +21,6 @@ import {
import { Topbar } from './components/Topbar'
import type { Position, Size } from './types'
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
import TaskHistory from './utils/taskHistory'
dotenv.config()
@@ -146,8 +145,6 @@ class ConfirmDialog {
}
export class ComfyPage {
private _history: TaskHistory | null = null
public readonly url: string
// All canvas position operations are based on default view of canvas.
public readonly canvas: Locator
@@ -301,11 +298,6 @@ export class ComfyPage {
}
}
setupHistory(): TaskHistory {
this._history ??= new TaskHistory(this)
return this._history
}
async setup({
clearStorage = true,
mockReleases = true

View File

@@ -1,164 +0,0 @@
import type { Request, Route } from '@playwright/test'
import _ from 'es-toolkit/compat'
import fs from 'fs'
import path from 'path'
import { v4 as uuidv4 } from 'uuid'
import type {
HistoryTaskItem,
TaskItem,
TaskOutput
} from '../../../src/schemas/apiSchema'
import type { ComfyPage } from '../ComfyPage'
/** keyof TaskOutput[string] */
type OutputFileType = 'images' | 'audio' | 'animated'
const DEFAULT_IMAGE = 'example.webp'
const getFilenameParam = (request: Request) => {
const url = new URL(request.url())
return url.searchParams.get('filename') || DEFAULT_IMAGE
}
const getContentType = (filename: string, fileType: OutputFileType) => {
const subtype = path.extname(filename).slice(1)
switch (fileType) {
case 'images':
return `image/${subtype}`
case 'audio':
return `audio/${subtype}`
case 'animated':
return `video/${subtype}`
}
}
const setQueueIndex = (task: TaskItem) => {
task.prompt[0] = TaskHistory.queueIndex++
}
const setPromptId = (task: TaskItem) => {
task.prompt[1] = uuidv4()
}
export default class TaskHistory {
static queueIndex = 0
static readonly defaultTask: Readonly<HistoryTaskItem> = {
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []],
outputs: {},
status: {
status_str: 'success',
completed: true,
messages: []
},
taskType: 'History'
}
private tasks: HistoryTaskItem[] = []
private outputContentTypes: Map<string, string> = new Map()
constructor(readonly comfyPage: ComfyPage) {}
private loadAsset: (filename: string) => Buffer = _.memoize(
(filename: string) => {
const filePath = this.comfyPage.assetPath(filename)
return fs.readFileSync(filePath)
}
)
private async handleGetHistory(route: Route) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.tasks)
})
}
private async handleGetView(route: Route) {
const fileName = getFilenameParam(route.request())
if (!this.outputContentTypes.has(fileName)) {
return route.continue()
}
const asset = this.loadAsset(fileName)
return route.fulfill({
status: 200,
contentType: this.outputContentTypes.get(fileName),
body: asset,
headers: {
'Cache-Control': 'public, max-age=31536000',
'Content-Length': asset.byteLength.toString()
}
})
}
async setupRoutes() {
return this.comfyPage.page.route(
/.*\/api\/(view|history)(\?.*)?$/,
async (route) => {
const request = route.request()
const method = request.method()
const isViewReq = request.url().includes('view') && method === 'GET'
if (isViewReq) return this.handleGetView(route)
const isHistoryPath = request.url().includes('history')
const isGetHistoryReq = isHistoryPath && method === 'GET'
if (isGetHistoryReq) return this.handleGetHistory(route)
const isClearReq =
method === 'POST' &&
isHistoryPath &&
request.postDataJSON()?.clear === true
if (isClearReq) return this.clearTasks()
return route.continue()
}
)
}
private createOutputs(
filenames: string[],
filetype: OutputFileType
): TaskOutput {
return filenames.reduce((outputs, filename, i) => {
const nodeId = `${i + 1}`
outputs[nodeId] = {
[filetype]: [{ filename, subfolder: '', type: 'output' }]
}
const contentType = getContentType(filename, filetype)
this.outputContentTypes.set(filename, contentType)
return outputs
}, {})
}
private addTask(task: HistoryTaskItem) {
setPromptId(task)
setQueueIndex(task)
this.tasks.unshift(task) // Tasks are added to the front of the queue
}
clearTasks(): this {
this.tasks = []
return this
}
withTask(
outputFilenames: string[],
outputFiletype: OutputFileType = 'images',
overrides: Partial<HistoryTaskItem> = {}
): this {
this.addTask({
...TaskHistory.defaultTask,
outputs: this.createOutputs(outputFilenames, outputFiletype),
...overrides
})
return this
}
/** Repeats the last task in the task history a specified number of times. */
repeat(n: number): this {
for (let i = 0; i < n; i++)
this.addTask(structuredClone(this.tasks.at(0)) as HistoryTaskItem)
return this
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View File

@@ -0,0 +1,33 @@
---
globs:
- '**/*.spec.ts'
---
# Playwright E2E Test Conventions
See `docs/testing/*.md` for detailed patterns.
## Best Practices
- Follow [Playwright Best Practices](https://playwright.dev/docs/best-practices)
- Do NOT use `waitForTimeout` - use Locator actions and retrying assertions
- Prefer specific selectors (role, label, test-id)
- Test across viewports
## Test Tags
Tags are respected by config:
- `@mobile` - Mobile viewport tests
- `@2x` - High DPI tests
## Test Data
- Check `browser_tests/assets/` for test data and fixtures
- Use realistic ComfyUI workflows for E2E tests
## Running Tests
```bash
pnpm test:browser # Run all E2E tests
pnpm test:browser -- --ui # Interactive UI mode
```

View File

@@ -0,0 +1,55 @@
---
globs:
- '**/*.stories.ts'
---
# Storybook Conventions
## File Placement
Place `*.stories.ts` files alongside their components:
```
src/components/MyComponent/
├── MyComponent.vue
└── MyComponent.stories.ts
```
## Story Structure
```typescript
import type { Meta, StoryObj } from '@storybook/vue3'
import ComponentName from './ComponentName.vue'
const meta: Meta<typeof ComponentName> = {
title: 'Category/ComponentName',
component: ComponentName,
parameters: { layout: 'centered' }
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: { /* props */ }
}
```
## Required Story Variants
Include when applicable:
- **Default** - Minimal props
- **WithData** - Realistic data
- **Loading** - Loading state
- **Error** - Error state
- **Empty** - No data
## Mock Data
Use realistic ComfyUI schemas for mocks (node definitions, components).
## Running Storybook
```bash
pnpm storybook # Development server
pnpm build-storybook # Production build
```

View File

@@ -0,0 +1,37 @@
---
globs:
- '**/*.ts'
- '**/*.tsx'
- '**/*.vue'
---
# TypeScript Conventions
## Type Safety
- Never use `any` type - use proper TypeScript types
- Never use `as any` type assertions - fix the underlying type issue
- Type assertions are a last resort; they lead to brittle code
- Avoid `@ts-expect-error` - fix the underlying issue instead
## Utility Libraries
- Use `es-toolkit` for utility functions (not lodash)
## API Utilities
When making API calls in `src/`:
```typescript
// ✅ Correct - use api helpers
const response = await api.get(api.apiURL('/prompt'))
const template = await fetch(api.fileURL('/templates/default.json'))
// ❌ Wrong - direct URL construction
const response = await fetch('/api/prompt')
```
## Security
- Sanitize HTML with `DOMPurify.sanitize()`
- Never log secrets or sensitive data

36
docs/guidance/vitest.md Normal file
View File

@@ -0,0 +1,36 @@
---
globs:
- '**/*.test.ts'
---
# Vitest Unit Test Conventions
See `docs/testing/*.md` for detailed patterns.
## Test Quality
- Do not write change detector tests (tests that just assert defaults)
- Do not write tests dependent on non-behavioral features (styles, classes)
- Do not write tests that just test mocks - ensure real code is exercised
- Be parsimonious; avoid redundant tests
## Mocking
- Use Vitest's mocking utilities (`vi.mock`, `vi.spyOn`)
- Keep module mocks contained - no global mutable state
- Use `vi.hoisted()` for per-test mock manipulation
- Don't mock what you don't own
## Component Testing
- Use Vue Test Utils for component tests
- Follow advice about making components easy to test
- Wait for reactivity with `await nextTick()` after state changes
## Running Tests
```bash
pnpm test:unit # Run all unit tests
pnpm test:unit -- path/to/file # Run specific test
pnpm test:unit -- --watch # Watch mode
```

View File

@@ -0,0 +1,46 @@
---
globs:
- '**/*.vue'
---
# Vue Component Conventions
Applies to all `.vue` files anywhere in the codebase.
## Vue 3 Composition API
- Use `<script setup lang="ts">` for component logic
- Destructure props (Vue 3.5 style with defaults) like `const { color = 'blue' } = defineProps<...>()`
- Use `ref`/`reactive` for state
- Use `computed()` for derived state
- Use lifecycle hooks: `onMounted`, `onUpdated`, etc.
## Component Communication
- Prefer `emit/@event-name` for state changes (promotes loose coupling)
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
- Proper props and emits definitions
## VueUse Composables
Prefer VueUse composables over manual event handling:
- `useElementHover` instead of manual mouseover/mouseout listeners
- `useIntersectionObserver` for visibility detection instead of scroll handlers
- `useFocusTrap` for modal/dialog focus management
- `useEventListener` for auto-cleanup event listeners
Prefer Vue native options when available:
- `defineModel` instead of `useVModel` for two-way binding with props
## Styling
- Use inline Tailwind CSS only (no `<style>` blocks)
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
## Best Practices
- Extract complex conditionals to `computed`
- In unmounted hooks, implement cleanup for async operations

View File

@@ -1,6 +1,8 @@
import path from 'node:path'
export default {
'tests-ui/**': () => 'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
'./**/*.js': (stagedFiles) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.38.2",
"version": "1.38.6",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -192,5 +192,10 @@
"yjs": "catalog:",
"zod": "catalog:",
"zod-validation-error": "catalog:"
},
"pnpm": {
"overrides": {
"vite": "^8.0.0-beta.8"
}
}
}

View File

@@ -282,7 +282,7 @@
--modal-card-border-highlighted: var(--secondary-background-selected);
--modal-card-button-surface: var(--color-smoke-300);
--modal-card-placeholder-background: var(--color-smoke-600);
--modal-card-tag-background: var(--color-smoke-400);
--modal-card-tag-background: var(--color-smoke-200);
--modal-card-tag-foreground: var(--base-foreground);
--modal-panel-background: var(--color-white);
}

544
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -93,7 +93,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vite: ^7.3.0
vite: ^8.0.0-beta.8
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0

26
src/AGENTS.md Normal file
View File

@@ -0,0 +1,26 @@
# Source Code Guidelines
## Error Handling
- User-friendly and actionable messages
- Proper error propagation
## Security
- Sanitize HTML with DOMPurify
- Validate trusted sources
- Never log secrets
## State Management (Stores)
- Follow domain-driven design for organizing files/folders
- Clear public interfaces
- Restrict extension access
- Clean up subscriptions
## General Guidelines
- Use `es-toolkit` for utility functions
- Use TypeScript for type safety
- Avoid `@ts-expect-error` - fix the underlying issue
- Use `vue-i18n` for ALL user-facing strings (`src/locales/en/main.json`)

View File

@@ -1,57 +1,3 @@
# Source Code Guidelines
## Service Layer
### API Calls
- Use `api.apiURL()` for backend endpoints
- Use `api.fileURL()` for static files
#### ✅ Correct Usage
```typescript
// Backend API call
const response = await api.get(api.apiURL('/prompt'))
// Static file
const template = await fetch(api.fileURL('/templates/default.json'))
```
#### ❌ Incorrect Usage
```typescript
// WRONG - Direct URL construction
const response = await fetch('/api/prompt')
const template = await fetch('/templates/default.json')
```
### Error Handling
- User-friendly and actionable messages
- Proper error propagation
### Security
- Sanitize HTML with DOMPurify
- Validate trusted sources
- Never log secrets
## State Management (Stores)
### Store Design
- Follow domain-driven design
- Clear public interfaces
- Restrict extension access
### Best Practices
- Use TypeScript for type safety
- Implement proper error handling
- Clean up subscriptions
- Avoid @ts-expect-error
## General Guidelines
- Use es-toolkit for utility functions
- Implement proper TypeScript types
- Follow Vue 3 composition API style guide
- Use vue-i18n for ALL user-facing strings in `src/locales/en/main.json`
<!-- We forked the path, yet here we are again—
Maintaining two files where one would have been sane. -->
@AGENTS.md

6
src/components/AGENTS.md Normal file
View File

@@ -0,0 +1,6 @@
# Component Guidelines
## Component Communication
- Prefer `emit/@event-name` for state changes
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)

View File

@@ -1,45 +1,3 @@
# Component Guidelines
## Vue 3 Composition API
- Use setup() function
- Destructure props (Vue 3.5 style)
- Use ref/reactive for state
- Implement computed() for derived state
- Use provide/inject for dependency injection
## Component Communication
- Prefer `emit/@event-name` for state changes
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
- Events promote loose coupling
## UI Framework
- Deprecated PrimeVue component replacements:
- Dropdown → Select
- OverlayPanel → Popover
- Calendar → DatePicker
- InputSwitch → ToggleSwitch
- Sidebar → Drawer
- Chips → AutoComplete with multiple enabled
- TabMenu → Tabs without panels
- Steps → Stepper without panels
- InlineMessage → Message
## Styling
- Use Tailwind CSS only (no custom CSS)
- Use the correct tokens from style.css in the design system package
- For common operations, try to use existing VueUse composables that automatically handle effect scope
- Example: Use `useElementHover` instead of manually managing mouseover/mouseout event listeners
- Example: Use `useIntersectionObserver` for visibility detection instead of custom scroll handlers
## Best Practices
- Extract complex conditionals to computed
- Implement cleanup for async operations
- Use vue-i18n for ALL UI strings
- Use lifecycle hooks: onMounted, onUpdated
- Use Teleport/Suspense when needed
- Proper props and emits definitions
<!-- "Play nice with others," mother always said,
But Claude prefers its own file name instead. -->
@AGENTS.md

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex h-full items-center">
<div class="flex h-full items-center" :class="cn(!isDocked && '-ml-2')">
<div
v-if="isDragging && !isDocked"
:class="actionbarClass"
@@ -77,7 +77,6 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const tabContainer = document.querySelector('.workflow-tabs-container')
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
@@ -88,14 +87,7 @@ const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
const { x, y, style, isDragging } = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body,
onMove: (event) => {
// Prevent dragging the menu over the top of the tabs
const minY = tabContainer?.getBoundingClientRect().bottom ?? 40
if (event.y < minY) {
event.y = minY
}
}
containerElement: document.body
})
// Update storedPosition when x or y changes

View File

@@ -0,0 +1,82 @@
<template>
<div class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<input
v-model.number="x"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<input
v-model.number="y"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<input
v-model.number="width"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<input
v-model.number="height"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Bounds } from '@/renderer/core/layout/types'
const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})
const x = computed({
get: () => modelValue.value.x,
set: (x) => {
modelValue.value = { ...modelValue.value, x }
}
})
const y = computed({
get: () => modelValue.value.y,
set: (y) => {
modelValue.value = { ...modelValue.value, y }
}
})
const width = computed({
get: () => modelValue.value.width,
set: (width) => {
modelValue.value = { ...modelValue.value, width }
}
})
const height = computed({
get: () => modelValue.value.height,
set: (height) => {
modelValue.value = { ...modelValue.value, height }
}
})
</script>

View File

@@ -55,17 +55,4 @@ const dialogStore = useDialogStore()
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;
max-height: 1026px;
}
@media (min-width: 3000px) {
.manager-dialog {
max-width: 2200px;
max-height: 1320px;
}
}
</style>

View File

@@ -10,7 +10,7 @@
class="selection-toolbox pointer-events-auto rounded-lg border border-interface-stroke bg-interface-panel-surface"
:pt="{
header: 'hidden',
content: 'p-2 h-12 flex flex-row gap-1'
content: 'p-1 h-10 flex flex-row gap-1'
}"
@wheel="canvasInteractions.forwardEventToCanvas"
>

View File

@@ -6,67 +6,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
// NOTE: The component import must come after mocks so they take effect.
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import Button from '@/components/ui/button/Button.vue'
const mockLGraphNode = {
type: 'TestNode',
title: 'Test Node'
}
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => true)
const { openPanelMock } = vi.hoisted(() => ({
openPanelMock: vi.fn()
}))
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: () => ({
id: 'node-library'
})
}))
const openHelpMock = vi.fn()
const closeHelpMock = vi.fn()
const nodeHelpState: { currentHelpNode: any } = { currentHelpNode: null }
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
useNodeHelpStore: () => ({
openHelp: (def: any) => {
nodeHelpState.currentHelpNode = def
openHelpMock(def)
},
closeHelp: () => {
nodeHelpState.currentHelpNode = null
closeHelpMock()
},
get currentHelpNode() {
return nodeHelpState.currentHelpNode
},
get isHelpOpen() {
return nodeHelpState.currentHelpNode !== null
}
})
}))
const toggleSidebarTabMock = vi.fn((id: string) => {
sidebarState.activeSidebarTabId =
sidebarState.activeSidebarTabId === id ? null : id
})
const sidebarState: { activeSidebarTabId: string | null } = {
activeSidebarTabId: 'other-tab'
}
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => ({
get activeSidebarTabId() {
return sidebarState.activeSidebarTabId
},
toggleSidebarTab: toggleSidebarTabMock
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({
openPanel: openPanelMock
})
}))
describe('InfoButton', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let nodeDefStore: ReturnType<typeof useNodeDefStore>
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -81,9 +33,6 @@ describe('InfoButton', () => {
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
nodeDefStore = useNodeDefStore()
vi.clearAllMocks()
})
@@ -92,58 +41,15 @@ describe('InfoButton', () => {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
stubs: {
'i-lucide:info': true,
Button: {
template:
'<button class="help-button" severity="secondary"><slot /></button>',
props: ['severity', 'text', 'class'],
emits: ['click']
}
}
components: { Button }
}
})
}
it('should handle click without errors', async () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
it('should open the info panel on click', async () => {
const wrapper = mountComponent()
const button = wrapper.find('button')
const button = wrapper.find('[data-testid="info-button"]')
await button.trigger('click')
expect(button.exists()).toBe(true)
})
it('should have correct CSS classes', () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.classes()).toContain('help-button')
expect(button.attributes('severity')).toBe('secondary')
})
it('should have correct tooltip', () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
expect(openPanelMock).toHaveBeenCalledWith('info')
})
})

View File

@@ -0,0 +1,100 @@
<template>
<div
class="widget-expands relative flex h-full w-full flex-col gap-1"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<!-- Image preview container -->
<div
ref="containerEl"
class="relative min-h-0 flex-1 overflow-hidden rounded-[5px] bg-node-component-surface"
>
<div v-if="isLoading" class="flex size-full items-center justify-center">
<span class="text-sm">{{ $t('imageCrop.loading') }}</span>
</div>
<div
v-else-if="!imageUrl"
class="flex size-full flex-col items-center justify-center text-center"
>
<i class="mb-2 icon-[lucide--image] h-12 w-12" />
<p class="text-sm">{{ $t('imageCrop.noInputImage') }}</p>
</div>
<img
v-else
ref="imageEl"
:src="imageUrl"
:alt="$t('imageCrop.cropPreviewAlt')"
draggable="false"
class="block size-full object-contain select-none brightness-50"
@load="handleImageLoad"
@error="handleImageError"
@dragstart.prevent
/>
<div
v-if="imageUrl && !isLoading"
class="absolute box-content cursor-move overflow-hidden border-2 border-white"
:style="cropBoxStyle"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"
>
<div class="pointer-events-none size-full" :style="cropImageStyle" />
</div>
<div
v-for="handle in resizeHandles"
v-show="imageUrl && !isLoading"
:key="handle.direction"
:class="['absolute', handle.class]"
:style="handle.style"
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
/>
</div>
<WidgetBoundingBox v-model="modelValue" class="shrink-0" />
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue'
import { useImageCrop } from '@/composables/useImageCrop'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Bounds } from '@/renderer/core/layout/types'
const props = defineProps<{
nodeId: NodeId
}>()
const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')
const {
imageUrl,
isLoading,
cropBoxStyle,
cropImageStyle,
resizeHandles,
handleImageLoad,
handleImageError,
handleDragStart,
handleDragMove,
handleDragEnd,
handleResizeStart,
handleResizeMove,
handleResizeEnd
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
</script>

View File

@@ -70,17 +70,17 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import { computed } from 'vue'
import { useNodeHelpContent } from '@/composables/useNodeHelpContent'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
const { node } = defineProps<{
node: ComfyNodeDefImpl
}>()
const nodeHelpStore = useNodeHelpStore()
const { renderedHelpHtml, isLoading, error } = storeToRefs(nodeHelpStore)
const { renderedHelpHtml, isLoading, error } = useNodeHelpContent(() => node)
const inputList = computed(() =>
Object.values(node.inputs).map((spec) => ({

View File

@@ -262,7 +262,7 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
openResultGallery(item)
await openResultGallery(item)
await focusAssetInSidebar(item)
}
)

View File

@@ -1,6 +1,9 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { TaskStatus } from '@/schemas/apiSchema'
import type {
JobListItem,
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
@@ -37,91 +40,86 @@ function resetStores() {
exec.nodeProgressStatesByPrompt = {}
}
function makeTask(
id: string,
priority: number,
fields: Partial<JobListItem> & { status: JobStatus; create_time: number }
): TaskItemImpl {
const job: JobListItem = {
id,
priority,
last_state_update: null,
update_time: fields.create_time,
...fields
}
return new TaskItemImpl(job)
}
function makePendingTask(
id: string,
index: number,
createTimeMs?: number
priority: number,
createTimeMs: number
): TaskItemImpl {
const extraData = {
client_id: 'c1',
...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {})
}
return new TaskItemImpl('Pending', [index, id, {}, extraData, []])
return makeTask(id, priority, {
status: 'pending',
create_time: createTimeMs
})
}
function makeRunningTask(
id: string,
index: number,
createTimeMs?: number
priority: number,
createTimeMs: number
): TaskItemImpl {
const extraData = {
client_id: 'c1',
...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {})
}
return new TaskItemImpl('Running', [index, id, {}, extraData, []])
return makeTask(id, priority, {
status: 'in_progress',
create_time: createTimeMs
})
}
function makeRunningTaskWithStart(
id: string,
index: number,
priority: number,
startedSecondsAgo: number
): TaskItemImpl {
const start = Date.now() - startedSecondsAgo * 1000
const status: TaskStatus = {
status_str: 'success',
completed: false,
messages: [['execution_start', { prompt_id: id, timestamp: start } as any]]
}
return new TaskItemImpl(
'Running',
[index, id, {}, { client_id: 'c1', create_time: start - 5000 }, []],
status
)
return makeTask(id, priority, {
status: 'in_progress',
create_time: start - 5000,
update_time: start
})
}
function makeHistoryTask(
id: string,
index: number,
priority: number,
durationSec: number,
ok: boolean,
errorMessage?: string
): TaskItemImpl {
const start = Date.now() - durationSec * 1000 - 1000
const end = start + durationSec * 1000
const messages: TaskStatus['messages'] = ok
? [
['execution_start', { prompt_id: id, timestamp: start } as any],
['execution_success', { prompt_id: id, timestamp: end } as any]
]
: [
['execution_start', { prompt_id: id, timestamp: start } as any],
[
'execution_error',
{
prompt_id: id,
timestamp: end,
node_id: '1',
node_type: 'Node',
executed: [],
exception_message:
errorMessage || 'Demo error: Node failed during execution',
exception_type: 'RuntimeError',
traceback: [],
current_inputs: {},
current_outputs: {}
} as any
]
]
const status: TaskStatus = {
status_str: ok ? 'success' : 'error',
completed: true,
messages
}
return new TaskItemImpl(
'History',
[index, id, {}, { client_id: 'c1', create_time: start }, []],
status
)
const now = Date.now()
const executionEndTime = now
const executionStartTime = now - durationSec * 1000
return makeTask(id, priority, {
status: ok ? 'completed' : 'failed',
create_time: executionStartTime - 5000,
update_time: now,
execution_start_time: executionStartTime,
execution_end_time: executionEndTime,
execution_error: errorMessage
? {
prompt_id: id,
timestamp: now,
node_id: '1',
node_type: 'ExampleNode',
exception_message: errorMessage,
exception_type: 'RuntimeError',
traceback: [],
current_inputs: {},
current_outputs: {}
}
: undefined
})
}
export const Queued: Story = {
@@ -140,8 +138,12 @@ export const Queued: Story = {
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
]
// Add some other pending jobs to give context
queue.pendingTasks.push(makePendingTask('job-older-1', 100))
queue.pendingTasks.push(makePendingTask('job-older-2', 101))
queue.pendingTasks.push(
makePendingTask('job-older-1', 100, Date.now() - 60_000)
)
queue.pendingTasks.push(
makePendingTask('job-older-2', 101, Date.now() - 30_000)
)
// Queued at (in metadata on prompt[4])

View File

@@ -4,6 +4,7 @@ import { defineComponent, nextTick } from 'vue'
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { TaskItemImpl } from '@/stores/queueStore'
const QueueJobItemStub = defineComponent({
name: 'QueueJobItemStub',
@@ -25,20 +26,25 @@ const QueueJobItemStub = defineComponent({
template: '<div class="queue-job-item-stub"></div>'
})
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-id',
title: 'Example job',
meta: 'Meta text',
state: 'running',
iconName: 'icon',
iconImageUrl: 'https://example.com/icon.png',
showClear: true,
taskRef: { workflow: { id: 'workflow-id' } },
progressTotalPercent: 60,
progressCurrentPercent: 30,
runningNodeName: 'Node A',
...overrides
})
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
const { taskRef, ...rest } = overrides
return {
id: 'job-id',
title: 'Example job',
meta: 'Meta text',
state: 'running',
iconName: 'icon',
iconImageUrl: 'https://example.com/icon.png',
showClear: true,
taskRef: (taskRef ?? {
workflow: { id: 'workflow-id' }
}) as TaskItemImpl,
progressTotalPercent: 60,
progressCurrentPercent: 30,
runningNodeName: 'Node A',
...rest
}
}
const mountComponent = (groups: JobGroup[]) =>
mount(JobGroupsList, {

View File

@@ -12,7 +12,7 @@
v-for="ji in group.items"
:key="ji.id"
:job-id="ji.id"
:workflow-id="ji.taskRef?.workflow?.id"
:workflow-id="ji.taskRef?.workflowId"
:state="ji.state"
:title="ji.title"
:right-text="ji.meta"

View File

@@ -2,116 +2,49 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import type { ComputedRef } from 'vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { TaskItemImpl } from '@/stores/queueStore'
import type {
JobErrorDialogService,
UseJobErrorReportingOptions
} from '@/components/queue/job/useJobErrorReporting'
import * as jobErrorReporting from '@/components/queue/job/useJobErrorReporting'
import type { JobErrorDialogService } from '@/components/queue/job/useJobErrorReporting'
import { useJobErrorReporting } from '@/components/queue/job/useJobErrorReporting'
import type { ExecutionError } from '@/platform/remote/comfyui/jobs/jobTypes'
const createExecutionErrorMessage = (
overrides: Partial<ExecutionErrorWsMessage> = {}
): ExecutionErrorWsMessage => ({
prompt_id: 'prompt',
timestamp: 100,
node_id: 'node-1',
node_type: 'KSampler',
executed: [],
exception_message: 'default failure',
exception_type: 'RuntimeError',
traceback: ['Trace line'],
current_inputs: {},
current_outputs: {},
...overrides
})
const createTaskWithMessages = (
messages: Array<[string, unknown]> | undefined = []
const createTaskWithError = (
promptId: string,
errorMessage?: string,
executionError?: ExecutionError,
createTime?: number
): TaskItemImpl =>
({
status: {
status_str: 'error',
completed: false,
messages
}
}) as TaskItemImpl
describe('extractExecutionError', () => {
it('returns null when task has no execution error messages', () => {
expect(jobErrorReporting.extractExecutionError(null)).toBeNull()
expect(
jobErrorReporting.extractExecutionError({
status: undefined
} as TaskItemImpl)
).toBeNull()
expect(
jobErrorReporting.extractExecutionError({
status: {
status_str: 'error',
completed: false,
messages: {} as unknown as Array<[string, unknown]>
}
} as TaskItemImpl)
).toBeNull()
expect(
jobErrorReporting.extractExecutionError(createTaskWithMessages([]))
).toBeNull()
expect(
jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_start', { prompt_id: 'prompt', timestamp: 1 }]
] as Array<[string, unknown]>)
)
).toBeNull()
})
it('returns detail and message for execution_error entries', () => {
const detail = createExecutionErrorMessage({ exception_message: 'Kaboom' })
const result = jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_success', { prompt_id: 'prompt', timestamp: 2 }],
['execution_error', detail]
] as Array<[string, unknown]>)
)
expect(result).toEqual({
detail,
message: 'Kaboom'
})
})
it('falls back to an empty message when the tuple lacks detail', () => {
const result = jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_error'] as unknown as [string, ExecutionErrorWsMessage]
])
)
expect(result).toEqual({ detail: undefined, message: '' })
})
})
promptId,
errorMessage,
executionError,
createTime: createTime ?? Date.now()
}) as Partial<TaskItemImpl> as TaskItemImpl
describe('useJobErrorReporting', () => {
let taskState = ref<TaskItemImpl | null>(null)
let taskForJob: ComputedRef<TaskItemImpl | null>
let copyToClipboard: UseJobErrorReportingOptions['copyToClipboard']
let showExecutionErrorDialog: JobErrorDialogService['showExecutionErrorDialog']
let showErrorDialog: JobErrorDialogService['showErrorDialog']
let copyToClipboard: ReturnType<typeof vi.fn>
let showErrorDialog: ReturnType<typeof vi.fn>
let showExecutionErrorDialog: ReturnType<typeof vi.fn>
let dialog: JobErrorDialogService
let composable: ReturnType<typeof jobErrorReporting.useJobErrorReporting>
let composable: ReturnType<typeof useJobErrorReporting>
beforeEach(() => {
vi.clearAllMocks()
taskState = ref<TaskItemImpl | null>(null)
taskForJob = computed(() => taskState.value)
copyToClipboard = vi.fn()
showExecutionErrorDialog = vi.fn()
showErrorDialog = vi.fn()
showExecutionErrorDialog = vi.fn()
dialog = {
showExecutionErrorDialog,
showErrorDialog
}
composable = jobErrorReporting.useJobErrorReporting({
showErrorDialog,
showExecutionErrorDialog
} as unknown as JobErrorDialogService
composable = useJobErrorReporting({
taskForJob,
copyToClipboard,
copyToClipboard: copyToClipboard as (
value: string
) => void | Promise<void>,
dialog
})
})
@@ -121,73 +54,87 @@ describe('useJobErrorReporting', () => {
})
it('exposes a computed message that reflects the current task error', () => {
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'First failure' })
]
])
taskState.value = createTaskWithError('job-1', 'First failure')
expect(composable.errorMessageValue.value).toBe('First failure')
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'Second failure' })
]
])
taskState.value = createTaskWithError('job-2', 'Second failure')
expect(composable.errorMessageValue.value).toBe('Second failure')
})
it('returns empty string when no error message', () => {
taskState.value = createTaskWithError('job-1')
expect(composable.errorMessageValue.value).toBe('')
})
it('returns empty string when task is null', () => {
taskState.value = null
expect(composable.errorMessageValue.value).toBe('')
})
it('only calls the copy handler when a message exists', () => {
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'Clipboard failure' })
]
])
taskState.value = createTaskWithError('job-1', 'Clipboard failure')
composable.copyErrorMessage()
expect(copyToClipboard).toHaveBeenCalledTimes(1)
expect(copyToClipboard).toHaveBeenCalledWith('Clipboard failure')
vi.mocked(copyToClipboard).mockClear()
taskState.value = createTaskWithMessages([])
copyToClipboard.mockClear()
taskState.value = createTaskWithError('job-2')
composable.copyErrorMessage()
expect(copyToClipboard).not.toHaveBeenCalled()
})
it('prefers the detailed execution dialog when detail is available', () => {
const detail = createExecutionErrorMessage({
exception_message: 'Detailed failure'
})
taskState.value = createTaskWithMessages([['execution_error', detail]])
it('shows simple error dialog when only errorMessage present', () => {
taskState.value = createTaskWithError('job-1', 'Queue job error')
composable.reportJobError()
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
expect(showExecutionErrorDialog).toHaveBeenCalledWith(detail)
expect(showErrorDialog).not.toHaveBeenCalled()
})
it('shows a fallback dialog when only a message is available', () => {
const message = 'Queue job error'
taskState.value = createTaskWithMessages([])
const valueSpy = vi
.spyOn(composable.errorMessageValue, 'value', 'get')
.mockReturnValue(message)
expect(composable.errorMessageValue.value).toBe(message)
composable.reportJobError()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
expect(showErrorDialog).toHaveBeenCalledTimes(1)
const [errorArg, optionsArg] = vi.mocked(showErrorDialog).mock.calls[0]
const [errorArg, optionsArg] = showErrorDialog.mock.calls[0]
expect(errorArg).toBeInstanceOf(Error)
expect(errorArg.message).toBe(message)
expect(errorArg.message).toBe('Queue job error')
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
valueSpy.mockRestore()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
})
it('does nothing when no error could be extracted', () => {
taskState.value = createTaskWithMessages([])
it('does nothing when no task exists', () => {
taskState.value = null
composable.reportJobError()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
expect(showErrorDialog).not.toHaveBeenCalled()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
})
it('shows rich error dialog when execution_error available on task', () => {
const executionError: ExecutionError = {
prompt_id: 'job-1',
timestamp: 12345,
node_id: '5',
node_type: 'KSampler',
executed: ['1', '2'],
exception_message: 'CUDA out of memory',
exception_type: 'RuntimeError',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
taskState.value = createTaskWithError(
'job-1',
'CUDA out of memory',
executionError,
12345
)
composable.reportJobError()
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
expect(showExecutionErrorDialog).toHaveBeenCalledWith(executionError)
expect(showErrorDialog).not.toHaveBeenCalled()
})
it('does nothing when no error message and no execution_error', () => {
taskState.value = createTaskWithError('job-1')
composable.reportJobError()
expect(showErrorDialog).not.toHaveBeenCalled()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
})
})

View File

@@ -1,13 +1,13 @@
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { ExecutionErrorDialogInput } from '@/services/dialogService'
import type { TaskItemImpl } from '@/stores/queueStore'
type CopyHandler = (value: string) => void | Promise<void>
export type JobErrorDialogService = {
showExecutionErrorDialog: (error: ExecutionErrorWsMessage) => void
showExecutionErrorDialog: (executionError: ExecutionErrorDialogInput) => void
showErrorDialog: (
error: Error,
options?: {
@@ -17,30 +17,7 @@ export type JobErrorDialogService = {
) => void
}
type JobExecutionError = {
detail?: ExecutionErrorWsMessage
message: string
}
export const extractExecutionError = (
task: TaskItemImpl | null
): JobExecutionError | null => {
const status = (task as TaskItemImpl | null)?.status
const messages = (status as { messages?: unknown[] } | undefined)?.messages
if (!Array.isArray(messages) || !messages.length) return null
const record = messages.find((entry: unknown) => {
return Array.isArray(entry) && entry[0] === 'execution_error'
}) as [string, ExecutionErrorWsMessage?] | undefined
if (!record) return null
const detail = record[1]
const message = String(detail?.exception_message ?? '')
return {
detail,
message
}
}
export type UseJobErrorReportingOptions = {
type UseJobErrorReportingOptions = {
taskForJob: ComputedRef<TaskItemImpl | null>
copyToClipboard: CopyHandler
dialog: JobErrorDialogService
@@ -51,10 +28,7 @@ export const useJobErrorReporting = ({
copyToClipboard,
dialog
}: UseJobErrorReportingOptions) => {
const errorMessageValue = computed(() => {
const error = extractExecutionError(taskForJob.value)
return error?.message ?? ''
})
const errorMessageValue = computed(() => taskForJob.value?.errorMessage ?? '')
const copyErrorMessage = () => {
if (errorMessageValue.value) {
@@ -63,11 +37,12 @@ export const useJobErrorReporting = ({
}
const reportJobError = () => {
const error = extractExecutionError(taskForJob.value)
if (error?.detail) {
dialog.showExecutionErrorDialog(error.detail)
const executionError = taskForJob.value?.executionError
if (executionError) {
dialog.showExecutionErrorDialog(executionError)
return
}
if (errorMessageValue.value) {
dialog.showErrorDialog(new Error(errorMessageValue.value), {
reportType: 'queueJobError'

View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const { nodes } = defineProps<{
nodes: LGraphNode[]
@@ -13,20 +11,10 @@ const { nodes } = defineProps<{
const node = computed(() => nodes[0])
const nodeDefStore = useNodeDefStore()
const nodeHelpStore = useNodeHelpStore()
const nodeInfo = computed(() => {
return nodeDefStore.fromLGraphNode(node.value)
})
// Open node help when the selected node changes
whenever(
nodeInfo,
(info) => {
nodeHelpStore.openHelp(info)
},
{ immediate: true }
)
</script>
<template>

View File

@@ -16,8 +16,8 @@ import {
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { renameWidget } from '@/utils/widgetUtil'
import { renameWidget } from '../shared'
import WidgetActions from './WidgetActions.vue'
const {

View File

@@ -1,11 +1,9 @@
import type { InjectionKey, MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
@@ -205,67 +203,3 @@ function repeatItems<T>(items: T[]): T[] {
}
return result
}
/**
* Renames a widget and its corresponding input.
* Handles both regular widgets and proxy widgets in subgraphs.
*
* @param widget The widget to rename
* @param node The node containing the widget
* @param newLabel The new label for the widget (empty string or undefined to clear)
* @param parents Optional array of parent SubgraphNodes (for proxy widgets)
* @returns true if the rename was successful, false otherwise
*/
export function renameWidget(
widget: IBaseWidget,
node: LGraphNode,
newLabel: string,
parents?: SubgraphNode[]
): boolean {
// For proxy widgets in subgraphs, we need to rename the original interior widget
if (isProxyWidget(widget) && parents?.length) {
const subgraph = parents[0].subgraph
if (!subgraph) {
console.error('Could not find subgraph for proxy widget')
return false
}
const interiorNode = subgraph.getNodeById(parseInt(widget._overlay.nodeId))
if (!interiorNode) {
console.error('Could not find interior node for proxy widget')
return false
}
const originalWidget = interiorNode.widgets?.find(
(w) => w.name === widget._overlay.widgetName
)
if (!originalWidget) {
console.error('Could not find original widget for proxy widget')
return false
}
// Rename the original widget
originalWidget.label = newLabel || undefined
// Also rename the corresponding input on the interior node
const interiorInput = interiorNode.inputs?.find(
(inp) => inp.widget?.name === widget._overlay.widgetName
)
if (interiorInput) {
interiorInput.label = newLabel || undefined
}
}
// Always rename the widget on the current node (either regular widget or proxy widget)
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
// Intentionally mutate the widget object here as it's a reference
// to the actual widget in the graph
widget.label = newLabel || undefined
if (input) {
input.label = newLabel || undefined
}
return true
}

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
computed,
@@ -7,7 +6,8 @@ import {
onBeforeUnmount,
onMounted,
ref,
triggerRef
triggerRef,
watch
} from 'vue'
import Button from '@/components/ui/button/Button.vue'
@@ -225,13 +225,10 @@ function setDraggableState() {
activeWidgets.value = aw
}
}
watchDebounced(
filteredActive,
() => {
setDraggableState()
},
{ debounce: 100 }
)
watch(filteredActive, () => {
setDraggableState()
})
onMounted(() => {
setDraggableState()
if (activeNode.value) pruneDisconnected(activeNode.value)

View File

@@ -44,7 +44,9 @@
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
<ModeToggle v-if="showLinearToggle" />
<ModeToggle
v-if="menuItemStore.hasSeenLinear || flags.linearToggleEnabled"
/>
</div>
</div>
<HelpCenterPopups :is-small="isSmall" />
@@ -52,7 +54,7 @@
</template>
<script setup lang="ts">
import { useResizeObserver, whenever } from '@vueuse/core'
import { useResizeObserver } from '@vueuse/core'
import { debounce } from 'es-toolkit/compat'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
@@ -68,6 +70,7 @@ import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
@@ -83,15 +86,11 @@ const settingStore = useSettingStore()
const userStore = useUserStore()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const menuItemStore = useMenuItemStore()
const sideToolbarRef = ref<HTMLElement>()
const topToolbarRef = ref<HTMLElement>()
const bottomToolbarRef = ref<HTMLElement>()
const showLinearToggle = ref(useFeatureFlags().flags.linearToggleEnabled)
whenever(
() => canvasStore.linearMode,
() => (showLinearToggle.value = true)
)
const { flags } = useFeatureFlags()
const isSmall = computed(
() => settingStore.get('Comfy.Sidebar.Size') === 'small'

View File

@@ -240,12 +240,19 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { getJobDetail } from '@/services/jobOutputCache'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
interface JobOutputItem {
filename: string
subfolder: string
type: string
}
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
@@ -492,6 +499,35 @@ function handleContextMenuHide() {
})
}
const handleBulkDownload = (assets: AssetItem[]) => {
downloadMultipleAssets(assets)
clearSelection()
}
const handleBulkDelete = async (assets: AssetItem[]) => {
await deleteMultipleAssets(assets)
clearSelection()
}
const handleClearQueue = async () => {
await commandStore.execute('Comfy.ClearPendingTasks')
}
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
await addMultipleToWorkflow(assets)
clearSelection()
}
const handleBulkOpenWorkflow = async (assets: AssetItem[]) => {
await openMultipleWorkflows(assets)
clearSelection()
}
const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
await exportMultipleWorkflows(assets)
clearSelection()
}
const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name)
@@ -519,16 +555,16 @@ const handleZoomClick = (asset: AssetItem) => {
}
}
const enterFolderView = (asset: AssetItem) => {
const enterFolderView = async (asset: AssetItem) => {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (!metadata) {
console.warn('Invalid output asset metadata')
return
}
const { promptId, allOutputs, executionTimeInSeconds } = metadata
const { promptId, allOutputs, executionTimeInSeconds, outputCount } = metadata
if (!promptId || !Array.isArray(allOutputs) || allOutputs.length === 0) {
if (!promptId) {
console.warn('Missing required folder view data')
return
}
@@ -536,7 +572,48 @@ const enterFolderView = (asset: AssetItem) => {
folderPromptId.value = promptId
folderExecutionTime.value = executionTimeInSeconds
folderAssets.value = allOutputs.map((output) => ({
// Determine which outputs to display
let outputsToDisplay = allOutputs ?? []
// If outputCount indicates more outputs than we have, fetch full outputs
const needsFullOutputs =
typeof outputCount === 'number' &&
outputCount > 1 &&
outputsToDisplay.length < outputCount
if (needsFullOutputs) {
try {
const jobDetail = await getJobDetail(promptId)
if (jobDetail?.outputs) {
// Convert job outputs to ResultItemImpl array
outputsToDisplay = Object.entries(jobDetail.outputs).flatMap(
([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as JobOutputItem[])
.map(
(item) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
.filter((r) => r.supportsPreview)
)
)
}
} catch (error) {
console.error('Failed to fetch job detail for folder view:', error)
outputsToDisplay = []
}
}
if (outputsToDisplay.length === 0) {
console.warn('No outputs available for folder view')
return
}
folderAssets.value = outputsToDisplay.map((output) => ({
id: `${output.nodeId}-${output.filename}`,
name: output.filename,
size: 0,
@@ -609,35 +686,6 @@ const handleDeleteSelected = async () => {
clearSelection()
}
const handleBulkDownload = (assets: AssetItem[]) => {
downloadMultipleAssets(assets)
clearSelection()
}
const handleBulkDelete = async (assets: AssetItem[]) => {
await deleteMultipleAssets(assets)
clearSelection()
}
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
await addMultipleToWorkflow(assets)
clearSelection()
}
const handleBulkOpenWorkflow = async (assets: AssetItem[]) => {
await openMultipleWorkflows(assets)
clearSelection()
}
const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
await exportMultipleWorkflows(assets)
clearSelection()
}
const handleClearQueue = async () => {
await commandStore.execute('Comfy.ClearPendingTasks')
}
const handleApproachEnd = useDebounceFn(async () => {
if (
activeTab.value === 'output' &&

View File

@@ -139,7 +139,15 @@ import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import type { Ref } from 'vue'
import { computed, h, nextTick, onMounted, ref, render } from 'vue'
import {
computed,
getCurrentInstance,
h,
nextTick,
onMounted,
ref,
render
} from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
@@ -171,6 +179,8 @@ import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue'
const instance = getCurrentInstance()!
const appContext = instance.appContext
const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeHelpStore = useNodeHelpStore()
@@ -272,6 +282,7 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
draggable: node.leaf,
renderDragPreview(container) {
const vnode = h(NodePreview, { nodeDef: node.data })
vnode.appContext = appContext
render(vnode, container)
return () => {
render(null, container)

View File

@@ -22,7 +22,15 @@
</template>
<script setup lang="ts">
import { computed, h, nextTick, ref, render, watch } from 'vue'
import {
computed,
getCurrentInstance,
h,
nextTick,
ref,
render,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import FolderCustomizationDialog from '@/components/common/CustomizationDialog.vue'
@@ -41,6 +49,8 @@ import type {
TreeNode
} from '@/types/treeExplorerTypes'
const instance = getCurrentInstance()!
const appContext = instance.appContext
const props = defineProps<{
filteredNodeDefs: ComfyNodeDefImpl[]
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
@@ -154,6 +164,7 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
},
renderDragPreview(container) {
const vnode = h(NodePreview, { nodeDef: node.data })
vnode.appContext = appContext
render(vnode, container)
return () => {
render(null, container)

View File

@@ -1,100 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import NodeHelpPage from '@/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
vi.mock('@/composables/graph/useSelectionState')
vi.mock('@/stores/workspace/nodeHelpStore')
const baseNode = {
nodePath: 'NodeA',
display_name: 'Node A',
description: '',
inputs: {},
outputs: []
}
describe('NodeHelpPage', () => {
const selection = ref<any | null>(null)
let openHelp: ReturnType<typeof vi.fn>
const mountPage = () =>
mount(NodeHelpPage, {
props: { node: baseNode as any },
global: {
mocks: {
$t: (key: string) => key
},
stubs: {
ProgressSpinner: true,
Button: true
}
}
})
beforeEach(() => {
vi.resetAllMocks()
selection.value = null
openHelp = vi.fn()
vi.mocked(useSelectionState).mockReturnValue({
nodeDef: computed(() => selection.value)
} as any)
vi.mocked(useNodeHelpStore).mockReturnValue({
renderedHelpHtml: ref('<p>help</p>'),
isLoading: ref(false),
error: ref(null),
isHelpOpen: true,
currentHelpNode: { nodePath: 'NodeA' },
openHelp,
closeHelp: vi.fn()
} as any)
})
test('opens help for a newly selected node while help is open', async () => {
const wrapper = mountPage()
selection.value = { nodePath: 'NodeB' }
await flushPromises()
expect(openHelp).toHaveBeenCalledWith({ nodePath: 'NodeB' })
wrapper.unmount()
})
test('does not reopen help when the same node stays selected', async () => {
const wrapper = mountPage()
selection.value = { nodePath: 'NodeA' }
await flushPromises()
expect(openHelp).not.toHaveBeenCalled()
wrapper.unmount()
})
test('does not react to selection when help is closed', async () => {
vi.mocked(useNodeHelpStore).mockReturnValueOnce({
renderedHelpHtml: ref('<p>help</p>'),
isLoading: ref(false),
error: ref(null),
isHelpOpen: false,
currentHelpNode: null,
openHelp,
closeHelp: vi.fn()
} as any)
const wrapper = mountPage()
selection.value = { nodePath: 'NodeB' }
await flushPromises()
expect(openHelp).not.toHaveBeenCalled()
wrapper.unmount()
})
})

View File

@@ -21,32 +21,13 @@
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
defineEmits<{
(e: 'close'): void
}>()
const nodeHelpStore = useNodeHelpStore()
const { nodeDef } = useSelectionState()
const activeHelpDef = computed(() =>
nodeHelpStore.isHelpOpen ? nodeDef.value : null
)
// Keep the open help page synced with the current selection while help is open.
whenever(activeHelpDef, (def) => {
const currentHelpNode = nodeHelpStore.currentHelpNode
if (currentHelpNode?.nodePath === def.nodePath) return
nodeHelpStore.openHelp(def)
})
</script>

View File

@@ -57,7 +57,7 @@
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('g.learnMore')"
@click.stop="props.openNodeHelp(nodeDef)"
@click.stop="onHelpClick"
>
<i class="pi pi-question size-3.5" />
</Button>
@@ -85,6 +85,7 @@ import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
@@ -112,6 +113,13 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
const toggleBookmark = async () => {
await nodeBookmarkStore.toggleBookmark(nodeDef.value)
}
const onHelpClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'node_library_help_button'
})
props.openNodeHelp(nodeDef.value)
}
const editBlueprint = async () => {
if (!props.node.data)
throw new Error(

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex h-full shrink-0 items-center gap-1">
<div class="flex h-full shrink-0 items-center gap-1 empty:hidden">
<Button
v-for="(button, index) in actionBarButtonStore.buttons"
:key="index"

View File

@@ -47,7 +47,7 @@ const transform = computed(() => {
<template>
<div
ref="zoomPane"
class="contain-size flex place-content-center"
class="contain-size place-content-center"
@wheel="handleWheel"
@pointerdown.prevent="handleDown"
@pointermove="handleMove"

View File

@@ -0,0 +1,182 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { ComponentExposed } from 'vue-component-type-helpers'
import { ref } from 'vue'
import TagsInput from './TagsInput.vue'
import TagsInputInput from './TagsInputInput.vue'
import TagsInputItem from './TagsInputItem.vue'
import TagsInputItemDelete from './TagsInputItemDelete.vue'
import TagsInputItemText from './TagsInputItemText.vue'
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
component: ComponentExposed<C>
}
const meta: GenericMeta<typeof TagsInput> = {
title: 'Components/TagsInput',
component: TagsInput,
tags: ['autodocs'],
argTypes: {
modelValue: {
control: 'object',
description: 'Array of tag values'
},
disabled: {
control: 'boolean',
description:
'When true, completely disables the component. When false (default), shows read-only state with edit icon until clicked.'
},
'onUpdate:modelValue': { action: 'update:modelValue' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
},
setup() {
const tags = ref(args.modelValue || ['tag1', 'tag2'])
return { tags, args }
},
template: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Add tag..." />
</TagsInput>
<div class="mt-4 text-sm text-muted-foreground">
Tags: {{ tags.join(', ') }}
</div>
`
}),
args: {
modelValue: ['Vue', 'TypeScript'],
disabled: false
}
}
export const Empty: Story = {
args: {
disabled: false
},
render: (args) => ({
components: {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
},
setup() {
const tags = ref<string[]>([])
return { tags, args }
},
template: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Start typing to add tags..." />
</TagsInput>
`
})
}
export const ManyTags: Story = {
render: (args) => ({
components: {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
},
setup() {
const tags = ref([
'JavaScript',
'TypeScript',
'Vue',
'React',
'Svelte',
'Node.js',
'Python',
'Rust'
])
return { tags, args }
},
template: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-96" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Add technology..." />
</TagsInput>
`
})
}
export const Disabled: Story = {
args: {
disabled: true
},
render: (args) => ({
components: {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
},
setup() {
const tags = ref(['Read', 'Only', 'Tags'])
return { tags, args }
},
template: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-80" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Cannot add tags..." />
</TagsInput>
`
})
}
export const CustomWidth: Story = {
render: (args) => ({
components: {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
},
setup() {
const tags = ref(['Full', 'Width'])
return { tags, args }
},
template: `
<TagsInput v-model="tags" :disabled="args.disabled" class="w-full" v-slot="{ isEmpty }">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty="isEmpty" placeholder="Add tag..." />
</TagsInput>
`
})
}

View File

@@ -0,0 +1,161 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { h, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import TagsInput from './TagsInput.vue'
import TagsInputInput from './TagsInputInput.vue'
import TagsInputItem from './TagsInputItem.vue'
import TagsInputItemDelete from './TagsInputItemDelete.vue'
import TagsInputItemText from './TagsInputItemText.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { removeTag: 'Remove tag' } } }
})
describe('TagsInput', () => {
function mountTagsInput(props = {}, slots = {}) {
return mount(TagsInput, {
props: {
modelValue: [],
...props
},
slots
})
}
it('renders slot content', () => {
const wrapper = mountTagsInput({}, { default: '<span>Slot Content</span>' })
expect(wrapper.text()).toContain('Slot Content')
})
})
describe('TagsInput with child components', () => {
function mountFullTagsInput(tags: string[] = ['tag1', 'tag2']) {
return mount(TagsInput, {
global: { plugins: [i18n] },
props: {
modelValue: tags
},
slots: {
default: () => [
...tags.map((tag) =>
h(TagsInputItem, { key: tag, value: tag }, () => [
h(TagsInputItemText),
h(TagsInputItemDelete)
])
),
h(TagsInputInput, { placeholder: 'Add tag...' })
]
}
})
}
it('renders tags structure and content', () => {
const tags = ['tag1', 'tag2']
const wrapper = mountFullTagsInput(tags)
const items = wrapper.findAllComponents(TagsInputItem)
const textElements = wrapper.findAllComponents(TagsInputItemText)
const deleteButtons = wrapper.findAllComponents(TagsInputItemDelete)
expect(items).toHaveLength(tags.length)
expect(textElements).toHaveLength(tags.length)
expect(deleteButtons).toHaveLength(tags.length)
textElements.forEach((el, i) => {
expect(el.text()).toBe(tags[i])
})
expect(wrapper.findComponent(TagsInputInput).exists()).toBe(true)
})
it('updates model value when adding a tag', async () => {
let currentTags = ['existing']
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
props: {
modelValue: currentTags,
'onUpdate:modelValue': (payload) => {
currentTags = payload
}
},
slots: {
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
}
})
await wrapper.trigger('click')
await nextTick()
const input = wrapper.find('input')
await input.setValue('newTag')
await input.trigger('keydown', { key: 'Enter' })
await nextTick()
expect(currentTags).toContain('newTag')
})
it('does not enter edit mode when disabled', async () => {
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
props: {
modelValue: ['tag1'],
disabled: true
},
slots: {
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
}
})
expect(wrapper.find('input').exists()).toBe(false)
await wrapper.trigger('click')
await nextTick()
expect(wrapper.find('input').exists()).toBe(false)
})
it('exits edit mode when clicking outside', async () => {
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
props: {
modelValue: ['tag1']
},
slots: {
default: () => h(TagsInputInput, { placeholder: 'Add tag...' })
},
attachTo: document.body
})
await wrapper.trigger('click')
await nextTick()
expect(wrapper.find('input').exists()).toBe(true)
document.body.click()
await nextTick()
expect(wrapper.find('input').exists()).toBe(false)
wrapper.unmount()
})
it('shows placeholder when modelValue is empty', async () => {
const wrapper = mount<typeof TagsInput<string>>(TagsInput, {
props: {
modelValue: []
},
slots: {
default: ({ isEmpty }: { isEmpty: boolean }) =>
h(TagsInputInput, { placeholder: 'Add tag...', isEmpty })
}
})
await nextTick()
const input = wrapper.find('input')
expect(input.exists()).toBe(true)
expect(input.attributes('placeholder')).toBe('Add tag...')
})
})

View File

@@ -0,0 +1,77 @@
<script setup lang="ts" generic="T extends AcceptableInputValue = string">
import { onClickOutside, useCurrentElement } from '@vueuse/core'
import type {
AcceptableInputValue,
TagsInputRootEmits,
TagsInputRootProps
} from 'reka-ui'
import { TagsInputRoot, useForwardPropsEmits } from 'reka-ui'
import { computed, nextTick, provide, ref } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { tagsInputFocusKey, tagsInputIsEditingKey } from './tagsInputContext'
import type { FocusCallback } from './tagsInputContext'
const {
disabled = false,
class: className,
...restProps
} = defineProps<TagsInputRootProps<T> & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<TagsInputRootEmits<T>>()
const isEditing = ref(false)
const rootEl = useCurrentElement<HTMLElement>()
const focusInput = ref<FocusCallback>()
provide(tagsInputFocusKey, (callback: FocusCallback) => {
focusInput.value = callback
})
provide(tagsInputIsEditingKey, isEditing)
const internalDisabled = computed(() => disabled || !isEditing.value)
const delegatedProps = computed(() => ({
...restProps,
disabled: internalDisabled.value
}))
const forwarded = useForwardPropsEmits(delegatedProps, emits)
async function enableEditing() {
if (!disabled && !isEditing.value) {
isEditing.value = true
await nextTick()
focusInput.value?.()
}
}
onClickOutside(rootEl, () => {
isEditing.value = false
})
</script>
<template>
<TagsInputRoot
v-slot="{ modelValue }"
v-bind="forwarded"
:class="
cn(
'group relative flex flex-wrap items-center gap-2 rounded-lg bg-transparent p-2 text-xs text-base-foreground',
!internalDisabled &&
'hover:bg-modal-card-background-hovered focus-within:bg-modal-card-background-hovered',
!disabled && !isEditing && 'cursor-pointer',
className
)
"
@click="enableEditing"
>
<slot :is-empty="modelValue.length === 0" />
<i
v-if="!disabled && !isEditing"
aria-hidden="true"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground"
/>
</TagsInputRoot>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { TagsInputInputProps } from 'reka-ui'
import { TagsInputInput, useForwardExpose, useForwardProps } from 'reka-ui'
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { tagsInputFocusKey, tagsInputIsEditingKey } from './tagsInputContext'
const {
isEmpty = false,
class: className,
...restProps
} = defineProps<
TagsInputInputProps & { class?: HTMLAttributes['class']; isEmpty?: boolean }
>()
const forwardedProps = useForwardProps(restProps)
const isEditing = inject(tagsInputIsEditingKey, ref(true))
const showInput = computed(() => isEditing.value || isEmpty)
const { forwardRef, currentElement } = useForwardExpose()
const registerFocus = inject(tagsInputFocusKey, undefined)
onMounted(() => {
registerFocus?.(() => currentElement.value?.focus())
})
onUnmounted(() => {
registerFocus?.(undefined)
})
</script>
<template>
<TagsInputInput
v-if="showInput"
:ref="forwardRef"
v-bind="forwardedProps"
:class="
cn(
'min-h-6 flex-1 bg-transparent text-xs text-muted-foreground placeholder:text-muted-foreground focus:outline-none appearance-none border-none',
!isEditing && 'pointer-events-none',
className
)
"
/>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { TagsInputItemProps } from 'reka-ui'
import { TagsInputItem, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
TagsInputItemProps & { class?: HTMLAttributes['class'] }
>()
const forwardedProps = useForwardProps(restProps)
</script>
<template>
<TagsInputItem
v-bind="forwardedProps"
:class="
cn(
'flex h-6 items-center gap-1 rounded-sm bg-modal-card-tag-background py-1 pl-2 pr-1 text-modal-card-tag-foreground backdrop-blur-sm ring-offset-base-background data-[state=active]:ring-2 data-[state=active]:ring-base-foreground data-[state=active]:ring-offset-1',
className
)
"
>
<slot />
</TagsInputItem>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { TagsInputItemDeleteProps } from 'reka-ui'
import { TagsInputItemDelete, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
TagsInputItemDeleteProps & { class?: HTMLAttributes['class'] }
>()
const forwardedProps = useForwardProps(restProps)
const { t } = useI18n()
</script>
<template>
<TagsInputItemDelete
v-bind="forwardedProps"
:as="Button"
variant="textonly"
size="icon-sm"
:aria-label="t('g.removeTag')"
:class="
cn(
'opacity-60 hover:bg-transparent hover:opacity-100 transition-[opacity,width] duration-150 w-4 data-[disabled]:w-0 data-[disabled]:opacity-0 data-[disabled]:pointer-events-none overflow-hidden',
className
)
"
>
<slot>
<i class="icon-[lucide--x] size-4" />
</slot>
</TagsInputItemDelete>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { TagsInputItemTextProps } from 'reka-ui'
import { TagsInputItemText, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
TagsInputItemTextProps & { class?: HTMLAttributes['class'] }
>()
const forwardedProps = useForwardProps(restProps)
</script>
<template>
<TagsInputItemText
v-bind="forwardedProps"
:class="cn('bg-transparent text-xs', className)"
/>
</template>

View File

@@ -0,0 +1,10 @@
import type { InjectionKey, Ref } from 'vue'
export type FocusCallback = (() => void) | undefined
export const tagsInputFocusKey: InjectionKey<
(callback: FocusCallback) => void
> = Symbol('tagsInputFocus')
export const tagsInputIsEditingKey: InjectionKey<Ref<boolean>> =
Symbol('tagsInputIsEditing')

View File

@@ -2,7 +2,7 @@
<div class="base-widget-layout rounded-2xl overflow-hidden relative">
<Button
v-show="!isRightPanelOpen && hasRightPanel"
size="icon"
size="lg"
:class="
cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
@@ -10,7 +10,7 @@
"
@click="toggleRightPanel"
>
<i class="icon-[lucide--panel-right] text-sm" />
<i class="icon-[lucide--panel-right]" />
</Button>
<Button
size="lg"
@@ -64,7 +64,7 @@
>
<Button
v-if="isRightPanelOpen && hasRightPanel"
size="icon"
size="lg"
@click="toggleRightPanel"
>
<i class="icon-[lucide--panel-right-close]" />
@@ -90,7 +90,7 @@
</div>
<aside
v-if="hasRightPanel && isRightPanelOpen"
class="w-1/4 min-w-40 max-w-80"
class="w-1/4 min-w-40 max-w-80 pt-16 pb-8"
>
<slot name="rightPanel"></slot>
</aside>
@@ -111,6 +111,10 @@ const { contentTitle } = defineProps<{
contentTitle: string
}>()
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
default: false
})
const BREAKPOINTS = { md: 880 }
const PANEL_SIZES = {
width: 'w-1/3',
@@ -125,7 +129,6 @@ const breakpoints = useBreakpoints(BREAKPOINTS)
const notMobile = breakpoints.greater('md')
const isLeftPanelOpen = ref<boolean>(true)
const isRightPanelOpen = ref<boolean>(false)
const mobileMenuOpen = ref<boolean>(false)
const hasRightPanel = computed(() => !!slots.rightPanel)

View File

@@ -41,8 +41,8 @@ export const useCurrentUser = () => {
whenever(() => authStore.tokenRefreshTrigger, callback)
const onUserLogout = (callback: () => void) => {
watch(resolvedUserInfo, (user) => {
if (!user) callback()
watch(resolvedUserInfo, (user, prevUser) => {
if (prevUser && !user) callback()
})
}

View File

@@ -1,41 +0,0 @@
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { ref, watch } from 'vue'
type BreakpointKey = keyof typeof breakpointsTailwind
/**
* Composable for element with responsive collapsed state
* @param breakpointThreshold - Breakpoint at which the element should become collapsible
*/
export const useResponsiveCollapse = (
breakpointThreshold: BreakpointKey = 'lg'
) => {
const breakpoints = useBreakpoints(breakpointsTailwind)
const isSmallScreen = breakpoints.smallerOrEqual(breakpointThreshold)
const isOpen = ref(!isSmallScreen.value)
/**
* Handles screen size changes to automatically open/close the element
* when crossing the breakpoint threshold
*/
const onIsSmallScreenChange = () => {
if (isSmallScreen.value && isOpen.value) {
isOpen.value = false
} else if (!isSmallScreen.value && !isOpen.value) {
isOpen.value = true
}
}
watch(isSmallScreen, onIsSmallScreenChange)
return {
breakpoints,
isOpen,
isSmallScreen,
open: () => (isOpen.value = true),
close: () => (isOpen.value = false),
toggle: () => (isOpen.value = !isOpen.value)
}
}

View File

@@ -1,6 +1,7 @@
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useCommandStore } from '@/stores/commandStore'
import type { MenuOption } from './useMoreOptionsMenu'
@@ -16,7 +17,7 @@ export function useImageMenuOptions() {
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
}
const openImage = (node: any) => {
const openImage = (node: LGraphNode) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
@@ -25,7 +26,7 @@ export function useImageMenuOptions() {
window.open(url.toString(), '_blank')
}
const copyImage = async (node: any) => {
const copyImage = async (node: LGraphNode) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
@@ -62,7 +63,7 @@ export function useImageMenuOptions() {
}
}
const saveImage = (node: any) => {
const saveImage = (node: LGraphNode) => {
if (!node?.imgs?.length) return
const img = node.imgs[node.imageIndex ?? 0]
if (!img) return
@@ -76,7 +77,7 @@ export function useImageMenuOptions() {
}
}
const getImageMenuOptions = (node: any): MenuOption[] => {
const getImageMenuOptions = (node: LGraphNode): MenuOption[] => {
if (!node?.imgs?.length) return []
return [

View File

@@ -404,8 +404,9 @@ export function useBrushDrawing(initialSettings?: {
device = root.device
console.warn('✅ TypeGPU initialized! Root:', root)
console.warn('Device info:', root.device.limits)
} catch (error: any) {
console.warn('Failed to initialize TypeGPU:', error.message)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.warn('Failed to initialize TypeGPU:', message)
}
}

View File

@@ -57,8 +57,8 @@ export const useCompletionSummary = () => {
}
if (prev && !active) {
const start = lastActiveStartTs.value ?? 0
const finished = queueStore.historyTasks.filter((t: any) => {
const ts: number | undefined = t.executionEndTimestamp
const finished = queueStore.historyTasks.filter((t) => {
const ts = t.executionEndTimestamp
return typeof ts === 'number' && ts >= start
})

View File

@@ -16,7 +16,7 @@ type TestTask = {
executionTime?: number
executionEndTimestamp?: number
createTime?: number
workflow?: { id?: string }
workflowId?: string
}
const translations: Record<string, string> = {
@@ -161,7 +161,7 @@ const createTask = (
executionTime: overrides.executionTime,
executionEndTimestamp: overrides.executionEndTimestamp,
createTime: overrides.createTime,
workflow: overrides.workflow
workflowId: overrides.workflowId
})
const mountUseJobList = () => {
@@ -305,7 +305,7 @@ describe('useJobList', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('sorts all tasks by queue index descending', async () => {
it('sorts all tasks by priority descending', async () => {
queueStoreMock.pendingTasks = [
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
]
@@ -360,13 +360,13 @@ describe('useJobList', () => {
promptId: 'wf-1',
queueIndex: 2,
mockState: 'pending',
workflow: { id: 'workflow-1' }
workflowId: 'workflow-1'
}),
createTask({
promptId: 'wf-2',
queueIndex: 1,
mockState: 'pending',
workflow: { id: 'workflow-2' }
workflowId: 'workflow-2'
})
]

View File

@@ -38,7 +38,7 @@ export type JobListItem = {
iconName?: string
iconImageUrl?: string
showClear?: boolean
taskRef?: any
taskRef?: TaskItemImpl
progressTotalPercent?: number
progressCurrentPercent?: number
runningNodeName?: string
@@ -238,7 +238,7 @@ export function useJobList() {
const activeId = workflowStore.activeWorkflow?.activeState?.id
if (!activeId) return []
entries = entries.filter(({ task }) => {
const wid = task.workflow?.id
const wid = task.workflowId
return !!wid && wid === activeId
})
}

View File

@@ -73,6 +73,7 @@ vi.mock('@/scripts/utils', () => ({
}))
const dialogServiceMock = {
showErrorDialog: vi.fn(),
showExecutionErrorDialog: vi.fn(),
prompt: vi.fn()
}
@@ -103,6 +104,11 @@ vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => queueStoreMock
}))
const getJobWorkflowMock = vi.fn()
vi.mock('@/services/jobOutputCache', () => ({
getJobWorkflow: (...args: any[]) => getJobWorkflowMock(...args)
}))
const createAnnotatedPathMock = vi.fn()
vi.mock('@/utils/createAnnotatedPath', () => ({
createAnnotatedPath: (...args: any[]) => createAnnotatedPathMock(...args)
@@ -117,13 +123,22 @@ vi.mock('@/utils/formatUtil', () => ({
}))
import { useJobMenu } from '@/composables/queue/useJobMenu'
import type { TaskItemImpl } from '@/stores/queueStore'
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
type MockTaskRef = Record<string, unknown>
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
taskRef?: MockTaskRef
}
const createJobItem = (
overrides: Partial<TestJobListItem> = {}
): JobListItem => ({
id: overrides.id ?? 'job-1',
title: overrides.title ?? 'Test job',
meta: overrides.meta ?? 'meta',
state: overrides.state ?? 'completed',
taskRef: overrides.taskRef,
taskRef: overrides.taskRef as TaskItemImpl | undefined,
iconName: overrides.iconName,
iconImageUrl: overrides.iconImageUrl,
showClear: overrides.showClear,
@@ -170,6 +185,8 @@ describe('useJobMenu', () => {
LoadVideo: { id: 'LoadVideo' },
LoadAudio: { id: 'LoadAudio' }
}
// Default: no workflow available via lazy loading
getJobWorkflowMock.mockResolvedValue(undefined)
})
const setCurrentItem = (item: JobListItem | null) => {
@@ -179,10 +196,13 @@ describe('useJobMenu', () => {
it('opens workflow when workflow data exists', async () => {
const { openJobWorkflow } = mountJobMenu()
const workflow = { nodes: [] }
setCurrentItem(createJobItem({ id: '55', taskRef: { workflow } }))
// Mock lazy loading via fetchJobDetail + extractWorkflow
getJobWorkflowMock.mockResolvedValue(workflow)
setCurrentItem(createJobItem({ id: '55' }))
await openJobWorkflow()
expect(getJobWorkflowMock).toHaveBeenCalledWith('55')
expect(workflowStoreMock.createTemporary).toHaveBeenCalledWith(
'Job 55.json',
workflow
@@ -257,11 +277,10 @@ describe('useJobMenu', () => {
it('copies error message from failed job entry', async () => {
const { jobMenuEntries } = mountJobMenu()
const error = { exception_message: 'boom' }
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { status: { messages: [['execution_error', error]] } } as any
taskRef: { errorMessage: 'Something went wrong' } as any
})
)
@@ -269,31 +288,75 @@ describe('useJobMenu', () => {
const entry = findActionEntry(jobMenuEntries.value, 'copy-error')
await entry?.onClick?.()
expect(copyToClipboardMock).toHaveBeenCalledWith('boom')
expect(copyToClipboardMock).toHaveBeenCalledWith('Something went wrong')
})
it('reports error via dialog when entry triggered', async () => {
it('reports error via rich dialog when execution_error available', async () => {
const executionError = {
prompt_id: 'job-1',
timestamp: 12345,
node_id: '5',
node_type: 'KSampler',
executed: ['1', '2'],
exception_message: 'CUDA out of memory',
exception_type: 'RuntimeError',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
const { jobMenuEntries } = mountJobMenu()
const error = { exception_message: 'bad', extra: 1 }
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { status: { messages: [['execution_error', error]] } } as any
taskRef: {
errorMessage: 'CUDA out of memory',
executionError,
createTime: 12345
} as any
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
void entry?.onClick?.()
await entry?.onClick?.()
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledTimes(1)
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledWith(
error
executionError
)
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
})
it('falls back to simple error dialog when no execution_error', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { errorMessage: 'Job failed with error' } as any
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
await entry?.onClick?.()
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
expect(dialogServiceMock.showErrorDialog).toHaveBeenCalledTimes(1)
const [errorArg, optionsArg] =
dialogServiceMock.showErrorDialog.mock.calls[0]
expect(errorArg).toBeInstanceOf(Error)
expect(errorArg.message).toBe('Job failed with error')
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
})
it('ignores error actions when message missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { errorMessage: undefined } as any
})
)
await nextTick()
const copyEntry = findActionEntry(jobMenuEntries.value, 'copy-error')
@@ -302,6 +365,7 @@ describe('useJobMenu', () => {
await reportEntry?.onClick?.()
expect(copyToClipboardMock).not.toHaveBeenCalled()
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
})
@@ -477,12 +541,13 @@ describe('useJobMenu', () => {
})
it('exports workflow with default filename when prompting disabled', async () => {
const workflow = { foo: 'bar' }
getJobWorkflowMock.mockResolvedValue(workflow)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
id: '7',
state: 'completed',
taskRef: { workflow: { foo: 'bar' } }
state: 'completed'
})
)
@@ -502,11 +567,11 @@ describe('useJobMenu', () => {
it('prompts for filename when setting enabled', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('custom-name')
getJobWorkflowMock.mockResolvedValue({})
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { workflow: {} }
state: 'completed'
})
)
@@ -526,12 +591,12 @@ describe('useJobMenu', () => {
it('keeps existing json extension when exporting workflow', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('existing.json')
getJobWorkflowMock.mockResolvedValue({ foo: 'bar' })
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
id: '42',
state: 'completed',
taskRef: { workflow: { foo: 'bar' } }
state: 'completed'
})
)
@@ -547,11 +612,11 @@ describe('useJobMenu', () => {
it('abandons export when prompt cancelled', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('')
getJobWorkflowMock.mockResolvedValue({})
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { workflow: {} }
state: 'completed'
})
)
@@ -671,7 +736,12 @@ describe('useJobMenu', () => {
it('returns failed menu entries with error actions', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { errorMessage: 'Some error' } as any
})
)
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([

View File

@@ -9,14 +9,11 @@ import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAsse
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type {
ExecutionErrorWsMessage,
ResultItem,
ResultItemType
} from '@/schemas/apiSchema'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { downloadBlob } from '@/scripts/utils'
import { useDialogService } from '@/services/dialogService'
import { getJobWorkflow } from '@/services/jobOutputCache'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useQueueStore } from '@/stores/queueStore'
@@ -58,7 +55,7 @@ export function useJobMenu(
const openJobWorkflow = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const data = target.taskRef?.workflow
const data = await getJobWorkflow(target.id)
if (!data) return
const filename = `Job ${target.id}.json`
const temp = workflowStore.createTemporary(filename, data)
@@ -84,31 +81,37 @@ export function useJobMenu(
const copyErrorMessage = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const msgs = target.taskRef?.status?.messages as any[] | undefined
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
| ExecutionErrorWsMessage
| undefined
const message = err?.exception_message
if (message) await copyToClipboard(String(message))
const message = target?.taskRef?.errorMessage
if (message) await copyToClipboard(message)
}
const reportError = (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const msgs = target.taskRef?.status?.messages as any[] | undefined
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
| ExecutionErrorWsMessage
| undefined
if (err) useDialogService().showExecutionErrorDialog(err)
// Use execution_error from list response if available
const executionError = target.taskRef?.executionError
if (executionError) {
useDialogService().showExecutionErrorDialog(executionError)
return
}
// Fall back to simple error dialog
const message = target.taskRef?.errorMessage
if (message) {
useDialogService().showErrorDialog(new Error(message), {
reportType: 'queueJobError'
})
}
}
// This is very magical only because it matches the respective backend implementation
// There is or will be a better way to do this
const addOutputLoaderNode = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
const addOutputLoaderNode = async () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
if (!result) return
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
@@ -156,10 +159,10 @@ export function useJobMenu(
/**
* Trigger a download of the job's previewable output asset.
*/
const downloadPreviewAsset = (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
const downloadPreviewAsset = () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
if (!result) return
downloadFile(result.url)
}
@@ -167,14 +170,14 @@ export function useJobMenu(
/**
* Export the workflow JSON attached to the job.
*/
const exportJobWorkflow = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const data = target.taskRef?.workflow
const exportJobWorkflow = async () => {
const item = currentMenuItem()
if (!item) return
const data = await getJobWorkflow(item.id)
if (!data) return
const settingStore = useSettingStore()
let filename = `Job ${target.id}.json`
let filename = `Job ${item.id}.json`
if (settingStore.get('Comfy.PromptFilename')) {
const input = await useDialogService().prompt({
@@ -191,10 +194,10 @@ export function useJobMenu(
downloadBlob(filename, blob)
}
const deleteJobAsset = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const task = target.taskRef as TaskItemImpl | undefined
const deleteJobAsset = async () => {
const item = currentMenuItem()
if (!item) return
const task = item.taskRef as TaskItemImpl | undefined
const preview = task?.previewOutput
if (!task || !preview) return
@@ -205,8 +208,8 @@ export function useJobMenu(
}
}
const removeFailedJob = async (item?: JobListItem | null) => {
const task = resolveItem(item)?.taskRef as TaskItemImpl | undefined
const removeFailedJob = async () => {
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
if (!task) return
await queueStore.delete(task)
}
@@ -237,8 +240,8 @@ export function useJobMenu(
icon: 'icon-[lucide--zoom-in]',
onClick: onInspectAsset
? () => {
const current = resolveItem()
if (current) onInspectAsset(current)
const item = currentMenuItem()
if (item) onInspectAsset(item)
}
: undefined
},
@@ -249,33 +252,33 @@ export function useJobMenu(
'Add to current workflow'
),
icon: 'icon-[comfy--node]',
onClick: () => addOutputLoaderNode(resolveItem())
onClick: addOutputLoaderNode
},
{
key: 'download',
label: st('queue.jobMenu.download', 'Download'),
icon: 'icon-[lucide--download]',
onClick: () => downloadPreviewAsset(resolveItem())
onClick: downloadPreviewAsset
},
{ kind: 'divider', key: 'd1' },
{
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: () => openJobWorkflow(resolveItem())
onClick: openJobWorkflow
},
{
key: 'export-workflow',
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
icon: 'icon-[comfy--file-output]',
onClick: () => exportJobWorkflow(resolveItem())
onClick: exportJobWorkflow
},
{ kind: 'divider', key: 'd2' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: () => copyJobId(resolveItem())
onClick: copyJobId
},
{ kind: 'divider', key: 'd3' },
...(hasDeletableAsset
@@ -284,7 +287,7 @@ export function useJobMenu(
key: 'delete',
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
icon: 'icon-[lucide--trash-2]',
onClick: () => deleteJobAsset(resolveItem())
onClick: deleteJobAsset
}
]
: [])
@@ -296,33 +299,33 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowFailedLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: () => openJobWorkflow(resolveItem())
onClick: openJobWorkflow
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: () => copyJobId(resolveItem())
onClick: copyJobId
},
{
key: 'copy-error',
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
icon: 'icon-[lucide--copy]',
onClick: () => copyErrorMessage(resolveItem())
onClick: copyErrorMessage
},
{
key: 'report-error',
label: st('queue.jobMenu.reportError', 'Report error'),
icon: 'icon-[lucide--message-circle-warning]',
onClick: () => reportError(resolveItem())
onClick: reportError
},
{ kind: 'divider', key: 'd2' },
{
key: 'delete',
label: st('queue.jobMenu.removeJob', 'Remove job'),
icon: 'icon-[lucide--circle-minus]',
onClick: () => removeFailedJob(resolveItem())
onClick: removeFailedJob
}
]
}
@@ -331,21 +334,21 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: () => openJobWorkflow(resolveItem())
onClick: openJobWorkflow
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: () => copyJobId(resolveItem())
onClick: copyJobId
},
{ kind: 'divider', key: 'd2' },
{
key: 'cancel-job',
label: jobMenuCancelLabel.value,
icon: 'icon-[lucide--x]',
onClick: () => cancelJob(resolveItem())
onClick: cancelJob
}
]
})

View File

@@ -1,35 +1,74 @@
import { describe, it, expect } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem as JobListViewItem } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
type PreviewLike = { url: string; supportsPreview: boolean }
const createResultItem = (
url: string,
supportsPreview = true
): ResultItemImpl => {
const item = new ResultItemImpl({
filename: url,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType: supportsPreview ? 'images' : 'unknown'
})
// Override url getter for test matching
Object.defineProperty(item, 'url', { get: () => url })
Object.defineProperty(item, 'supportsPreview', { get: () => supportsPreview })
return item
}
const createPreview = (url: string, supportsPreview = true): PreviewLike => ({
url,
supportsPreview
const createMockJob = (id: string, outputsCount = 1): JobListItem => ({
id,
status: 'completed',
create_time: Date.now(),
preview_output: null,
outputs_count: outputsCount,
priority: 0
})
const createTask = (preview?: PreviewLike) => ({
previewOutput: preview
})
const createTask = (
preview?: ResultItemImpl,
allOutputs?: ResultItemImpl[],
outputsCount = 1
): TaskItemImpl => {
const job = createMockJob(
`task-${Math.random().toString(36).slice(2)}`,
outputsCount
)
const flatOutputs = allOutputs ?? (preview ? [preview] : [])
return new TaskItemImpl(job, {}, flatOutputs)
}
const createJobItem = (id: string, preview?: PreviewLike): JobListItem =>
const createJobViewItem = (
id: string,
taskRef?: TaskItemImpl
): JobListViewItem =>
({
id,
title: `Job ${id}`,
meta: '',
state: 'completed',
showClear: false,
taskRef: preview ? { previewOutput: preview } : undefined
}) as JobListItem
taskRef
}) as JobListViewItem
describe('useResultGallery', () => {
it('collects only previewable outputs and preserves their order', () => {
const previewable = [createPreview('p-1'), createPreview('p-2')]
beforeEach(() => {
setActivePinia(createPinia())
})
it('collects only previewable outputs and preserves their order', async () => {
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
const nonPreviewable = createResultItem('skip-me', false)
const tasks = [
createTask(previewable[0]),
createTask({ url: 'skip-me', supportsPreview: false }),
createTask(nonPreviewable),
createTask(previewable[1]),
createTask()
]
@@ -38,28 +77,28 @@ describe('useResultGallery', () => {
() => tasks
)
onViewItem(createJobItem('job-1', previewable[0]))
await onViewItem(createJobViewItem('job-1', tasks[0]))
expect(galleryItems.value).toEqual(previewable)
expect(galleryItems.value).toEqual([previewable[0]])
expect(galleryActiveIndex.value).toBe(0)
})
it('does not change state when there are no previewable tasks', () => {
it('does not change state when there are no previewable tasks', async () => {
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => []
)
onViewItem(createJobItem('job-missing'))
await onViewItem(createJobViewItem('job-missing'))
expect(galleryItems.value).toEqual([])
expect(galleryActiveIndex.value).toBe(-1)
})
it('activates the index that matches the viewed preview URL', () => {
it('activates the index that matches the viewed preview URL', async () => {
const previewable = [
createPreview('p-1'),
createPreview('p-2'),
createPreview('p-3')
createResultItem('p-1'),
createResultItem('p-2'),
createResultItem('p-3')
]
const tasks = previewable.map((preview) => createTask(preview))
@@ -67,37 +106,66 @@ describe('useResultGallery', () => {
() => tasks
)
onViewItem(createJobItem('job-2', createPreview('p-2')))
await onViewItem(createJobViewItem('job-2', tasks[1]))
expect(galleryItems.value).toEqual(previewable)
expect(galleryActiveIndex.value).toBe(1)
expect(galleryItems.value).toEqual([previewable[1]])
expect(galleryActiveIndex.value).toBe(0)
})
it('defaults to the first entry when the clicked job lacks a preview', () => {
const previewable = [createPreview('p-1'), createPreview('p-2')]
it('defaults to the first entry when the clicked job lacks a preview', async () => {
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
const tasks = previewable.map((preview) => createTask(preview))
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => tasks
)
onViewItem(createJobItem('job-no-preview'))
await onViewItem(createJobViewItem('job-no-preview'))
expect(galleryItems.value).toEqual(previewable)
expect(galleryActiveIndex.value).toBe(0)
})
it('defaults to the first entry when no gallery item matches the preview URL', () => {
const previewable = [createPreview('p-1'), createPreview('p-2')]
it('defaults to the first entry when no gallery item matches the preview URL', async () => {
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
const tasks = previewable.map((preview) => createTask(preview))
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => tasks
)
onViewItem(createJobItem('job-mismatch', createPreview('missing')))
const taskWithMismatchedPreview = createTask(createResultItem('missing'))
await onViewItem(
createJobViewItem('job-mismatch', taskWithMismatchedPreview)
)
expect(galleryItems.value).toEqual(previewable)
expect(galleryItems.value).toEqual([createResultItem('missing')])
expect(galleryActiveIndex.value).toBe(0)
})
it('loads full outputs when task has only preview outputs', async () => {
const previewOutput = createResultItem('preview-1')
const fullOutputs = [
createResultItem('full-1'),
createResultItem('full-2'),
createResultItem('full-3')
]
// Create a task with outputsCount > 1 to trigger lazy loading
const job = createMockJob('task-1', 3)
const task = new TaskItemImpl(job, {}, [previewOutput])
// Mock loadFullOutputs to return full outputs
const loadedTask = new TaskItemImpl(job, {}, fullOutputs)
task.loadFullOutputs = async () => loadedTask
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => [task]
)
await onViewItem(createJobViewItem('job-1', task))
expect(galleryItems.value).toEqual(fullOutputs)
expect(galleryActiveIndex.value).toBe(0)
})
})

View File

@@ -1,27 +1,42 @@
import { ref, shallowRef } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { ResultItemImpl } from '@/stores/queueStore'
import { findActiveIndex, getOutputsForTask } from '@/services/jobOutputCache'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
/**
* Manages result gallery state and activation for queue items.
*/
export function useResultGallery(getFilteredTasks: () => any[]) {
export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
const galleryActiveIndex = ref(-1)
const galleryItems = shallowRef<ResultItemImpl[]>([])
const onViewItem = (item: JobListItem) => {
const items: ResultItemImpl[] = getFilteredTasks().flatMap((t: any) => {
const preview = t.previewOutput
return preview && preview.supportsPreview ? [preview] : []
})
async function onViewItem(item: JobListItem) {
const tasks = getFilteredTasks()
if (!tasks.length) return
const targetTask = item.taskRef
const targetOutputs = targetTask
? await getOutputsForTask(targetTask)
: null
// Request was superseded by a newer one
if (targetOutputs === null && targetTask) return
// Use target's outputs if available, otherwise fall back to all previews
const items = targetOutputs?.length
? targetOutputs
: tasks
.map((t) => t.previewOutput)
.filter((o): o is ResultItemImpl => !!o)
if (!items.length) return
galleryItems.value = items
const activeUrl: string | undefined = item.taskRef?.previewOutput?.url
const idx = activeUrl ? items.findIndex((o) => o.url === activeUrl) : 0
galleryActiveIndex.value = idx >= 0 ? idx : 0
galleryActiveIndex.value = findActiveIndex(
items,
item.taskRef?.previewOutput?.url
)
}
return {

View File

@@ -36,7 +36,7 @@ interface CivitaiModelVersionResponse {
model: CivitaiModel
modelId: number
files: CivitaiModelFile[]
[key: string]: any
[key: string]: unknown
}
/**

View File

@@ -123,7 +123,10 @@ export const useContextMenuTranslation = () => {
}
// for capture translation text of input and widget
const extraInfo: any = options.extra || options.parentMenu?.options?.extra
const extraInfo = (options.extra ||
options.parentMenu?.options?.extra) as
| { inputs?: INodeInputSlot[]; widgets?: IWidget[] }
| undefined
// widgets and inputs
const matchInput = value.content?.match(reInput)
if (matchInput) {

View File

@@ -17,7 +17,8 @@ export enum ServerFeatureFlag {
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled'
}
/**
@@ -92,6 +93,12 @@ export function useFeatureFlags() {
false
)
)
},
get teamWorkspacesEnabled() {
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
)
}
})

View File

@@ -0,0 +1,469 @@
import { useResizeObserver } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { Bounds } from '@/renderer/core/layout/types'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
type ResizeDirection =
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'nw'
| 'ne'
| 'sw'
| 'se'
const HANDLE_SIZE = 8
const CORNER_SIZE = 10
const MIN_CROP_SIZE = 16
const CROP_BOX_BORDER = 2
interface UseImageCropOptions {
imageEl: Ref<HTMLImageElement | null>
containerEl: Ref<HTMLDivElement | null>
modelValue: Ref<Bounds>
}
export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const { imageEl, containerEl, modelValue } = options
const nodeOutputStore = useNodeOutputStore()
const node = ref<LGraphNode | null>(null)
const imageUrl = ref<string | null>(null)
const isLoading = ref(false)
const naturalWidth = ref(0)
const naturalHeight = ref(0)
const displayedWidth = ref(0)
const displayedHeight = ref(0)
const scaleFactor = ref(1)
const imageOffsetX = ref(0)
const imageOffsetY = ref(0)
const cropX = computed({
get: () => modelValue.value.x,
set: (v: number) => {
modelValue.value.x = v
}
})
const cropY = computed({
get: () => modelValue.value.y,
set: (v: number) => {
modelValue.value.y = v
}
})
const cropWidth = computed({
get: () => modelValue.value.width || 512,
set: (v: number) => {
modelValue.value.width = v
}
})
const cropHeight = computed({
get: () => modelValue.value.height || 512,
set: (v: number) => {
modelValue.value.height = v
}
})
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const dragStartCropX = ref(0)
const dragStartCropY = ref(0)
const isResizing = ref(false)
const resizeDirection = ref<ResizeDirection | null>(null)
const resizeStartX = ref(0)
const resizeStartY = ref(0)
const resizeStartCropX = ref(0)
const resizeStartCropY = ref(0)
const resizeStartCropWidth = ref(0)
const resizeStartCropHeight = ref(0)
useResizeObserver(containerEl, () => {
if (imageEl.value && imageUrl.value) {
updateDisplayedDimensions()
}
})
const getInputImageUrl = (): string | null => {
if (!node.value) return null
const inputNode = node.value.getInputNode(0)
if (!inputNode) return null
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
if (urls?.length) {
return urls[0]
}
return null
}
const updateImageUrl = () => {
imageUrl.value = getInputImageUrl()
}
const updateDisplayedDimensions = () => {
if (!imageEl.value || !containerEl.value) return
const img = imageEl.value
const container = containerEl.value
naturalWidth.value = img.naturalWidth
naturalHeight.value = img.naturalHeight
if (naturalWidth.value <= 0 || naturalHeight.value <= 0) {
scaleFactor.value = 1
return
}
const containerWidth = container.clientWidth
const containerHeight = container.clientHeight
const imageAspect = naturalWidth.value / naturalHeight.value
const containerAspect = containerWidth / containerHeight
if (imageAspect > containerAspect) {
displayedWidth.value = containerWidth
displayedHeight.value = containerWidth / imageAspect
imageOffsetX.value = 0
imageOffsetY.value = (containerHeight - displayedHeight.value) / 2
} else {
displayedHeight.value = containerHeight
displayedWidth.value = containerHeight * imageAspect
imageOffsetX.value = (containerWidth - displayedWidth.value) / 2
imageOffsetY.value = 0
}
if (naturalWidth.value <= 0 || displayedWidth.value <= 0) {
scaleFactor.value = 1
} else {
scaleFactor.value = displayedWidth.value / naturalWidth.value
}
}
const getEffectiveScale = (): number => {
const container = containerEl.value
if (!container || naturalWidth.value <= 0 || displayedWidth.value <= 0) {
return 1
}
const rect = container.getBoundingClientRect()
const clientWidth = container.clientWidth
if (!clientWidth || !rect.width) return 1
const renderedDisplayedWidth =
(displayedWidth.value / clientWidth) * rect.width
return renderedDisplayedWidth / naturalWidth.value
}
const cropBoxStyle = computed(() => ({
left: `${imageOffsetX.value + cropX.value * scaleFactor.value - CROP_BOX_BORDER}px`,
top: `${imageOffsetY.value + cropY.value * scaleFactor.value - CROP_BOX_BORDER}px`,
width: `${cropWidth.value * scaleFactor.value}px`,
height: `${cropHeight.value * scaleFactor.value}px`
}))
const cropImageStyle = computed(() => {
if (!imageUrl.value) return {}
return {
backgroundImage: `url(${imageUrl.value})`,
backgroundSize: `${displayedWidth.value}px ${displayedHeight.value}px`,
backgroundPosition: `-${cropX.value * scaleFactor.value}px -${cropY.value * scaleFactor.value}px`,
backgroundRepeat: 'no-repeat'
}
})
interface ResizeHandle {
direction: ResizeDirection
class: string
style: {
left: string
top: string
width?: string
height?: string
}
}
const resizeHandles = computed<ResizeHandle[]>(() => {
const x = imageOffsetX.value + cropX.value * scaleFactor.value
const y = imageOffsetY.value + cropY.value * scaleFactor.value
const w = cropWidth.value * scaleFactor.value
const h = cropHeight.value * scaleFactor.value
return [
{
direction: 'top',
class: 'h-2 cursor-ns-resize',
style: {
left: `${x + HANDLE_SIZE}px`,
top: `${y - HANDLE_SIZE / 2}px`,
width: `${Math.max(0, w - HANDLE_SIZE * 2)}px`
}
},
{
direction: 'bottom',
class: 'h-2 cursor-ns-resize',
style: {
left: `${x + HANDLE_SIZE}px`,
top: `${y + h - HANDLE_SIZE / 2}px`,
width: `${Math.max(0, w - HANDLE_SIZE * 2)}px`
}
},
{
direction: 'left',
class: 'w-2 cursor-ew-resize',
style: {
left: `${x - HANDLE_SIZE / 2}px`,
top: `${y + HANDLE_SIZE}px`,
height: `${Math.max(0, h - HANDLE_SIZE * 2)}px`
}
},
{
direction: 'right',
class: 'w-2 cursor-ew-resize',
style: {
left: `${x + w - HANDLE_SIZE / 2}px`,
top: `${y + HANDLE_SIZE}px`,
height: `${Math.max(0, h - HANDLE_SIZE * 2)}px`
}
},
{
direction: 'nw',
class: 'cursor-nwse-resize rounded-sm bg-white/80',
style: {
left: `${x - CORNER_SIZE / 2}px`,
top: `${y - CORNER_SIZE / 2}px`,
width: `${CORNER_SIZE}px`,
height: `${CORNER_SIZE}px`
}
},
{
direction: 'ne',
class: 'cursor-nesw-resize rounded-sm bg-white/80',
style: {
left: `${x + w - CORNER_SIZE / 2}px`,
top: `${y - CORNER_SIZE / 2}px`,
width: `${CORNER_SIZE}px`,
height: `${CORNER_SIZE}px`
}
},
{
direction: 'sw',
class: 'cursor-nesw-resize rounded-sm bg-white/80',
style: {
left: `${x - CORNER_SIZE / 2}px`,
top: `${y + h - CORNER_SIZE / 2}px`,
width: `${CORNER_SIZE}px`,
height: `${CORNER_SIZE}px`
}
},
{
direction: 'se',
class: 'cursor-nwse-resize rounded-sm bg-white/80',
style: {
left: `${x + w - CORNER_SIZE / 2}px`,
top: `${y + h - CORNER_SIZE / 2}px`,
width: `${CORNER_SIZE}px`,
height: `${CORNER_SIZE}px`
}
}
]
})
const handleImageLoad = () => {
isLoading.value = false
updateDisplayedDimensions()
}
const handleImageError = () => {
isLoading.value = false
imageUrl.value = null
}
const capturePointer = (e: PointerEvent) =>
(e.target as HTMLElement).setPointerCapture(e.pointerId)
const releasePointer = (e: PointerEvent) =>
(e.target as HTMLElement).releasePointerCapture(e.pointerId)
const handleDragStart = (e: PointerEvent) => {
if (!imageUrl.value) return
isDragging.value = true
dragStartX.value = e.clientX
dragStartY.value = e.clientY
dragStartCropX.value = cropX.value
dragStartCropY.value = cropY.value
capturePointer(e)
}
const handleDragMove = (e: PointerEvent) => {
if (!isDragging.value) return
const effectiveScale = getEffectiveScale()
if (effectiveScale === 0) return
const deltaX = (e.clientX - dragStartX.value) / effectiveScale
const deltaY = (e.clientY - dragStartY.value) / effectiveScale
const maxX = naturalWidth.value - cropWidth.value
const maxY = naturalHeight.value - cropHeight.value
cropX.value = Math.round(
Math.max(0, Math.min(maxX, dragStartCropX.value + deltaX))
)
cropY.value = Math.round(
Math.max(0, Math.min(maxY, dragStartCropY.value + deltaY))
)
}
const handleDragEnd = (e: PointerEvent) => {
if (!isDragging.value) return
isDragging.value = false
releasePointer(e)
}
const handleResizeStart = (e: PointerEvent, direction: ResizeDirection) => {
if (!imageUrl.value) return
e.stopPropagation()
isResizing.value = true
resizeDirection.value = direction
resizeStartX.value = e.clientX
resizeStartY.value = e.clientY
resizeStartCropX.value = cropX.value
resizeStartCropY.value = cropY.value
resizeStartCropWidth.value = cropWidth.value
resizeStartCropHeight.value = cropHeight.value
capturePointer(e)
}
const handleResizeMove = (e: PointerEvent) => {
if (!isResizing.value || !resizeDirection.value) return
const effectiveScale = getEffectiveScale()
if (effectiveScale === 0) return
const dir = resizeDirection.value
const deltaX = (e.clientX - resizeStartX.value) / effectiveScale
const deltaY = (e.clientY - resizeStartY.value) / effectiveScale
const affectsLeft = dir === 'left' || dir === 'nw' || dir === 'sw'
const affectsRight = dir === 'right' || dir === 'ne' || dir === 'se'
const affectsTop = dir === 'top' || dir === 'nw' || dir === 'ne'
const affectsBottom = dir === 'bottom' || dir === 'sw' || dir === 'se'
let newX = resizeStartCropX.value
let newY = resizeStartCropY.value
let newWidth = resizeStartCropWidth.value
let newHeight = resizeStartCropHeight.value
if (affectsLeft) {
const maxDeltaX = resizeStartCropWidth.value - MIN_CROP_SIZE
const minDeltaX = -resizeStartCropX.value
const clampedDeltaX = Math.max(minDeltaX, Math.min(maxDeltaX, deltaX))
newX = resizeStartCropX.value + clampedDeltaX
newWidth = resizeStartCropWidth.value - clampedDeltaX
} else if (affectsRight) {
const maxWidth = naturalWidth.value - resizeStartCropX.value
newWidth = Math.max(
MIN_CROP_SIZE,
Math.min(maxWidth, resizeStartCropWidth.value + deltaX)
)
}
if (affectsTop) {
const maxDeltaY = resizeStartCropHeight.value - MIN_CROP_SIZE
const minDeltaY = -resizeStartCropY.value
const clampedDeltaY = Math.max(minDeltaY, Math.min(maxDeltaY, deltaY))
newY = resizeStartCropY.value + clampedDeltaY
newHeight = resizeStartCropHeight.value - clampedDeltaY
} else if (affectsBottom) {
const maxHeight = naturalHeight.value - resizeStartCropY.value
newHeight = Math.max(
MIN_CROP_SIZE,
Math.min(maxHeight, resizeStartCropHeight.value + deltaY)
)
}
if (affectsLeft || affectsRight) {
cropX.value = Math.round(newX)
cropWidth.value = Math.round(newWidth)
}
if (affectsTop || affectsBottom) {
cropY.value = Math.round(newY)
cropHeight.value = Math.round(newHeight)
}
}
const handleResizeEnd = (e: PointerEvent) => {
if (!isResizing.value) return
isResizing.value = false
resizeDirection.value = null
releasePointer(e)
}
const initialize = () => {
if (nodeId != null) {
node.value = app.rootGraph?.getNodeById(nodeId) || null
}
updateImageUrl()
}
watch(
() => nodeOutputStore.nodeOutputs,
() => updateImageUrl(),
{ deep: true }
)
watch(
() => nodeOutputStore.nodePreviewImages,
() => updateImageUrl(),
{ deep: true }
)
onMounted(initialize)
return {
imageUrl,
isLoading,
cropX,
cropY,
cropWidth,
cropHeight,
cropBoxStyle,
cropImageStyle,
resizeHandles,
handleImageLoad,
handleImageError,
handleDragStart,
handleDragMove,
handleDragEnd,
handleResizeStart,
handleResizeMove,
handleResizeEnd
}
}

View File

@@ -511,6 +511,22 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
hasSkeleton.value = load3d?.hasSkeleton() ?? false
// Reset skeleton visibility when loading new model
modelConfig.value.showSkeleton = false
if (load3d) {
const node = nodeRef.value
const modelWidget = node?.widgets?.find(
(w) => w.name === 'model_file' || w.name === 'image'
)
const value = modelWidget?.value
if (typeof value === 'string') {
void Load3dUtils.generateThumbnailIfNeeded(
load3d,
value,
isPreview.value ? 'output' : 'input'
)
}
}
},
skeletonVisibilityChange: (value: boolean) => {
modelConfig.value.showSkeleton = value

View File

@@ -5,9 +5,13 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
AnimationItem,
BackgroundRenderModeType,
CameraConfig,
CameraState,
CameraType,
LightConfig,
MaterialMode,
ModelConfig,
SceneConfig,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
@@ -271,10 +275,18 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const sourceCameraState = source.getCameraState()
const sceneConfig = node.properties['Scene Config'] as any
const modelConfig = node.properties['Model Config'] as any
const cameraConfig = node.properties['Camera Config'] as any
const lightConfig = node.properties['Light Config'] as any
const sceneConfig = node.properties['Scene Config'] as
| SceneConfig
| undefined
const modelConfig = node.properties['Model Config'] as
| ModelConfig
| undefined
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const lightConfig = node.properties['Light Config'] as
| LightConfig
| undefined
isPreview.value = node.type === 'Preview3D'
@@ -438,7 +450,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
materialMode: initialState.value.materialMode
}
const currentCameraConfig = nodeValue.properties['Camera Config'] as any
const currentCameraConfig = nodeValue.properties['Camera Config'] as
| CameraConfig
| undefined
nodeValue.properties['Camera Config'] = {
...currentCameraConfig,
state: initialState.value.cameraState

View File

@@ -0,0 +1,381 @@
import { flushPromises } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useNodeHelpContent } from '@/composables/useNodeHelpContent'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
function createMockNode(
overrides: Partial<ComfyNodeDefImpl>
): ComfyNodeDefImpl {
return {
name: 'TestNode',
display_name: 'Test Node',
description: 'A test node',
category: 'test',
python_module: 'comfy.test_node',
inputs: {},
outputs: [],
deprecated: false,
experimental: false,
output_node: false,
api_node: false,
...overrides
} as ComfyNodeDefImpl
}
vi.mock('@/scripts/api', () => ({
api: {
fileURL: vi.fn((url) => url)
}
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
locale: ref('en')
})
}))
vi.mock('@/types/nodeSource', () => ({
NodeSourceType: {
Core: 'core',
CustomNodes: 'custom_nodes'
},
getNodeSource: vi.fn((pythonModule) => {
if (pythonModule?.startsWith('custom_nodes.')) {
return { type: 'custom_nodes' }
}
return { type: 'core' }
})
}))
describe('useNodeHelpContent', () => {
const mockCoreNode = createMockNode({
name: 'TestNode',
display_name: 'Test Node',
description: 'A test node',
python_module: 'comfy.test_node'
})
const mockCustomNode = createMockNode({
name: 'CustomNode',
display_name: 'Custom Node',
description: 'A custom node',
python_module: 'custom_nodes.test_module.custom@1.0.0'
})
const mockFetch = vi.fn()
beforeEach(() => {
mockFetch.mockReset()
vi.stubGlobal('fetch', mockFetch)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should generate correct baseUrl for core nodes', async () => {
const nodeRef = ref(mockCoreNode)
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '# Test'
})
const { baseUrl } = useNodeHelpContent(nodeRef)
await nextTick()
expect(baseUrl.value).toBe(`/docs/${mockCoreNode.name}/`)
})
it('should generate correct baseUrl for custom nodes', async () => {
const nodeRef = ref(mockCustomNode)
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '# Test'
})
const { baseUrl } = useNodeHelpContent(nodeRef)
await nextTick()
expect(baseUrl.value).toBe('/extensions/test_module/docs/')
})
it('should render markdown content correctly', async () => {
const nodeRef = ref(mockCoreNode)
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '# Test Help\nThis is test help content'
})
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain('This is test help content')
})
it('should handle fetch errors and fall back to description', async () => {
const nodeRef = ref(mockCoreNode)
mockFetch.mockResolvedValueOnce({
ok: false,
statusText: 'Not Found'
})
const { error, renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(error.value).toBe('Not Found')
expect(renderedHelpHtml.value).toContain(mockCoreNode.description)
})
it('should include alt attribute for images', async () => {
const nodeRef = ref(mockCustomNode)
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '![image](test.jpg)'
})
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain('alt="image"')
})
it('should prefix relative video src in custom nodes', async () => {
const nodeRef = ref(mockCustomNode)
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '<video src="video.mp4"></video>'
})
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
'src="/extensions/test_module/docs/video.mp4"'
)
})
it('should prefix relative video src for core nodes with node-specific base URL', async () => {
const nodeRef = ref(mockCoreNode)
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '<video src="video.mp4"></video>'
})
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
`src="/docs/${mockCoreNode.name}/video.mp4"`
)
})
it('should handle loading state', async () => {
const nodeRef = ref(mockCoreNode)
mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves
const { isLoading } = useNodeHelpContent(nodeRef)
await nextTick()
expect(isLoading.value).toBe(true)
})
it('should try fallback URL for custom nodes', async () => {
const nodeRef = ref(mockCustomNode)
mockFetch
.mockResolvedValueOnce({
ok: false,
statusText: 'Not Found'
})
.mockResolvedValueOnce({
ok: true,
text: async () => '# Fallback content'
})
useNodeHelpContent(nodeRef)
await flushPromises()
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenCalledWith(
'/extensions/test_module/docs/CustomNode/en.md'
)
expect(mockFetch).toHaveBeenCalledWith(
'/extensions/test_module/docs/CustomNode.md'
)
})
it('should prefix relative source src in custom nodes', async () => {
const nodeRef = ref(mockCustomNode)
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () =>
'<video><source src="video.mp4" type="video/mp4" /></video>'
})
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
'src="/extensions/test_module/docs/video.mp4"'
)
})
it('should prefix relative source src for core nodes with node-specific base URL', async () => {
const nodeRef = ref(mockCoreNode)
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () =>
'<video><source src="video.webm" type="video/webm" /></video>'
})
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
`src="/docs/${mockCoreNode.name}/video.webm"`
)
})
it('should prefix relative img src in raw HTML for custom nodes', async () => {
const nodeRef = ref(mockCustomNode)
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '# Test\n<img src="image.png" alt="Test image">'
})
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
'src="/extensions/test_module/docs/image.png"'
)
expect(renderedHelpHtml.value).toContain('alt="Test image"')
})
it('should prefix relative img src in raw HTML for core nodes', async () => {
const nodeRef = ref(mockCoreNode)
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '# Test\n<img src="image.png" alt="Test image">'
})
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
`src="/docs/${mockCoreNode.name}/image.png"`
)
expect(renderedHelpHtml.value).toContain('alt="Test image"')
})
it('should not prefix absolute img src in raw HTML', async () => {
const nodeRef = ref(mockCustomNode)
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '<img src="/absolute/image.png" alt="Absolute">'
})
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain('src="/absolute/image.png"')
expect(renderedHelpHtml.value).toContain('alt="Absolute"')
})
it('should not prefix external img src in raw HTML', async () => {
const nodeRef = ref(mockCustomNode)
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () =>
'<img src="https://example.com/image.png" alt="External">'
})
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
expect(renderedHelpHtml.value).toContain(
'src="https://example.com/image.png"'
)
expect(renderedHelpHtml.value).toContain('alt="External"')
})
it('should handle various quote styles in media src attributes', async () => {
const nodeRef = ref(mockCoreNode)
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => `# Media Test
Testing quote styles in properly formed HTML:
<video src="video1.mp4" controls></video>
<video src='video2.mp4' controls></video>
<img src="image1.png" alt="Double quotes">
<img src='image2.png' alt='Single quotes'>
<video controls>
<source src="video3.mp4" type="video/mp4">
<source src='video3.webm' type='video/webm'>
</video>
The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.`
})
const { renderedHelpHtml } = useNodeHelpContent(nodeRef)
await flushPromises()
// All media src attributes should be prefixed correctly
// Note: marked normalizes quotes to double quotes in output
expect(renderedHelpHtml.value).toContain(
`src="/docs/${mockCoreNode.name}/video1.mp4"`
)
expect(renderedHelpHtml.value).toContain(
`src="/docs/${mockCoreNode.name}/video2.mp4"`
)
expect(renderedHelpHtml.value).toContain(
`src="/docs/${mockCoreNode.name}/image1.png"`
)
expect(renderedHelpHtml.value).toContain(
`src="/docs/${mockCoreNode.name}/image2.png"`
)
expect(renderedHelpHtml.value).toContain(
`src="/docs/${mockCoreNode.name}/video3.mp4"`
)
expect(renderedHelpHtml.value).toContain(
`src="/docs/${mockCoreNode.name}/video3.webm"`
)
})
it('should ignore stale requests when node changes', async () => {
const nodeRef = ref(mockCoreNode)
let resolveFirst: (value: unknown) => void
const firstRequest = new Promise((resolve) => {
resolveFirst = resolve
})
mockFetch
.mockImplementationOnce(() => firstRequest)
.mockResolvedValueOnce({
ok: true,
text: async () => '# Second node content'
})
const { helpContent } = useNodeHelpContent(nodeRef)
await nextTick()
// Change node before first request completes
nodeRef.value = mockCustomNode
await nextTick()
await flushPromises()
// Now resolve the first (stale) request
resolveFirst!({
ok: true,
text: async () => '# First node content'
})
await flushPromises()
// Should have second node's content, not first
expect(helpContent.value).toBe('# Second node content')
})
})

View File

@@ -0,0 +1,79 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, ref, toValue, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { nodeHelpService } from '@/services/nodeHelpService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import { getNodeHelpBaseUrl } from '@/workbench/utils/nodeHelpUtil'
/**
* Composable for fetching and rendering node help content.
* Creates independent state for each usage, allowing multiple panels
* to show help content without interfering with each other.
*
* @param nodeRef - Reactive reference to the node to show help for
* @returns Reactive help content state and rendered HTML
*/
export function useNodeHelpContent(
nodeRef: MaybeRefOrGetter<ComfyNodeDefImpl | null>
) {
const { locale } = useI18n()
const helpContent = ref<string>('')
const isLoading = ref<boolean>(false)
const error = ref<string | null>(null)
let currentRequest: Promise<string> | null = null
const baseUrl = computed(() => {
const node = toValue(nodeRef)
if (!node) return ''
return getNodeHelpBaseUrl(node)
})
const renderedHelpHtml = computed(() => {
return renderMarkdownToHtml(helpContent.value, baseUrl.value)
})
// Watch for node changes and fetch help content
watch(
() => toValue(nodeRef),
async (node) => {
helpContent.value = ''
error.value = null
if (node) {
isLoading.value = true
const request = (currentRequest = nodeHelpService.fetchNodeHelp(
node,
locale.value || 'en'
))
try {
const content = await request
if (currentRequest !== request) return
helpContent.value = content
} catch (e: unknown) {
if (currentRequest !== request) return
error.value = e instanceof Error ? e.message : String(e)
helpContent.value = node.description || ''
} finally {
if (currentRequest === request) {
currentRequest = null
isLoading.value = false
}
}
}
},
{ immediate: true }
)
return {
helpContent,
isLoading,
error,
baseUrl,
renderedHelpHtml
}
}

View File

@@ -368,7 +368,7 @@ export class GroupNodeConfig {
}
getNodeDef(
node: GroupNodeData
node: GroupNodeData | GroupNodeWorkflowData['nodes'][number]
): GroupNodeDef | ComfyNodeDef | null | undefined {
if (node.type) {
const def = globalDefs[node.type]
@@ -386,7 +386,8 @@ export class GroupNodeConfig {
let type: string | number | null = linksFrom[0]?.[0]?.[5] ?? null
if (type === 'COMBO') {
// Use the array items
const source = node.outputs?.[0]?.widget?.name
const output = node.outputs?.[0] as GroupNodeOutput | undefined
const source = output?.widget?.name
const nodeIdx = linksFrom[0]?.[0]?.[2]
if (source && nodeIdx != null) {
const fromTypeName = this.nodeData.nodes[Number(nodeIdx)]?.type

View File

@@ -1,9 +1,11 @@
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
import {
type LGraphNode,
type LGraphNodeConstructor,
LiteGraph
import type {
GroupNodeConfigEntry,
GroupNodeWorkflowData,
LGraphNode,
LGraphNodeConstructor
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { type ComfyApp, app } from '../../scripts/app'
@@ -15,18 +17,20 @@ import './groupNodeManage.css'
const ORDER: symbol = Symbol()
// @ts-expect-error fixme ts strict error
function merge(target, source) {
if (typeof target === 'object' && typeof source === 'object') {
for (const key in source) {
const sv = source[key]
if (typeof sv === 'object') {
let tv = target[key]
if (!tv) tv = target[key] = {}
merge(tv, source[key])
} else {
target[key] = sv
function merge(
target: Record<string, unknown>,
source: Record<string, unknown>
): Record<string, unknown> {
for (const key in source) {
const sv = source[key]
if (typeof sv === 'object' && sv !== null) {
let tv = target[key] as Record<string, unknown> | undefined
if (!tv) {
tv = target[key] = {}
}
merge(tv, sv as Record<string, unknown>)
} else {
target[key] = sv
}
}
@@ -34,8 +38,7 @@ function merge(target, source) {
}
export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
// @ts-expect-error fixme ts strict error
tabs: Record<
tabs!: Record<
'Inputs' | 'Outputs' | 'Widgets',
{ tab: HTMLAnchorElement; page: HTMLElement }
>
@@ -52,31 +55,26 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
>
>
> = {}
// @ts-expect-error fixme ts strict error
nodeItems: any[]
nodeItems!: HTMLLIElement[]
app: ComfyApp
// @ts-expect-error fixme ts strict error
groupNodeType: LGraphNodeConstructor<LGraphNode>
groupNodeDef: any
groupData: any
groupNodeType!: LGraphNodeConstructor<LGraphNode>
groupData!: GroupNodeConfig
// @ts-expect-error fixme ts strict error
innerNodesList: HTMLUListElement
// @ts-expect-error fixme ts strict error
widgetsPage: HTMLElement
// @ts-expect-error fixme ts strict error
inputsPage: HTMLElement
// @ts-expect-error fixme ts strict error
outputsPage: HTMLElement
draggable: any
innerNodesList!: HTMLUListElement
widgetsPage!: HTMLElement
inputsPage!: HTMLElement
outputsPage!: HTMLElement
draggable: DraggableList | undefined
get selectedNodeInnerIndex() {
// @ts-expect-error fixme ts strict error
return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex
get selectedNodeInnerIndex(): number {
const index = this.selectedNodeIndex
if (index == null) throw new Error('No node selected')
const item = this.nodeItems[index]
if (!item?.dataset.nodeindex) throw new Error('Invalid node item')
return +item.dataset.nodeindex
}
// @ts-expect-error fixme ts strict error
constructor(app) {
constructor(app: ComfyApp) {
super()
this.app = app
this.element = $el('dialog.comfy-group-manage', {
@@ -84,19 +82,15 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
}) as HTMLDialogElement
}
// @ts-expect-error fixme ts strict error
changeTab(tab) {
changeTab(tab: keyof ManageGroupDialog['tabs']): void {
this.tabs[this.selectedTab].tab.classList.remove('active')
this.tabs[this.selectedTab].page.classList.remove('active')
// @ts-expect-error fixme ts strict error
this.tabs[tab].tab.classList.add('active')
// @ts-expect-error fixme ts strict error
this.tabs[tab].page.classList.add('active')
this.selectedTab = tab
}
// @ts-expect-error fixme ts strict error
changeNode(index, force?) {
changeNode(index: number, force?: boolean): void {
if (!force && this.selectedNodeIndex === index) return
if (this.selectedNodeIndex != null) {
@@ -122,43 +116,41 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
this.groupNodeType = LiteGraph.registered_node_types[
`${PREFIX}${SEPARATOR}` + this.selectedGroup
] as unknown as LGraphNodeConstructor<LGraphNode>
this.groupNodeDef = this.groupNodeType.nodeData
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType)
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType)!
}
// @ts-expect-error fixme ts strict error
changeGroup(group, reset = true) {
changeGroup(group: string, reset = true): void {
this.selectedGroup = group
this.getGroupData()
const nodes = this.groupData.nodeData.nodes
// @ts-expect-error fixme ts strict error
this.nodeItems = nodes.map((n, i) =>
$el(
'li.draggable-item',
{
dataset: {
nodeindex: n.index + ''
},
onclick: () => {
this.changeNode(i)
}
},
[
$el('span.drag-handle'),
$el(
'div',
{
textContent: n.title ?? n.type
this.nodeItems = nodes.map(
(n, i) =>
$el(
'li.draggable-item',
{
dataset: {
nodeindex: n.index + ''
},
n.title
? $el('span', {
textContent: n.type
})
: []
)
]
)
onclick: () => {
this.changeNode(i)
}
},
[
$el('span.drag-handle'),
$el(
'div',
{
textContent: n.title ?? n.type
},
n.title
? $el('span', {
textContent: n.type
})
: []
)
]
) as HTMLLIElement
)
this.innerNodesList.replaceChildren(...this.nodeItems)
@@ -167,47 +159,46 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
this.selectedNodeIndex = null
this.changeNode(0)
} else {
const items = this.draggable.getAllItems()
// @ts-expect-error fixme ts strict error
let index = items.findIndex((item) => item.classList.contains('selected'))
if (index === -1) index = this.selectedNodeIndex
const items = this.draggable!.getAllItems()
let index = items.findIndex((item: Element) =>
item.classList.contains('selected')
)
if (index === -1) index = this.selectedNodeIndex!
this.changeNode(index, true)
}
const ordered = [...nodes]
this.draggable?.dispose()
this.draggable = new DraggableList(this.innerNodesList, 'li')
this.draggable.addEventListener(
'dragend',
// @ts-expect-error fixme ts strict error
({ detail: { oldPosition, newPosition } }) => {
if (oldPosition === newPosition) return
ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0])
for (let i = 0; i < ordered.length; i++) {
this.storeModification({
nodeIndex: ordered[i].index,
section: ORDER,
prop: 'order',
value: i
})
}
this.draggable.addEventListener('dragend', (e: Event) => {
const { oldPosition, newPosition } = (e as CustomEvent).detail
if (oldPosition === newPosition) return
ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0])
for (let i = 0; i < ordered.length; i++) {
this.storeModification({
nodeIndex: ordered[i].index,
section: ORDER,
prop: 'order',
value: i
})
}
)
})
}
storeModification(props: {
nodeIndex?: number
section: symbol
section: string | symbol
prop: string
value: any
value: unknown
}) {
const { nodeIndex, section, prop, value } = props
// @ts-expect-error fixme ts strict error
const groupMod = (this.modifications[this.selectedGroup] ??= {})
const nodesMod = (groupMod.nodes ??= {})
const groupKey = this.selectedGroup!
const groupMod = (this.modifications[groupKey] ??= {})
const nodesMod = ((groupMod as Record<string, unknown>).nodes ??=
{}) as Record<string, Record<symbol | string, Record<string, unknown>>>
const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {})
const typeMod = (nodeMod[section] ??= {})
if (typeof value === 'object') {
if (typeof value === 'object' && value !== null) {
const objMod = (typeMod[prop] ??= {})
Object.assign(objMod, value)
} else {
@@ -215,35 +206,45 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
}
}
// @ts-expect-error fixme ts strict error
getEditElement(section, prop, value, placeholder, checked, checkable = true) {
if (value === placeholder) value = ''
getEditElement(
section: string,
prop: string | number,
value: unknown,
placeholder: string,
checked: boolean,
checkable = true
): HTMLDivElement {
let displayValue = value === placeholder ? '' : value
const mods =
// @ts-expect-error fixme ts strict error
this.modifications[this.selectedGroup]?.nodes?.[
this.selectedNodeInnerIndex
]?.[section]?.[prop]
if (mods) {
if (mods.name != null) {
value = mods.name
const groupKey = this.selectedGroup!
const mods = (
this.modifications[groupKey] as Record<string, unknown> | undefined
)?.nodes as
| Record<
number,
Record<string, Record<string, { name?: string; visible?: boolean }>>
>
| undefined
const modEntry = mods?.[this.selectedNodeInnerIndex]?.[section]?.[prop]
if (modEntry) {
if (modEntry.name != null) {
displayValue = modEntry.name
}
if (mods.visible != null) {
checked = mods.visible
if (modEntry.visible != null) {
checked = modEntry.visible
}
}
return $el('div', [
$el('input', {
value,
value: displayValue as string,
placeholder,
type: 'text',
// @ts-expect-error fixme ts strict error
onchange: (e) => {
onchange: (e: Event) => {
this.storeModification({
section,
prop,
value: { name: e.target.value }
prop: String(prop),
value: { name: (e.target as HTMLInputElement).value }
})
}
}),
@@ -252,25 +253,23 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
type: 'checkbox',
checked,
disabled: !checkable,
// @ts-expect-error fixme ts strict error
onchange: (e) => {
onchange: (e: Event) => {
this.storeModification({
section,
prop,
value: { visible: !!e.target.checked }
prop: String(prop),
value: { visible: !!(e.target as HTMLInputElement).checked }
})
}
})
])
])
]) as HTMLDivElement
}
buildWidgetsPage() {
const widgets =
this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex]
const items = Object.keys(widgets ?? {})
// @ts-expect-error fixme ts strict error
const type = app.rootGraph.extra.groupNodes[this.selectedGroup]
const type = app.rootGraph.extra.groupNodes![this.selectedGroup!]!
const config = type.config?.[this.selectedNodeInnerIndex]?.input
this.widgetsPage.replaceChildren(
...items.map((oldName) => {
@@ -289,28 +288,25 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
buildInputsPage() {
const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex]
const items = Object.keys(inputs ?? {})
// @ts-expect-error fixme ts strict error
const type = app.rootGraph.extra.groupNodes[this.selectedGroup]
const type = app.rootGraph.extra.groupNodes![this.selectedGroup!]!
const config = type.config?.[this.selectedNodeInnerIndex]?.input
this.inputsPage.replaceChildren(
// @ts-expect-error fixme ts strict error
...items
.map((oldName) => {
let value = inputs[oldName]
if (!value) {
return
}
const elements = items
.map((oldName) => {
const value = inputs[oldName]
if (!value) {
return null
}
return this.getEditElement(
'input',
oldName,
value,
oldName,
config?.[oldName]?.visible !== false
)
})
.filter(Boolean)
)
return this.getEditElement(
'input',
oldName,
value,
oldName,
config?.[oldName]?.visible !== false
)
})
.filter((el): el is HTMLDivElement => el !== null)
this.inputsPage.replaceChildren(...elements)
return !!items.length
}
@@ -323,38 +319,35 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
const groupOutputs =
this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex]
// @ts-expect-error fixme ts strict error
const type = app.rootGraph.extra.groupNodes[this.selectedGroup]
const type = app.rootGraph.extra.groupNodes![this.selectedGroup!]!
const config = type.config?.[this.selectedNodeInnerIndex]?.output
const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex]
const checkable = node.type !== 'PrimitiveNode'
this.outputsPage.replaceChildren(
...outputs
// @ts-expect-error fixme ts strict error
.map((type, slot) => {
const groupOutputIndex = groupOutputs?.[slot]
const oldName = innerNodeDef.output_name?.[slot] ?? type
let value = config?.[slot]?.name
const visible = config?.[slot]?.visible || groupOutputIndex != null
if (!value || value === oldName) {
value = ''
}
return this.getEditElement(
'output',
slot,
value,
oldName,
visible,
checkable
)
})
.filter(Boolean)
)
const elements = outputs.map((outputType: unknown, slot: number) => {
const groupOutputIndex = groupOutputs?.[slot]
const oldName = innerNodeDef?.output_name?.[slot] ?? String(outputType)
let value = config?.[slot]?.name
const visible = config?.[slot]?.visible || groupOutputIndex != null
if (!value || value === oldName) {
value = ''
}
return this.getEditElement(
'output',
slot,
value,
oldName,
visible,
checkable
)
})
this.outputsPage.replaceChildren(...elements)
return !!outputs.length
}
// @ts-expect-error fixme ts strict error
show(type?) {
override show(groupNodeType?: string | HTMLElement | HTMLElement[]): void {
// Extract string type - this method repurposes the show signature
const nodeType =
typeof groupNodeType === 'string' ? groupNodeType : undefined
const groupNodes = Object.keys(app.rootGraph.extra?.groupNodes ?? {}).sort(
(a, b) => a.localeCompare(b)
)
@@ -371,24 +364,27 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
this.outputsPage
])
this.tabs = [
type TabName = 'Inputs' | 'Widgets' | 'Outputs'
const tabEntries: [TabName, HTMLElement][] = [
['Inputs', this.inputsPage],
['Widgets', this.widgetsPage],
['Outputs', this.outputsPage]
// @ts-expect-error fixme ts strict error
].reduce((p, [name, page]: [string, HTMLElement]) => {
// @ts-expect-error fixme ts strict error
p[name] = {
tab: $el('a', {
onclick: () => {
this.changeTab(name)
},
textContent: name
}),
page
}
return p
}, {}) as any
]
this.tabs = tabEntries.reduce(
(p, [name, page]) => {
p[name] = {
tab: $el('a', {
onclick: () => {
this.changeTab(name)
},
textContent: name
}) as HTMLAnchorElement,
page
}
return p
},
{} as ManageGroupDialog['tabs']
)
const outer = $el('div.comfy-group-manage-outer', [
$el('header', [
@@ -396,15 +392,14 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
$el(
'select',
{
// @ts-expect-error fixme ts strict error
onchange: (e) => {
this.changeGroup(e.target.value)
onchange: (e: Event) => {
this.changeGroup((e.target as HTMLSelectElement).value)
}
},
groupNodes.map((g) =>
$el('option', {
textContent: g,
selected: `${PREFIX}${SEPARATOR}${g}` === type,
selected: `${PREFIX}${SEPARATOR}${g}` === nodeType,
value: g
})
)
@@ -439,8 +434,7 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
`Are you sure you want to remove the node: "${this.selectedGroup}"`
)
) {
// @ts-expect-error fixme ts strict error
delete app.rootGraph.extra.groupNodes[this.selectedGroup]
delete app.rootGraph.extra.groupNodes![this.selectedGroup!]
LiteGraph.unregisterNodeType(
`${PREFIX}${SEPARATOR}` + this.selectedGroup
)
@@ -454,97 +448,106 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
'button.comfy-btn',
{
onclick: async () => {
let nodesByType
let recreateNodes = []
const types = {}
type NodesByType = Record<string, LGraphNode[]>
let nodesByType: NodesByType | undefined
const recreateNodes: LGraphNode[] = []
const types: Record<string, GroupNodeWorkflowData> = {}
for (const g in this.modifications) {
// @ts-expect-error fixme ts strict error
const type = app.rootGraph.extra.groupNodes[g]
let config = (type.config ??= {})
const groupNodeData = app.rootGraph.extra.groupNodes![g]!
let config = (groupNodeData.config ??= {})
let nodeMods = this.modifications[g]?.nodes
type NodeMods = Record<
string,
Record<symbol | string, Record<string, unknown>>
>
let nodeMods = this.modifications[g]?.nodes as
| NodeMods
| undefined
if (nodeMods) {
const keys = Object.keys(nodeMods)
// @ts-expect-error fixme ts strict error
if (nodeMods[keys[0]][ORDER]) {
if (nodeMods[keys[0]]?.[ORDER]) {
// If any node is reordered, they will all need sequencing
const orderedNodes = []
const orderedMods = {}
const orderedConfig = {}
const orderedNodes: GroupNodeWorkflowData['nodes'] = []
const orderedMods: NodeMods = {}
const orderedConfig: Record<number, GroupNodeConfigEntry> =
{}
for (const n of keys) {
// @ts-expect-error fixme ts strict error
const order = nodeMods[n][ORDER].order
orderedNodes[order] = type.nodes[+n]
// @ts-expect-error fixme ts strict error
const order = (nodeMods[n][ORDER] as { order: number })
.order
orderedNodes[order] = groupNodeData.nodes[+n]
orderedMods[order] = nodeMods[n]
orderedNodes[order].index = order
}
// Rewrite links
for (const l of type.links) {
// @ts-expect-error l[0]/l[2] used as node index
if (l[0] != null) l[0] = type.nodes[l[0]].index
// @ts-expect-error l[0]/l[2] used as node index
if (l[2] != null) l[2] = type.nodes[l[2]].index
const nodesLen = groupNodeData.nodes.length
for (const l of groupNodeData.links) {
const srcIdx = l[0] as number
const dstIdx = l[2] as number
if (srcIdx != null && srcIdx < nodesLen)
l[0] = groupNodeData.nodes[srcIdx].index!
if (dstIdx != null && dstIdx < nodesLen)
l[2] = groupNodeData.nodes[dstIdx].index!
}
// Rewrite externals
if (type.external) {
for (const ext of type.external) {
if (ext[0] != null) {
// @ts-expect-error ext[0] used as node index
ext[0] = type.nodes[ext[0]].index
if (groupNodeData.external) {
for (const ext of groupNodeData.external) {
const extIdx = ext[0] as number
if (extIdx != null && extIdx < nodesLen) {
ext[0] = groupNodeData.nodes[extIdx].index!
}
}
}
// Rewrite modifications
for (const id of keys) {
// @ts-expect-error id used as node index
if (config[id]) {
// @ts-expect-error fixme ts strict error
orderedConfig[type.nodes[id].index] = config[id]
if (config[+id]) {
orderedConfig[groupNodeData.nodes[+id].index!] =
config[+id]
}
// @ts-expect-error id used as config key
delete config[id]
delete config[+id]
}
type.nodes = orderedNodes
groupNodeData.nodes = orderedNodes
nodeMods = orderedMods
type.config = config = orderedConfig
groupNodeData.config = config = orderedConfig
}
merge(config, nodeMods)
merge(
config as Record<string, unknown>,
nodeMods as Record<string, unknown>
)
}
// @ts-expect-error fixme ts strict error
types[g] = type
types[g] = groupNodeData
if (!nodesByType) {
nodesByType = app.rootGraph.nodes.reduce((p, n) => {
// @ts-expect-error fixme ts strict error
p[n.type] ??= []
// @ts-expect-error fixme ts strict error
p[n.type].push(n)
return p
}, {})
nodesByType = app.rootGraph.nodes.reduce<NodesByType>(
(p, n) => {
const nodeType = n.type ?? ''
p[nodeType] ??= []
p[nodeType].push(n)
return p
},
{}
)
}
// @ts-expect-error fixme ts strict error
const nodes = nodesByType[`${PREFIX}${SEPARATOR}` + g]
if (nodes) recreateNodes.push(...nodes)
const groupTypeNodes = nodesByType[`${PREFIX}${SEPARATOR}` + g]
if (groupTypeNodes) recreateNodes.push(...groupTypeNodes)
}
await GroupNodeConfig.registerFromWorkflow(types, [])
for (const node of recreateNodes) {
node.recreate()
node.recreate?.()
}
this.modifications = {}
this.app.canvas.setDirty(true, true)
this.changeGroup(this.selectedGroup, false)
this.changeGroup(this.selectedGroup!, false)
}
},
'Save'
@@ -559,8 +562,8 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
this.element.replaceChildren(outer)
this.changeGroup(
type
? (groupNodes.find((g) => `${PREFIX}${SEPARATOR}${g}` === type) ??
nodeType
? (groupNodes.find((g) => `${PREFIX}${SEPARATOR}${g}` === nodeType) ??
groupNodes[0])
: groupNodes[0]
)

View File

@@ -1,8 +1,13 @@
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
type ImageCompareOutput = NodeOutputWith<{
a_images?: Record<string, string>[]
b_images?: Record<string, string>[]
}>
useExtensionService().registerExtension({
name: 'Comfy.ImageCompare',
@@ -14,15 +19,10 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
node.onExecuted = function (output: NodeExecutionOutput) {
node.onExecuted = function (output: ImageCompareOutput) {
onExecuted?.call(this, output)
const aImages = (output as Record<string, unknown>).a_images as
| Record<string, string>[]
| undefined
const bImages = (output as Record<string, unknown>).b_images as
| Record<string, string>[]
| undefined
const { a_images: aImages, b_images: bImages } = output
const rand = app.getRandParam()
const beforeUrl =

View File

@@ -0,0 +1,12 @@
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.ImageCrop',
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'ImageCrop') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 450)])
}
})

View File

@@ -10,6 +10,7 @@ import './groupNode'
import './groupNodeManage'
import './groupOptions'
import './imageCompare'
import './imageCrop'
import './load3d'
import './maskeditor'
import './nodeTemplates'

View File

@@ -15,7 +15,11 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { IStringWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import type { NodeOutputWith } from '@/schemas/apiSchema'
type Load3dPreviewOutput = NodeOutputWith<{
result?: [string?, CameraState?, string?]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
import { ComfyApp, app } from '@/scripts/app'
@@ -496,13 +500,11 @@ useExtensionService().registerExtension({
config.configure(settings)
}
node.onExecuted = function (output: NodeExecutionOutput) {
node.onExecuted = function (output: Load3dPreviewOutput) {
onExecuted?.call(this, output)
const result = (output as Record<string, unknown>).result as
| unknown[]
| undefined
const filePath = result?.[0] as string | undefined
const result = output.result
const filePath = result?.[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
@@ -510,8 +512,8 @@ useExtensionService().registerExtension({
useToastStore().addAlert(msg)
}
const cameraState = result?.[1] as CameraState | undefined
const bgImagePath = result?.[2] as string | undefined
const cameraState = result?.[1]
const bgImagePath = result?.[2]
modelWidget.value = filePath?.replaceAll('\\', '/')

View File

@@ -754,6 +754,60 @@ class Load3d {
this.forceRender()
}
public async captureThumbnail(
width: number = 256,
height: number = 256
): Promise<string> {
if (!this.modelManager.currentModel) {
throw new Error('No model loaded for thumbnail capture')
}
const savedState = this.cameraManager.getCameraState()
const savedCameraType = this.cameraManager.getCurrentCameraType()
const savedGridVisible = this.sceneManager.gridHelper.visible
try {
this.sceneManager.gridHelper.visible = false
if (savedCameraType !== 'perspective') {
this.cameraManager.toggleCamera('perspective')
}
const box = new THREE.Box3().setFromObject(this.modelManager.currentModel)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const distance = maxDim * 1.5
const cameraPosition = new THREE.Vector3(
center.x - distance * 0.8,
center.y + distance * 0.4,
center.z + distance * 0.3
)
this.cameraManager.perspectiveCamera.position.copy(cameraPosition)
this.cameraManager.perspectiveCamera.lookAt(center)
this.cameraManager.perspectiveCamera.updateProjectionMatrix()
if (this.controlsManager.controls) {
this.controlsManager.controls.target.copy(center)
this.controlsManager.controls.update()
}
const result = await this.sceneManager.captureScene(width, height)
return result.scene
} finally {
this.sceneManager.gridHelper.visible = savedGridVisible
if (savedCameraType !== 'perspective') {
this.cameraManager.toggleCamera(savedCameraType)
}
this.cameraManager.setCameraState(savedState)
this.controlsManager.controls?.update()
}
}
public remove(): void {
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()

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