Compare commits

...

43 Commits

Author SHA1 Message Date
Comfy Org PR Bot
32b6e58859 [backport cloud/1.40] feat: add cloud frontend build dispatch workflow (#9462)
Backport of #9308 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9462-backport-cloud-1-40-feat-add-cloud-frontend-build-dispatch-workflow-31b6d73d36508155854afa23906a9ffd)
by [Unito](https://www.unito.io)

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-03-06 05:14:29 +00:00
Robin Huang
10a680f690 [backport cloud/1.40] feat: Add PostHog telemetry provider (#9457)
Backport of #9409 to `cloud/1.40`.

Automatically created by manual backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9457-backport-cloud-1-40-feat-Add-PostHog-telemetry-provider-31b6d73d365081ac9eb4d596daafddf3)
by [Unito](https://www.unito.io)
2026-03-05 20:54:12 -08:00
Alexander Brown
416f96649b [backport cloud/1.40] feat: workflow sharing and ComfyHub publish flow (#9454)
Backport of #8951 to `cloud/1.40`.

Cherry-pick of merge commit `1bac5d9bddd2106b04f330733a45094df379b592`
with conflict resolution.

## Conflict Resolution

- **TopMenuSection.vue**: Kept both queue context menu (from target) and
share tooltip/button (from PR)
- **StatusBadge.vue**: Accepted PR version (adds `class` prop,
`badgeClass` computed with `cn()`)
- **WorkflowTab.vue**: Kept target branch version — PR's context menu
additions depend on `WorkflowActionsList.vue` and
`types/workflowMenuItem` which don't exist on `cloud/1.40`
- **composables/README.md**: Merged both — kept `useValueTransform` from
target, used `useWorkflowPersistenceV2` from PR
- **Textarea.vue**: Accepted PR version (new file, deleted in target)
- **Binary snapshot**: Accepted PR version
- **pnpm-lock.yaml**: Regenerated with `pnpm install`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9454-backport-cloud-1-40-feat-workflow-sharing-and-ComfyHub-publish-flow-31b6d73d3650813ebd55e0d2a24860ec)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-06 02:42:47 +00:00
Comfy Org PR Bot
964002cd2a [backport cloud/1.40] [bugfix] Fix workspace dialog pt override losing base styles (#9366)
Backport of #9188 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9366-backport-cloud-1-40-bugfix-Fix-workspace-dialog-pt-override-losing-base-styles-3196d73d3650814e84f1f22dd2e6c10a)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-03 18:37:09 -08:00
Hunter
130e1eed7a [backport cloud/1.40] feat: add ever-present upgrade button for free-tier users (#9316)
Backport of #9315 to `cloud/1.40`

Automatically created by manual backport.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9316-backport-cloud-1-40-feat-add-ever-present-upgrade-button-for-free-tier-users-3166d73d365081039257c489cabc6b38)
by [Unito](https://www.unito.io)
2026-02-28 20:38:41 -08:00
Comfy Org PR Bot
fdda951516 [backport cloud/1.40] fix: remove beta labeling from comfy cloud badges (#9280)
Backport of #9184 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9280-backport-cloud-1-40-fix-remove-beta-labeling-from-comfy-cloud-badges-3146d73d365081129135db83e379de7f)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-02-28 04:15:56 -08:00
Comfy Org PR Bot
23f26efd94 [backport cloud/1.40] fix: add GLSLShader to toolkit node telemetry tracking (#9240)
Backport of #9197 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9240-backport-cloud-1-40-fix-add-GLSLShader-to-toolkit-node-telemetry-tracking-3136d73d365081f6be37e4414eccae20)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-25 23:42:55 -08:00
Comfy Org PR Bot
afbdea2956 [backport cloud/1.40] feat: add toolkit node tracking to execution telemetry (#9238)
Backport of #9073 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9238-backport-cloud-1-40-feat-add-toolkit-node-tracking-to-execution-telemetry-3136d73d365081c99c09f79ffde87773)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-25 23:00:03 -08:00
Comfy Org PR Bot
c46a95bd90 [backport cloud/1.40] Prevent serialization of progress text to prompt (#9225)
Backport of #9221 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9225-backport-cloud-1-40-Prevent-serialization-of-progress-text-to-prompt-3136d73d365081f29603f360cf6edded)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-02-25 17:38:39 -08:00
Comfy Org PR Bot
58460ada48 [backport cloud/1.40] feat: add Free subscription tier support (#9190)
Backport of #8864 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9190-backport-cloud-1-40-feat-add-Free-subscription-tier-support-3126d73d36508153b889ddc3c5d01f57)
by [Unito](https://www.unito.io)

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-02-24 23:49:58 -05:00
Comfy Org PR Bot
9ba3d75c5b [backport cloud/1.40] fix: prevent infinite node resize loop in Vue mode (#9179)
Backport of #9177 to `cloud/1.40`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-02-24 21:18:38 +00:00
AustinMroz
c7cbefc256 [backport cloud/1.40] fix: sync DOM widget values to widgetValueStore on registration (#9174)
Backport of #9166 to cloud/1.40

Manually created.

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-02-24 10:47:43 -08:00
Comfy Org PR Bot
4daba09415 [backport cloud/1.40] fix: open image in new tab on cloud fetches as blob to avoid GCS auto-download (#9158)
Backport of #9122 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9158-backport-cloud-1-40-fix-open-image-in-new-tab-on-cloud-fetches-as-blob-to-avoid-GCS-au-3116d73d365081789cc2fffb3fb538f3)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-23 22:13:19 -08:00
Comfy Org PR Bot
b6ca126eff [backport cloud/1.40] fix: fix error overlay and TabErrors filtering for nested subgraphs (#9133)
Backport of #9129 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9133-backport-cloud-1-40-fix-fix-error-overlay-and-TabErrors-filtering-for-nested-subgraphs-3106d73d3650818cb0d9d8e96e60ae37)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-02-23 04:14:00 -08:00
Comfy Org PR Bot
d4f6a9af0e [backport cloud/1.40] [refactor] Extract executionErrorStore from executionStore (#9131)
Backport of #9060 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9131-backport-cloud-1-40-refactor-Extract-executionErrorStore-from-executionStore-3106d73d3650815e8713d30979eed798)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-02-23 04:01:08 -08:00
Comfy Org PR Bot
b654d7c06a [backport cloud/1.40] feat(node): show Enter Subgraph and Error buttons side by side in node footer (#9128)
Backport of #9126 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9128-backport-cloud-1-40-feat-node-show-Enter-Subgraph-and-Error-buttons-side-by-side-in-n-3106d73d3650818e8a61c8c6efab2a93)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-02-23 01:18:43 -08:00
Benjamin Lu
d3b67511f9 fix: backport load 3D dialog defineAsyncComponent to cloud/1.40 (#9092)
## Summary
Backport the `defineAsyncComponent` fix from #8990 to `cloud/1.40` so
the 3D inspect dialog renders the component instead of a promise
payload.

## Changes
- Add `defineAsyncComponent` import in `AssetsSidebarTab.vue`
- Wrap `Load3dViewerContent` lazy import with
`defineAsyncComponent(...)`

## Validation
- `pnpm typecheck`
- `pnpm lint`
- pre-push hook (`pnpm knip --cache`) ran during `git push`

Backports #8990.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9092-fix-backport-load-3D-dialog-defineAsyncComponent-to-cloud-1-40-30f6d73d365081eb9ea6f2d771cbd762)
by [Unito](https://www.unito.io)
2026-02-22 02:03:54 -08:00
Comfy Org PR Bot
92a193203d [backport cloud/1.40] feat: add feature flag to disable Essentials tab in node library (#9082)
Backport of #9067 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9082-backport-cloud-1-40-feat-add-feature-flag-to-disable-Essentials-tab-in-node-library-30f6d73d3650818ab63dd02841657bec)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-02-21 22:29:52 -08:00
Benjamin Lu
25880aa024 fix: update asset video previews for card and list (#8908)
## Summary
Render generated video previews in list items using a real video element (instead of an image element (this caused errors before)) and include a custom play button with dimming per [the designs](https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3928-39270&m=dev).

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

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

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

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

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

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

## Changes

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

## How It Works

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

## Migration

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

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

---------

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

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9058-1-40-10-30e6d73d36508133b358d9f35840055a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-21 00:57:11 -08:00
Christian Byrne
39af93ae3e feat: read category from blueprint subgraph definition (#9053)
Read `category` from `definitions.subgraphs[0].category` in blueprint
JSON files as a fallback default for node categorization.

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

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

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

---------

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

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

### Why 1500 instead of 10000?

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

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

- Fixes #4573
### test

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

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

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

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

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

## Changes

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

## Review Focus

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

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

Fixes COM-15200

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

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

## Changes

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

## Review Focus

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

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

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


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

## Root Cause

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

## Solution

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

## Changes

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

## Testing

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

Fixes COM-15168

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



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

Fixes #4006

## Test plan

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

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

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

## Changes

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

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

## Review Focus

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

## Screenshots (if applicable)

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



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

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

---------

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

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9034-1-40-9-30e6d73d365081a1b1e4e7a1c0b77629)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-20 20:16:39 -08:00
Christian Byrne
c1a569211d fix: skip MatchType recalculation during graph configuration (#9004)
## Summary

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

## Problem

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

## Solution

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

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

## Changes

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

## Testing

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

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

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

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

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

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

## Changes

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

## Review Focus

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

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

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

## Problem

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

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

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

## Changes

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9038-fix-restore-strictExecutionOrder-for-Storybook-Chromatic-builds-30e6d73d365081489c53cea131b97a2f)
by [Unito](https://www.unito.io)
2026-02-20 19:03:06 -08:00
Jin Yi
4e9e3a0c26 [bugfix] Fix workspace settings member layout alignment (#9008) 2026-02-21 11:29:21 +09:00
Terry Jia
f7a83f6dfa feat: add gradient-slider widget for FLOAT inputs (#8992)
## Summary
Add a new 'gradient-slider' display mode for FLOAT widget inputs. Nodes
can specify gradient_stops (color stop arrays) to render a colored
gradient track behind the slider thumb, useful for color adjustment
parameters like hue, saturation, brightness, etc.

- GradientSlider.vue: reusable Reka UI-based gradient slider component
- GradientSliderWidget.ts: litegraph canvas-mode fallback rendering
- WidgetInputNumberGradientSlider.vue: Vue node widget integration
- Schema, registry, and type updates for gradient-slider support

this is prerequisite for color correct and balance

BE changes https://github.com/Comfy-Org/ComfyUI/pull/12536

## Screenshots (if applicable)

<img width="610" height="237" alt="image"
src="https://github.com/user-attachments/assets/b0577ca8-8576-4062-8f14-0a3612e56242"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8992-feat-add-gradient-slider-widget-for-FLOAT-inputs-30d6d73d36508199b3e8db6a0c213ab4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-20 20:51:10 -05:00
Christian Byrne
4103379901 fix: intercept window.open in zendesk test to avoid external network dependency (#9035)
The zendesk support test was flaky because it waited for `networkidle`
on an external Zendesk page (`support.comfy.org`). External network
requests in CI are unreliable — they can timeout, be slow, or redirect.

**Fix:** Intercept `window.open` to capture the URL that would be
opened, without actually navigating to the external page. This makes the
test deterministic and fast.

- Fixes flaky test: `[chromium] ›
browser_tests/tests/dialog.spec.ts:339:3 › Support › Should open
external zendesk link with OSS tag`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9035-fix-intercept-window-open-in-zendesk-test-to-avoid-external-network-dependency-30e6d73d365081c2a021edd816b8c1d0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-20 17:28:40 -08:00
Christian Byrne
337e0486ea feat: client-side distribution filtering for blueprint subgraphs (#8686)
Adds client-side filtering of blueprint subgraphs by distribution.

**Changes:**
- Added `includeOnDistributions` typed field to `GlobalSubgraphData` in
`api.ts`
- Distribution detection: `isCloud → 'cloud'`, `isDesktop → 'desktop'`,
else `'localhost'`
- Filters subgraphs before loading — excluded blueprints are never
fetched

**Depends on:** Comfy-Org/workflow_templates schema update (merge first)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8686-feat-client-side-distribution-filtering-for-blueprint-subgraphs-2ff6d73d365081d29f79c4e3cab174ac)
by [Unito](https://www.unito.io)
2026-02-20 17:02:54 -08:00
Christian Byrne
b27eb5861a fix(queue): allow deleting failed jobs from queue progress UI (#8478)
## Summary
Failed jobs could not be removed from the Media Assets queue progress
panel because `useJobActions` only supported cancel for pending/running
jobs.

## Changes
- Add `deleteAction`, `canDeleteJob`, `runDeleteJob` to `useJobActions`
composable
- Export `removeFailedJob` from `useJobMenu` with optional task
parameter
- Update `ActiveMediaAssetCard.vue` to show delete button on failed jobs

## Testing
1. Queue a workflow that will fail (e.g., missing model)
2. Open Media Assets panel
3. Hover over the failed job card → delete button (circle-minus icon)
appears
4. Click delete → job is removed from queue


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8478-fix-queue-allow-deleting-failed-jobs-from-queue-progress-UI-2f86d73d3650810ba3aaf6cf38703bf5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-20 16:49:36 -08:00
Benjamin Lu
b3aed9afd0 feat: split job history into a dedicated sidebar tab (#8957)
## Summary

Move queue job history into a dedicated sidebar tab (gated by `Comfy.Queue.QPOV2`) and remove mixed job-history UI from the Assets sidebar so assets and job controls are separated.

## Changes

- **What**:
  - Added `JobHistorySidebarTab` with reusable job UI primitives: `JobFilterTabs`, `JobFilterActions`, `JobAssetsList`, and shared `JobHistoryActionsMenu`.
  - Added reactive `job-history` tab registration in `sidebarTabStore`; prepends above Assets when `Comfy.Queue.QPOV2` is enabled and unregisters cleanly when disabled.
  - Added debounced search to `useJobList` (filters by job title, metadata, and prompt id).
  - Extracted clear-history dialog logic to `useQueueClearHistoryDialog` and reused it from queue overlay and job history tab.
  - Removed active-job rendering and queue-clear controls from assets list/grid/tab views; assets sidebar now focuses on media assets only.
  - Removed the QPOV2 gate from `MediaAssetViewModeToggle` and updated queue/job localized copy.
  - Added and updated tests for queue overlay header actions, job filters, search filtering, sidebar tab registration, and assets sidebar behavior.

## Review Focus

- Verify QPOV2 toggle behavior:
  - `Docked Job History` menu action toggles `Comfy.Queue.QPOV2`.
  - `job-history` tab insertion/removal order and active-tab reset on removal.
- Verify behavior split between tabs:
  - Job controls (cancel/delete/view/filter/search/clear history/clear queue) live in Job History.
  - Assets sidebar loading/empty states and list/grid rendering remain correct after removing active jobs.

## Screenshots (if applicable)
<img width="670" height="707" alt="image" src="https://github.com/user-attachments/assets/3a201fcb-d104-4e95-b5fe-49c4006a30a5" />
2026-02-20 16:42:41 -08:00
AustinMroz
7baa14af86 Fix badges to bottom of node (#9033)
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/75171909-829e-49c0-9d94-3d5588f28f05"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/1ee438c7-8231-4d8f-abea-f901a682dfa3"/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9033-Fix-badges-to-bottom-of-node-30d6d73d365081a584b1ca925980e59a)
by [Unito](https://www.unito.io)
2026-02-20 15:49:15 -08:00
Benjamin Lu
7921c38db9 chore: trigger snapshot refresh (#9027)
## Summary
- apply a tiny docs-only wording update in `README.md`
- create a non-functional diff to trigger snapshot regeneration workflow

## Testing
- pnpm typecheck
- pnpm lint

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9027-chore-trigger-snapshot-refresh-30d6d73d365081038bc2ed6e463d0e5f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-20 14:48:25 -08:00
316 changed files with 24176 additions and 4814 deletions

3
.github/license-clarifications.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"posthog-js@*": { "licenses": "Apache-2.0" }
}

View File

@@ -79,3 +79,22 @@ jobs:
exit 1
fi
echo '✅ No Mixpanel references found'
- name: Scan dist for PostHog telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for PostHog references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '(?i)posthog\.init' \
-e '(?i)posthog\.capture' \
-e 'PostHogTelemetryProvider' \
-e 'ph\.comfy\.org' \
-e 'posthog-js' \
dist; then
echo '❌ ERROR: PostHog references found in dist assets!'
echo 'PostHog must be properly tree-shaken from OSS builds.'
exit 1
fi
echo '✅ No PostHog references found'

View File

@@ -0,0 +1,45 @@
---
# Dispatches a frontend-asset-build event to the cloud repo on push to
# cloud/* branches and main. The cloud repo handles the actual build,
# GCS upload, and secret management (Sentry, Algolia, GCS creds).
#
# This is fire-and-forget — it does NOT wait for the cloud workflow to
# complete. Status is visible in the cloud repo's Actions tab.
name: Cloud Frontend Build Dispatch
on:
push:
branches:
- 'cloud/*'
- 'main'
workflow_dispatch:
permissions: {}
concurrency:
group: cloud-dispatch-${{ github.ref }}
cancel-in-progress: true
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
- name: Build client payload
id: payload
run: |
payload="$(jq -nc \
--arg ref "${GITHUB_SHA}" \
--arg branch "${GITHUB_REF_NAME}" \
'{ref: $ref, branch: $branch}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
- name: Dispatch to cloud repo
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
repository: Comfy-Org/cloud
event-type: frontend-asset-build
client-payload: ${{ steps.payload.outputs.json }}

View File

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

View File

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

View File

@@ -244,21 +244,9 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
const parsed = await (
await comfyPage.page.waitForFunction(
() => {
const workflow = localStorage.getItem('workflow')
if (!workflow) return null
try {
const data = JSON.parse(workflow)
return Array.isArray(data?.nodes) ? data : null
} catch {
return null
}
},
{ timeout: 3000 }
)
).jsonValue()
const parsed = await comfyPage.page.evaluate(() => {
return window['app'].graph.serialize()
})
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
for (const node of parsed.nodes) {

View File

@@ -345,17 +345,23 @@ test.describe('Support', () => {
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
const pagePromise = comfyPage.page.context().waitForEvent('page')
// Prevent loading the external page
await comfyPage.page
.context()
.route('https://support.comfy.org/**', (route) =>
route.fulfill({ body: '<html></html>', contentType: 'text/html' })
)
const popupPromise = comfyPage.page.waitForEvent('popup')
await comfyPage.menu.topbar.triggerTopbarCommand(['Help', 'Support'])
const newPage = await pagePromise
const popup = await popupPromise
await newPage.waitForLoadState('networkidle')
await expect(newPage).toHaveURL(/.*support\.comfy\.org.*/)
const url = new URL(newPage.url())
const url = new URL(popup.url())
expect(url.hostname).toBe('support.comfy.org')
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
await newPage.close()
await popup.close()
})
})

View File

@@ -10,6 +10,7 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
})

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -27,6 +27,7 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
})
test.describe('Selection Toolbox', () => {

View File

@@ -5,6 +5,7 @@ import { TestIds } from '../../fixtures/selectors'
test.describe('Properties panel position', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
// Open a sidebar tab to ensure sidebar is visible
await comfyPage.menu.nodeLibraryTab.open()
await comfyPage.actionbar.propertiesButton.click()

View File

@@ -53,6 +53,7 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -5,6 +5,7 @@ import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Node library sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
await comfyPage.settings.setSetting(
'Comfy.NodeLibrary.BookmarksCustomization',

View File

@@ -332,12 +332,9 @@ test.describe('Workflows sidebar', () => {
.getPersistedItem('workflow1.json')
.click({ button: 'right' })
await comfyPage.contextMenu.clickMenuItem('Duplicate')
await comfyPage.nextFrame()
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'*workflow1 (Copy).json'
])
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', '*workflow1 (Copy).json'])
})
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

2
global.d.ts vendored
View File

@@ -33,6 +33,8 @@ interface Window {
gtm_container_id?: string
ga_measurement_id?: string
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.40.8",
"version": "1.40.10",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -60,6 +60,7 @@
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@formkit/auto-animate": "catalog:",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",
"@primeuix/styled": "catalog:",
@@ -99,6 +100,7 @@
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "catalog:",
"posthog-js": "catalog:",
"primeicons": "catalog:",
"primevue": "catalog:",
"reka-ui": "catalog:",

View File

@@ -609,6 +609,10 @@
}
}
@utility bg-subscription-gradient {
background: var(--color-subscription-button-gradient);
}
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {
@@ -1192,7 +1196,7 @@ button.comfy-queue-btn {
.graphdialog {
min-height: 1em;
background-color: var(--comfy-menu-bg);
z-index: 41; /* z-index is set to 41 here in order to appear over selection-overlay-container which should have a z-index of 40 */
z-index: 1500;
}
.graphdialog .name {

View File

@@ -3952,7 +3952,7 @@ export interface components {
* @description The subscription tier level
* @enum {string}
*/
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
SubscriptionTier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
/**
* @description The subscription billing duration
* @enum {string}

4590
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ catalog:
'@alloc/quick-lru': ^5.2.0
'@comfyorg/comfyui-electron-types': 0.6.2
'@eslint/js': ^9.39.1
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind4': ^1.2.0
@@ -76,6 +77,7 @@ catalog:
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
posthog-js: ^1.358.1
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5

View File

@@ -2,17 +2,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
downloadFile,
extractFilenameFromContentDisposition
extractFilenameFromContentDisposition,
openFileInNewTab
} from '@/base/common/downloadUtil'
let mockIsCloud = false
const { mockIsCloud } = vi.hoisted(() => ({
mockIsCloud: { value: false }
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud
return mockIsCloud.value
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({ addAlert: vi.fn() }))
}))
// Global stubs
const createObjectURLSpy = vi
.spyOn(URL, 'createObjectURL')
@@ -26,7 +37,7 @@ describe('downloadUtil', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
mockIsCloud = false
mockIsCloud.value = false
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
@@ -154,7 +165,7 @@ describe('downloadUtil', () => {
})
it('streams downloads via blob when running in cloud', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -173,6 +184,7 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -183,7 +195,7 @@ describe('downloadUtil', () => {
})
it('logs an error when cloud fetch fails', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchMock.mockResolvedValue({
@@ -197,14 +209,15 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve()
await Promise.resolve() // let fetchAsBlob throw
await Promise.resolve() // let .catch handler run
expect(consoleSpy).toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('uses filename from Content-Disposition header in cloud mode', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -223,6 +236,7 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -231,7 +245,7 @@ describe('downloadUtil', () => {
})
it('uses RFC 5987 filename from Content-Disposition header', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -253,6 +267,7 @@ describe('downloadUtil', () => {
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -260,7 +275,7 @@ describe('downloadUtil', () => {
})
it('falls back to provided filename when Content-Disposition is missing', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -278,6 +293,7 @@ describe('downloadUtil', () => {
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -285,6 +301,99 @@ describe('downloadUtil', () => {
})
})
describe('openFileInNewTab', () => {
let windowOpenSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.useFakeTimers()
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
})
afterEach(() => {
vi.useRealTimers()
})
it('opens URL directly when not in cloud mode', async () => {
mockIsCloud.value = false
const testUrl = 'https://example.com/image.png'
await openFileInNewTab(testUrl)
expect(windowOpenSpy).toHaveBeenCalledWith(testUrl, '_blank')
expect(fetchMock).not.toHaveBeenCalled()
})
it('opens blank tab synchronously then navigates to blob URL in cloud mode', async () => {
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab(testUrl)
expect(windowOpenSpy).toHaveBeenCalledWith('', '_blank')
expect(fetchMock).toHaveBeenCalledWith(testUrl)
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
expect(mockTab.location.href).toBe('blob:mock-url')
})
it('revokes blob URL after timeout in cloud mode', async () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab('https://example.com/image.png')
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
vi.advanceTimersByTime(60_000)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
})
it('closes blank tab and logs error when cloud fetch fails', async () => {
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: false,
status: 404
} as unknown as Response)
await openFileInNewTab(testUrl)
expect(mockTab.close).toHaveBeenCalled()
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('revokes blob URL immediately if tab was closed by user', async () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab('https://example.com/image.png')
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
expect(mockTab.location.href).toBe('')
})
})
describe('extractFilenameFromContentDisposition', () => {
it('returns null for null header', () => {
expect(extractFilenameFromContentDisposition(null)).toBeNull()

View File

@@ -1,7 +1,9 @@
/**
* Utility functions for downloading files
*/
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
// Constants
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
@@ -112,14 +114,23 @@ export function extractFilenameFromContentDisposition(
return null
}
const downloadViaBlobFetch = async (
/**
* Fetch a URL and return its body as a Blob.
* Shared by download and open-in-new-tab cloud paths.
*/
async function fetchAsBlob(url: string): Promise<Response> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`)
}
return response
}
async function downloadViaBlobFetch(
href: string,
fallbackFilename: string
): Promise<void> => {
const response = await fetch(href)
if (!response.ok) {
throw new Error(`Failed to fetch ${href}: ${response.status}`)
}
): Promise<void> {
const response = await fetchAsBlob(href)
// Try to get filename from Content-Disposition header (set by backend)
const contentDisposition = response.headers.get('Content-Disposition')
@@ -129,3 +140,44 @@ const downloadViaBlobFetch = async (
const blob = await response.blob()
downloadBlob(headerFilename ?? fallbackFilename, blob)
}
/**
* Open a file URL in a new browser tab.
* On cloud, fetches the resource as a blob first to avoid GCS redirects
* that would trigger an auto-download instead of displaying the file.
*
* Opens the tab synchronously to preserve the user-gesture context
* (browsers block window.open after an await), then navigates it to
* the blob URL once the fetch completes.
*/
export async function openFileInNewTab(url: string): Promise<void> {
if (!isCloud) {
window.open(url, '_blank')
return
}
// Open immediately to preserve user-gesture activation.
const tab = window.open('', '_blank')
try {
const response = await fetchAsBlob(url)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
if (tab && !tab.closed) {
tab.location.href = blobUrl
// Revoke after the tab has had time to load the blob.
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
} else {
URL.revokeObjectURL(blobUrl)
}
} catch (error) {
tab?.close()
console.error('Failed to open image:', error)
useToastStore().addAlert(
t('toastMessages.errorOpenImage', {
error: error instanceof Error ? error.message : String(error)
})
)
}
}

View File

@@ -51,6 +51,7 @@
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@@ -98,6 +99,19 @@
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--share-2] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
@@ -169,11 +183,17 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isDesktop } from '@/platform/distribution/types'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
openShareDialog,
prefetchShareDialog
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
@@ -183,12 +203,14 @@ const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { flags } = useFeatureFlags()
const { isLoggedIn } = useCurrentUser()
const { t, n } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const queueUIStore = useQueueUIStore()
const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
@@ -257,12 +279,15 @@ const queueContextMenuItems = computed<MenuItem[]>(() => [
}
}
])
const shareTooltipConfig = computed(() =>
buildTooltipConfig(t('actionbar.shareTooltip'))
)
const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
})
const { hasAnyError } = storeToRefs(executionStore)
const { hasAnyError } = storeToRefs(executionErrorStore)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)

View File

@@ -0,0 +1,22 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import MarqueeLine from './MarqueeLine.vue'
describe(MarqueeLine, () => {
it('renders slot content', () => {
const wrapper = mount(MarqueeLine, {
slots: { default: 'Hello World' }
})
expect(wrapper.text()).toBe('Hello World')
})
it('renders content inside a span within the container', () => {
const wrapper = mount(MarqueeLine, {
slots: { default: 'Test Text' }
})
const span = wrapper.find('span')
expect(span.exists()).toBe(true)
expect(span.text()).toBe('Test Text')
})
})

View File

@@ -0,0 +1,24 @@
<template>
<div
class="overflow-hidden [container-type:inline-size] [mask-image:linear-gradient(to_right,black_70%,transparent)] motion-safe:group-hover:[mask-image:none]"
>
<span
class="whitespace-nowrap inline-block min-w-full text-center [--_marquee-end:min(calc(-100%+100cqw),0px)] motion-safe:group-hover:[animation:marquee-scroll_3s_linear_infinite_alternate]"
>
<slot />
</span>
</div>
</template>
<style>
@keyframes marquee-scroll {
0%,
20% {
transform: translateX(0);
}
80%,
100% {
transform: translateX(var(--_marquee-end));
}
}
</style>

View File

@@ -1,27 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { statusBadgeVariants } from './statusBadge.variants'
import type { StatusBadgeVariants } from './statusBadge.variants'
const {
label,
severity = 'default',
variant
variant,
class: className
} = defineProps<{
label?: string | number
severity?: StatusBadgeVariants['severity']
variant?: StatusBadgeVariants['variant']
class?: string
}>()
const badgeClass = computed(() =>
cn(
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
}),
className
)
)
</script>
<template>
<span
:class="
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
"
>
<span :class="badgeClass">
{{ label }}
</span>
</template>

View File

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

View File

@@ -0,0 +1,105 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import MarqueeLine from './MarqueeLine.vue'
import TextTickerMultiLine from './TextTickerMultiLine.vue'
type Callback = () => void
const resizeCallbacks: Callback[] = []
const mutationCallbacks: Callback[] = []
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...actual,
useResizeObserver: (_target: unknown, cb: Callback) => {
resizeCallbacks.push(cb)
return { stop: vi.fn() }
},
useMutationObserver: (_target: unknown, cb: Callback) => {
mutationCallbacks.push(cb)
return { stop: vi.fn() }
}
}
})
function mockElementSize(
el: HTMLElement,
clientWidth: number,
scrollWidth: number
) {
Object.defineProperty(el, 'clientWidth', {
value: clientWidth,
configurable: true
})
Object.defineProperty(el, 'scrollWidth', {
value: scrollWidth,
configurable: true
})
}
describe(TextTickerMultiLine, () => {
let wrapper: ReturnType<typeof mount>
afterEach(() => {
wrapper?.unmount()
resizeCallbacks.length = 0
mutationCallbacks.length = 0
})
function mountComponent(text: string) {
wrapper = mount(TextTickerMultiLine, {
slots: { default: text }
})
return wrapper
}
function getMeasureEl(): HTMLElement {
return wrapper.find('[aria-hidden="true"]').element as HTMLElement
}
async function triggerSplitLines() {
resizeCallbacks.forEach((cb) => cb())
await nextTick()
}
it('renders slot content', () => {
mountComponent('Load Checkpoint')
expect(wrapper.text()).toContain('Load Checkpoint')
})
it('renders a single MarqueeLine when text fits', async () => {
mountComponent('Short')
mockElementSize(getMeasureEl(), 200, 100)
await triggerSplitLines()
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(1)
})
it('renders two MarqueeLines when text overflows', async () => {
mountComponent('Load Checkpoint Loader Simple')
mockElementSize(getMeasureEl(), 100, 300)
await triggerSplitLines()
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(2)
})
it('splits text at word boundary when overflowing', async () => {
mountComponent('Load Checkpoint Loader')
mockElementSize(getMeasureEl(), 100, 200)
await triggerSplitLines()
const lines = wrapper.findAllComponents(MarqueeLine)
expect(lines[0].text()).toBe('Load')
expect(lines[1].text()).toBe('Checkpoint Loader')
})
it('has hidden measurement element with aria-hidden', () => {
mountComponent('Test')
const measureEl = wrapper.find('[aria-hidden="true"]')
expect(measureEl.exists()).toBe(true)
expect(measureEl.classes()).toContain('invisible')
})
})

View File

@@ -0,0 +1,66 @@
<template>
<div>
<!-- Hidden single-line measurement element for overflow detection -->
<div
ref="measureRef"
class="invisible absolute inset-x-0 top-0 overflow-hidden whitespace-nowrap pointer-events-none"
aria-hidden="true"
>
<slot />
</div>
<MarqueeLine v-if="!secondLine">
<slot />
</MarqueeLine>
<div v-else class="flex flex-col w-full">
<MarqueeLine>{{ firstLine }}</MarqueeLine>
<MarqueeLine>{{ secondLine }}</MarqueeLine>
</div>
</div>
</template>
<script setup lang="ts">
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
import { ref } from 'vue'
import MarqueeLine from './MarqueeLine.vue'
import { splitTextAtWordBoundary } from '@/utils/textTickerUtils'
const measureRef = ref<HTMLElement | null>(null)
const firstLine = ref('')
const secondLine = ref('')
function splitLines() {
const el = measureRef.value
const text = el?.textContent?.trim()
if (!el || !text) {
firstLine.value = ''
secondLine.value = ''
return
}
const containerWidth = el.clientWidth
const textWidth = el.scrollWidth
if (textWidth <= containerWidth) {
firstLine.value = text
secondLine.value = ''
return
}
const [first, second] = splitTextAtWordBoundary(
text,
containerWidth / textWidth
)
firstLine.value = first
secondLine.value = second
}
useResizeObserver(measureRef, splitLines)
useMutationObserver(measureRef, splitLines, {
childList: true,
characterData: true,
subtree: true
})
</script>

View File

@@ -1,37 +1,41 @@
<template>
<ContextMenuRoot>
<TreeRoot
:expanded="[...expandedKeys]"
:items="root.children ?? []"
:get-key="(item) => item.key"
:get-children="
(item) => (item.children?.length ? item.children : undefined)
"
class="m-0 p-0 pb-6"
>
<TreeVirtualizer
v-slot="{ item }"
:estimate-size="36"
:text-content="(item) => item.value.label ?? ''"
<ContextMenuTrigger :disabled="!showContextMenu" as-child>
<TreeRoot
:expanded="[...expandedKeys]"
:items="root.children ?? []"
:get-key="(item) => item.key"
:get-children="
(item) => (item.children?.length ? item.children : undefined)
"
class="m-0 p-0 pb-6"
>
<TreeExplorerV2Node
:item="
item as FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
"
@node-click="
(node: RenderedTreeExplorerNode<ComfyNodeDefImpl>, e: MouseEvent) =>
emit('nodeClick', node, e)
"
<TreeVirtualizer
v-slot="{ item }"
:estimate-size="36"
:text-content="(item) => item.value.label ?? ''"
>
<template #folder="{ node }">
<slot name="folder" :node="node" />
</template>
<template #node="{ node }">
<slot name="node" :node="node" />
</template>
</TreeExplorerV2Node>
</TreeVirtualizer>
</TreeRoot>
<TreeExplorerV2Node
:item="
item as FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
"
@node-click="
(
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
e: MouseEvent
) => emit('nodeClick', node, e)
"
>
<template #folder="{ node }">
<slot name="folder" :node="node" />
</template>
<template #node="{ node }">
<slot name="node" :node="node" />
</template>
</TreeExplorerV2Node>
</TreeVirtualizer>
</TreeRoot>
</ContextMenuTrigger>
<ContextMenuPortal v-if="showContextMenu">
<ContextMenuContent
@@ -49,7 +53,11 @@
"
class="size-4"
/>
{{ $t('sideToolbar.nodeLibraryTab.sections.favorites') }}
{{
isCurrentNodeBookmarked
? $t('sideToolbar.nodeLibraryTab.sections.unfavoriteNode')
: $t('sideToolbar.nodeLibraryTab.sections.favoriteNode')
}}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
@@ -63,6 +71,7 @@ import {
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuTrigger,
TreeRoot,
TreeVirtualizer
} from 'reka-ui'

View File

@@ -80,10 +80,6 @@ describe('TreeExplorerV2Node', () => {
global: {
stubs: {
TreeItem: treeItemStub.stub,
ContextMenuTrigger: {
name: 'ContextMenuTrigger',
template: '<div data-testid="context-menu-trigger"><slot /></div>'
},
Teleport: { template: '<div />' }
},
provide: {
@@ -145,36 +141,12 @@ describe('TreeExplorerV2Node', () => {
})
describe('context menu', () => {
it('renders ContextMenuTrigger when showContextMenu is true for nodes', () => {
const { wrapper } = mountComponent({
item: createMockItem('node'),
showContextMenu: true
})
expect(
wrapper.find('[data-testid="context-menu-trigger"]').exists()
).toBe(true)
})
it('does not render ContextMenuTrigger for folder items', () => {
const { wrapper } = mountComponent({
item: createMockItem('folder')
})
expect(
wrapper.find('[data-testid="context-menu-trigger"]').exists()
).toBe(false)
})
it('sets contextMenuNode when contextmenu event is triggered', async () => {
it('sets contextMenuNode when contextmenu event is triggered on node', async () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
const nodeItem = createMockItem('node')
const { wrapper } = mountComponent(
{
item: nodeItem,
showContextMenu: true
},
{ item: nodeItem },
{
provide: {
[InjectKeyContextMenuNode as symbol]: contextMenuNode
@@ -187,6 +159,24 @@ describe('TreeExplorerV2Node', () => {
expect(contextMenuNode.value).toEqual(nodeItem.value)
})
it('does not set contextMenuNode for folder items', async () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
const { wrapper } = mountComponent(
{ item: createMockItem('folder') },
{
provide: {
[InjectKeyContextMenuNode as symbol]: contextMenuNode
}
}
)
const folderDiv = wrapper.find('div.group\\/tree-node')
await folderDiv.trigger('contextmenu')
expect(contextMenuNode.value).toBeNull()
})
})
describe('rendering', () => {

View File

@@ -5,27 +5,26 @@
:level="item.level"
as-child
>
<!-- Node with context menu -->
<ContextMenuTrigger v-if="item.value.type === 'node'" as-child>
<div
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
draggable="true"
@click.stop="handleClick($event, handleToggle, handleSelect)"
@contextmenu="handleContextMenu"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
>
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
<slot name="node" :node="item.value">
{{ item.value.label }}
</slot>
</span>
</div>
</ContextMenuTrigger>
<!-- Node -->
<div
v-if="item.value.type === 'node'"
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
draggable="true"
@click.stop="handleClick($event, handleToggle, handleSelect)"
@contextmenu="handleContextMenu"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
>
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
<slot name="node" :node="item.value">
{{ item.value.label }}
</slot>
</span>
</div>
<!-- Folder -->
<div
@@ -69,7 +68,7 @@
<script setup lang="ts">
import type { FlattenedItem } from 'reka-ui'
import { ContextMenuTrigger, TreeItem } from 'reka-ui'
import { TreeItem } from 'reka-ui'
import { computed, inject } from 'vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'

View File

@@ -13,7 +13,7 @@
class="about-badge inline-flex items-center no-underline"
:title="badge.url"
>
<Tag class="mr-2">
<Tag class="mr-2" :severity="badge.severity">
<template #icon>
<i :class="[badge.icon, 'mr-2 text-xl']" />
</template>

View File

@@ -64,17 +64,17 @@ import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
const { t } = useI18n()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionStore)
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
const errorCountLabel = computed(() =>
@@ -90,7 +90,7 @@ const isVisible = computed(
)
function dismiss() {
executionStore.dismissErrorOverlay()
executionErrorStore.dismissErrorOverlay()
}
function seeErrors() {
@@ -100,6 +100,6 @@ function seeErrors() {
}
rightSidePanelStore.openPanel('errors')
executionStore.dismissErrorOverlay()
executionErrorStore.dismissErrorOverlay()
}
</script>

View File

@@ -0,0 +1,80 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import GradientSlider from './GradientSlider.vue'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import { interpolateStops, stopsToGradient } from './gradients'
const TEST_STOPS: ColorStop[] = [
{ offset: 0, color: [0, 0, 0] },
{ offset: 1, color: [255, 255, 255] }
]
function mountSlider(props: {
stops?: ColorStop[]
modelValue: number
min?: number
max?: number
step?: number
}) {
return mount(GradientSlider, {
props: { stops: TEST_STOPS, ...props }
})
}
describe('GradientSlider', () => {
it('passes min, max, step to SliderRoot', () => {
const wrapper = mountSlider({
modelValue: 50,
min: -100,
max: 100,
step: 5
})
const thumb = wrapper.find('[role="slider"]')
expect(thumb.attributes('aria-valuemin')).toBe('-100')
expect(thumb.attributes('aria-valuemax')).toBe('100')
})
it('renders slider root with track and thumb', () => {
const wrapper = mountSlider({ modelValue: 0 })
expect(wrapper.find('[data-slider-impl]').exists()).toBe(true)
expect(wrapper.find('[role="slider"]').exists()).toBe(true)
})
it('does not render SliderRange', () => {
const wrapper = mountSlider({ modelValue: 50 })
expect(wrapper.find('[data-slot="slider-range"]').exists()).toBe(false)
})
})
describe('stopsToGradient', () => {
it('returns transparent for empty stops', () => {
expect(stopsToGradient([])).toBe('transparent')
})
})
describe('interpolateStops', () => {
it('returns transparent for empty stops', () => {
expect(interpolateStops([], 0.5)).toBe('transparent')
})
it('returns start color at t=0', () => {
expect(interpolateStops(TEST_STOPS, 0)).toBe('rgb(0,0,0)')
})
it('returns end color at t=1', () => {
expect(interpolateStops(TEST_STOPS, 1)).toBe('rgb(255,255,255)')
})
it('returns midpoint color at t=0.5', () => {
expect(interpolateStops(TEST_STOPS, 0.5)).toBe('rgb(128,128,128)')
})
it('clamps values below 0', () => {
expect(interpolateStops(TEST_STOPS, -1)).toBe('rgb(0,0,0)')
})
it('clamps values above 1', () => {
expect(interpolateStops(TEST_STOPS, 2)).toBe('rgb(255,255,255)')
})
})

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { SliderRoot, SliderThumb, SliderTrack } from 'reka-ui'
import { computed, ref } from 'vue'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import {
interpolateStops,
stopsToGradient
} from '@/components/gradientslider/gradients'
import { cn } from '@/utils/tailwindUtil'
const {
stops,
min = 0,
max = 100,
step = 1,
disabled = false,
ariaLabel
} = defineProps<{
stops: ColorStop[]
min?: number
max?: number
step?: number
disabled?: boolean
ariaLabel?: string
}>()
const modelValue = defineModel<number>({ required: true })
const sliderValue = computed({
get: () => [modelValue.value],
set: (v: number[]) => {
if (v.length) modelValue.value = v[0]
}
})
const gradient = computed(() => stopsToGradient(stops))
const thumbColor = computed(() => {
const t = max === min ? 0 : (modelValue.value - min) / (max - min)
return interpolateStops(stops, t)
})
const pressed = ref(false)
</script>
<template>
<SliderRoot
v-model="sliderValue"
:min
:max
:step
:disabled
:class="
cn(
'relative flex w-full touch-none items-center select-none',
'data-[disabled]:opacity-50'
)
"
:style="{ '--reka-slider-thumb-transform': 'translate(-50%, -50%)' }"
@slide-start="pressed = true"
@slide-move="pressed = true"
@slide-end="pressed = false"
>
<SliderTrack
:class="
cn(
'relative h-2.5 w-full grow cursor-pointer overflow-visible rounded-full',
'before:absolute before:-inset-2 before:block before:bg-transparent'
)
"
:style="{ background: gradient }"
>
<SliderThumb
:class="
cn(
'block size-4 shrink-0 cursor-grab rounded-full shadow-md ring-1 ring-black/25 top-1/2',
'transition-[color,box-shadow,background-color]',
'before:absolute before:-inset-1.5 before:block before:rounded-full before:bg-transparent',
'hover:ring-2 hover:ring-black/40 focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:outline-hidden',
'disabled:pointer-events-none disabled:opacity-50',
{ 'cursor-grabbing': pressed }
)
"
:style="{ backgroundColor: thumbColor }"
:aria-label
/>
</SliderTrack>
</SliderRoot>
</template>

View File

@@ -0,0 +1,40 @@
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
export function stopsToGradient(stops: ColorStop[]): string {
if (!stops.length) return 'transparent'
const colors = stops.map(
({ offset, color: [r, g, b] }) => `rgb(${r},${g},${b}) ${offset * 100}%`
)
return `linear-gradient(to right, ${colors.join(', ')})`
}
export function interpolateStops(stops: ColorStop[], t: number): string {
if (!stops.length) return 'transparent'
const clamped = Math.max(0, Math.min(1, t))
if (clamped <= stops[0].offset) {
const [r, g, b] = stops[0].color
return `rgb(${r},${g},${b})`
}
for (let i = 0; i < stops.length - 1; i++) {
const {
offset: o1,
color: [r1, g1, b1]
} = stops[i]
const {
offset: o2,
color: [r2, g2, b2]
} = stops[i + 1]
if (clamped >= o1 && clamped <= o2) {
const f = o2 === o1 ? 0 : (clamped - o1) / (o2 - o1)
const r = Math.round(r1 + (r2 - r1) * f)
const g = Math.round(g1 + (g2 - g1) * f)
const b = Math.round(b1 + (b2 - b1) * f)
return `rgb(${r},${g},${b})`
}
}
const [r, g, b] = stops[stops.length - 1].color
return `rgb(${r},${g},${b})`
}

View File

@@ -18,6 +18,7 @@
>
<WorkflowTabs />
<TopbarBadges />
<TopbarSubscribeButton />
</div>
</div>
</template>
@@ -70,7 +71,7 @@
:key="nodeData.id"
:node-data="nodeData"
:error="
executionStore.lastExecutionError?.node_id === nodeData.id
executionErrorStore.lastExecutionError?.node_id === nodeData.id
? 'Execution error'
: null
"
@@ -133,6 +134,7 @@ import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
@@ -152,7 +154,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
@@ -170,6 +172,7 @@ import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
@@ -196,6 +199,7 @@ const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const toastStore = useToastStore()
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
@@ -376,7 +380,7 @@ watch(
// Update node slot errors for LiteGraph nodes
// (Vue nodes read from store directly)
watch(
() => executionStore.lastNodeErrors,
() => executionErrorStore.lastNodeErrors,
(lastNodeErrors) => {
if (!comfyApp.graph) return
@@ -524,8 +528,13 @@ onMounted(async () => {
await workflowPersistence.initializeWorkflow()
workflowPersistence.restoreWorkflowTabsState()
const sharedWorkflowLoadStatus =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
// Load template from URL if present
await workflowPersistence.loadTemplateFromUrlIfPresent()
if (sharedWorkflowLoadStatus === 'not-present') {
await workflowPersistence.loadTemplateFromUrlIfPresent()
}
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts

View File

@@ -0,0 +1,104 @@
<template>
<div class="flex items-center gap-1">
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-[14rem] flex-col items-stretch font-inter">
<Button
data-testid="docked-job-history-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="sm"
@click="onToggleDockedJobHistory"
>
<span class="flex items-center gap-2">
<i
class="icon-[lucide--panel-left-close] size-4 text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.dockedJobHistory')
}}</span>
</span>
<i
v-if="isQueuePanelV2Enabled"
class="icon-[lucide--check] size-4"
/>
</Button>
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
<template v-if="showClearHistoryAction">
<div class="my-1 border-t border-interface-stroke" />
<Button
data-testid="clear-history-action"
class="h-auto min-h-0 w-full items-start justify-start whitespace-normal"
variant="textonly"
size="sm"
@click="onClearHistoryFromMenu(close)"
>
<i
class="icon-[lucide--trash-2] size-4 shrink-0 self-center text-destructive-background"
/>
<span
class="flex flex-col items-start break-words text-left leading-tight"
>
<span class="text-sm font-light">
{{ t('sideToolbar.queueProgressOverlay.clearHistory') }}
</span>
<span class="text-xs text-text-secondary font-light">
{{
t(
'sideToolbar.queueProgressOverlay.clearHistoryMenuAssetsNote'
)
}}
</span>
</span>
</Button>
</template>
</div>
</template>
</Popover>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
const emit = defineEmits<{
(e: 'clearHistory'): void
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const showClearHistoryAction = computed(() => !isCloud)
const onClearHistoryFromMenu = (close: () => void) => {
close()
emit('clearHistory')
}
const onToggleDockedJobHistory = async () => {
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled.value)
}
</script>

View File

@@ -51,6 +51,7 @@ import type {
} from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
@@ -83,6 +84,7 @@ const emit = defineEmits<{
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
@@ -102,9 +104,9 @@ const onMenuItem = (item: JobListItem, event: Event) => {
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = async (entry: MenuEntry) => {
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
}
})
</script>

View File

@@ -1,28 +1,40 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent } from 'vue'
import { defineComponent, h } from 'vue'
const popoverToggleSpy = vi.fn()
const popoverHideSpy = vi.fn()
const popoverCloseSpy = vi.fn()
vi.mock('primevue/popover', () => {
vi.mock('@/components/ui/Popover.vue', () => {
const PopoverStub = defineComponent({
name: 'Popover',
setup(_, { slots, expose }) {
const toggle = (event: Event) => {
popoverToggleSpy(event)
}
const hide = () => {
popoverHideSpy()
}
expose({ toggle, hide })
return () => slots.default?.()
setup(_, { slots }) {
return () =>
h('div', [
slots.button?.(),
slots.default?.({
close: () => {
popoverCloseSpy()
}
})
])
}
})
return { default: PopoverStub }
})
const mockGetSetting = vi.fn((key: string) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const mockSetSetting = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting,
set: mockSetSetting
})
}))
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
@@ -43,7 +55,8 @@ const i18n = createI18n({
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
moreOptions: 'More options',
clearHistory: 'Clear history'
clearHistory: 'Clear history',
dockedJobHistory: 'Docked Job History'
}
}
}
@@ -66,6 +79,11 @@ const mountHeader = (props = {}) =>
})
describe('QueueOverlayHeader', () => {
beforeEach(() => {
popoverCloseSpy.mockClear()
mockSetSetting.mockClear()
})
it('renders header title and concurrent indicator when enabled', () => {
const wrapper = mountHeader({ concurrentWorkflowCount: 3 })
@@ -102,19 +120,33 @@ describe('QueueOverlayHeader', () => {
)
})
it('toggles popover and emits clear history', async () => {
it('emits clear history from the menu', async () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
const wrapper = mountHeader()
const moreButton = wrapper.get('button[aria-label="More options"]')
await moreButton.trigger('click')
expect(popoverToggleSpy).toHaveBeenCalledTimes(1)
expect(wrapper.find('button[aria-label="More options"]').exists()).toBe(
true
)
expect(spy).toHaveBeenCalledWith('More')
const clearHistoryButton = wrapper.get('button[aria-label="Clear history"]')
const clearHistoryButton = wrapper.get(
'[data-testid="clear-history-action"]'
)
await clearHistoryButton.trigger('click')
expect(popoverHideSpy).toHaveBeenCalledTimes(1)
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
it('toggles docked job history setting from the menu', async () => {
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', false)
})
})

View File

@@ -37,62 +37,17 @@
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
<div v-if="!isCloud" class="flex items-center gap-1">
<Button
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
@click="onMoreClick"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</Button>
<Popover
ref="morePopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<Button
class="w-full justify-start"
variant="textonly"
size="sm"
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
@click="onClearHistoryFromMenu"
>
<i class="icon-[lucide--file-x-2] size-4 text-muted" />
<span>{{
t('sideToolbar.queueProgressOverlay.clearHistory')
}}</span>
</Button>
</div>
</Popover>
</div>
<JobHistoryActionsMenu @clear-history="$emit('clearHistory')" />
</div>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import type { PopoverMethods } from 'primevue/popover'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
defineProps<{
headerTitle: string
@@ -101,24 +56,13 @@ defineProps<{
queuedCount: number
}>()
const emit = defineEmits<{
defineEmits<{
(e: 'clearHistory'): void
(e: 'clearQueued'): void
}>()
const { t } = useI18n()
const morePopoverRef = ref<PopoverMethods | null>(null)
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const clearAllJobsTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearAllJobsTooltip'))
)
const onMoreClick = (event: MouseEvent) => {
morePopoverRef.value?.toggle(event)
}
const onClearHistoryFromMenu = () => {
morePopoverRef.value?.hide()
emit('clearHistory')
}
</script>

View File

@@ -7,6 +7,7 @@ import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import { i18n } from '@/i18n'
import type { JobStatus } from '@/platform/remote/comfyui/jobs/jobTypes'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
@@ -20,7 +21,12 @@ const QueueOverlayExpandedStub = defineComponent({
required: true
}
},
template: '<div data-testid="expanded-title">{{ headerTitle }}</div>'
template: `
<div>
<div data-testid="expanded-title">{{ headerTitle }}</div>
<button data-testid="show-assets-button" @click="$emit('show-assets')" />
</div>
`
})
function createTask(id: string, status: JobStatus): TaskItemImpl {
@@ -41,10 +47,11 @@ const mountComponent = (
stubActions: false
})
const queueStore = useQueueStore(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
queueStore.runningTasks = runningTasks
queueStore.pendingTasks = pendingTasks
return mount(QueueProgressOverlay, {
const wrapper = mount(QueueProgressOverlay, {
props: {
expanded: true
},
@@ -60,6 +67,8 @@ const mountComponent = (
}
}
})
return { wrapper, sidebarTabStore }
}
describe('QueueProgressOverlay', () => {
@@ -68,7 +77,7 @@ describe('QueueProgressOverlay', () => {
})
it('shows expanded header with running and queued labels', () => {
const wrapper = mountComponent(
const { wrapper } = mountComponent(
[
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
@@ -82,7 +91,10 @@ describe('QueueProgressOverlay', () => {
})
it('shows only running label when queued count is zero', () => {
const wrapper = mountComponent([createTask('running-1', 'in_progress')], [])
const { wrapper } = mountComponent(
[createTask('running-1', 'in_progress')],
[]
)
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'1 running'
@@ -90,10 +102,22 @@ describe('QueueProgressOverlay', () => {
})
it('shows job queue title when there are no active jobs', () => {
const wrapper = mountComponent([], [])
const { wrapper } = mountComponent([], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'Job Queue'
)
})
it('toggles the assets sidebar tab when show-assets is clicked', async () => {
const { wrapper, sidebarTabStore } = mountComponent([], [])
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
await wrapper.get('[data-testid="show-assets-button"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
await wrapper.get('[data-testid="show-assets-button"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
})

View File

@@ -22,7 +22,7 @@
:queued-count="queuedCount"
:displayed-job-groups="displayedJobGroups"
:has-failed-jobs="hasFailedJobs"
@show-assets="openAssetsSidebar"
@show-assets="toggleAssetsSidebar"
@clear-history="onClearHistoryFromMenu"
@clear-queued="cancelQueuedWorkflows"
@cancel-item="onCancelItem"
@@ -59,10 +59,10 @@ import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
@@ -71,7 +71,6 @@ import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -97,9 +96,9 @@ const queueStore = useQueueStore()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const sidebarTabStore = useSidebarTabStore()
const dialogStore = useDialogStore()
const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const {
@@ -243,6 +242,10 @@ const viewAllJobs = () => {
setExpanded(true)
}
const toggleAssetsSidebar = () => {
sidebarTabStore.toggleSidebarTab('assets')
}
const openAssetsSidebar = () => {
sidebarTabStore.activeSidebarTabId = 'assets'
}
@@ -309,28 +312,7 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
await queueStore.update()
})
const showClearHistoryDialog = () => {
dialogStore.showDialog({
key: 'queue-clear-history',
component: QueueClearHistoryDialog,
dialogComponentProps: {
headless: true,
closable: false,
closeOnEscape: true,
dismissableMask: true,
pt: {
root: {
class: 'max-w-[360px] w-auto bg-transparent border-none shadow-none'
},
content: {
class: '!p-0 bg-transparent'
}
}
}
})
}
const onClearHistoryFromMenu = () => {
showClearHistoryDialog()
showQueueClearHistoryDialog()
}
</script>

View File

@@ -0,0 +1,110 @@
<template>
<div class="flex flex-col gap-4 px-3 pb-4">
<div
v-for="group in displayedJobGroups"
:key="group.key"
class="flex flex-col gap-2"
>
<div class="text-xs leading-none text-text-secondary">
{{ group.label }}
</div>
<AssetsListItem
v-for="job in group.items"
:key="job.id"
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="hoveredJobId = job.id"
@mouseleave="onJobLeave(job.id)"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="$emit('cancelItem', job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="$emit('deleteItem', job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="$emit('viewItem', job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
defineProps<{ displayedJobGroups: JobGroup[] }>()
defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
(e: 'viewItem', item: JobListItem): void
}>()
const { t } = useI18n()
const hoveredJobId = ref<string | null>(null)
const onJobLeave = (jobId: string) => {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
const isCancelable = (job: JobListItem) =>
job.showClear !== false && isActiveJobState(job.state)
const isFailedDeletable = (job: JobListItem) =>
job.showClear !== false && job.state === 'failed'
const getJobIconClass = (job: JobListItem): string | undefined => {
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
return 'animate-spin'
}
return undefined
}
</script>

View File

@@ -0,0 +1,199 @@
<template>
<div class="flex min-w-0 items-center gap-2">
<SearchBox
v-if="showSearch"
:model-value="searchQuery"
class="min-w-0 flex-1"
:placeholder="searchPlaceholderText"
@update:model-value="onSearchQueryUpdate"
/>
<div
class="flex shrink-0 items-center gap-2"
:class="{ 'ml-2': !showSearch }"
>
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="filterTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
>
<i class="icon-[lucide--list-filter] size-4" />
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-[12rem] flex-col items-stretch">
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('all', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] size-4"
/>
</Button>
<div class="mx-2 mt-1 h-px" />
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('current', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</Button>
</div>
</template>
</Popover>
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="sortTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-[12rem] flex-col items-stretch">
<template v-for="(mode, index) in jobSortModes" :key="mode">
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectSortMode(mode, close)"
>
<span>{{ sortLabel(mode) }}</span>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] size-4 text-text-secondary"
/>
</Button>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
/>
</template>
</div>
</template>
</Popover>
<Button
v-if="showAssetsAction"
v-tooltip.top="showAssetsTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
@click="emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'
import type { JobSortMode } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
const {
hideShowAssetsAction = false,
showSearch = false,
searchPlaceholder
} = defineProps<{
hideShowAssetsAction?: boolean
showSearch?: boolean
searchPlaceholder?: string
}>()
const selectedWorkflowFilter = defineModel<'all' | 'current'>(
'selectedWorkflowFilter',
{ required: true }
)
const selectedSortMode = defineModel<JobSortMode>('selectedSortMode', {
required: true
})
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const emit = defineEmits<{
(e: 'showAssets'): void
}>()
const { t } = useI18n()
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
)
const sortTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
)
const showAssetsTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
)
const showAssetsAction = computed(() => !hideShowAssetsAction)
const searchPlaceholderText = computed(
() => searchPlaceholder ?? t('sideToolbar.queueProgressOverlay.searchJobs')
)
const selectWorkflowFilter = (value: 'all' | 'current') => {
selectedWorkflowFilter.value = value
}
const onSelectWorkflowFilter = (
value: 'all' | 'current',
close: () => void
) => {
selectWorkflowFilter(value)
close()
}
const selectSortMode = (value: JobSortMode) => {
selectedSortMode.value = value
}
const onSelectSortMode = (value: JobSortMode, close: () => void) => {
selectSortMode(value)
close()
}
const onSearchQueryUpdate = (value: string | undefined) => {
searchQuery.value = value ?? ''
}
const sortLabel = (mode: JobSortMode) => {
if (mode === 'mostRecent') {
return t('queue.jobList.sortMostRecent')
}
if (mode === 'totalGenerationTime') {
return t('queue.jobList.sortTotalGenerationTime')
}
return ''
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap">
<Button
v-for="tab in visibleJobTabs"
:key="tab"
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
size="sm"
@click="$emit('update:selectedJobTab', tab)"
>
{{ tabLabel(tab) }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { jobTabs } from '@/composables/queue/useJobList'
import type { JobTab } from '@/composables/queue/useJobList'
const { selectedJobTab, hasFailedJobs } = defineProps<{
selectedJobTab: JobTab
hasFailedJobs: boolean
}>()
defineEmits<{
(e: 'update:selectedJobTab', value: JobTab): void
}>()
const { t } = useI18n()
const visibleJobTabs = computed(() =>
hasFailedJobs ? jobTabs : jobTabs.filter((tab) => tab !== 'Failed')
)
const tabLabel = (tab: JobTab) => {
if (tab === 'All') return t('g.all')
if (tab === 'Completed') return t('g.completed')
return t('g.failed')
}
</script>

View File

@@ -76,4 +76,24 @@ describe('JobFiltersBar', () => {
expect(wrapper.emitted('showAssets')).toHaveLength(1)
})
it('hides the assets icon button when hideShowAssetsAction is true', () => {
const wrapper = mount(JobFiltersBar, {
props: {
selectedJobTab: 'All',
selectedWorkflowFilter: 'all',
selectedSortMode: 'mostRecent',
hasFailedJobs: false,
hideShowAssetsAction: true
},
global: {
plugins: [i18n],
directives: { tooltip: () => undefined }
}
})
expect(
wrapper.find('button[aria-label="Show assets panel"]').exists()
).toBe(false)
})
})

View File

@@ -1,225 +1,46 @@
<template>
<div class="flex items-center justify-between gap-2 px-3">
<div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap">
<Button
v-for="tab in visibleJobTabs"
:key="tab"
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
size="sm"
class="px-3"
@click="$emit('update:selectedJobTab', tab)"
>
{{ tabLabel(tab) }}
</Button>
</div>
</div>
<div class="ml-2 flex shrink-0 items-center gap-2">
<Button
v-if="showWorkflowFilter"
v-tooltip.top="filterTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
@click="onFilterClick"
>
<i class="icon-[lucide--list-filter] size-4" />
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
<Popover
v-if="showWorkflowFilter"
ref="filterPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<Button
class="w-full justify-between"
variant="textonly"
size="sm"
@click="selectWorkflowFilter('all')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] size-4"
/>
</Button>
<div class="mx-2 mt-1 h-px" />
<Button
class="w-full justify-between"
variant="textonly"
@click="selectWorkflowFilter('current')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</Button>
</div>
</Popover>
<Button
v-tooltip.top="sortTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
@click="onSortClick"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
<Popover
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<template v-for="(mode, index) in jobSortModes" :key="mode">
<Button
class="w-full justify-between"
variant="textonly"
size="sm"
@click="selectSortMode(mode)"
>
<span>{{ sortLabel(mode) }}</span>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] size-4 text-text-secondary"
/>
</Button>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
/>
</template>
</div>
</Popover>
<Button
v-tooltip.top="showAssetsTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
@click="$emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
</div>
<JobFilterTabs
:selected-job-tab="selectedJobTab"
:has-failed-jobs="hasFailedJobs"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
/>
<JobFilterActions
:selected-workflow-filter="selectedWorkflowFilter"
:selected-sort-mode="selectedSortMode"
:hide-show-assets-action="hideShowAssetsAction"
@show-assets="$emit('showAssets')"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
"
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
/>
</div>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
const props = defineProps<{
const {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
hasFailedJobs,
hideShowAssetsAction
} = defineProps<{
selectedJobTab: JobTab
selectedWorkflowFilter: 'all' | 'current'
selectedSortMode: JobSortMode
hasFailedJobs: boolean
hideShowAssetsAction?: boolean
}>()
const emit = defineEmits<{
defineEmits<{
(e: 'showAssets'): void
(e: 'update:selectedJobTab', value: JobTab): void
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
(e: 'update:selectedSortMode', value: JobSortMode): void
}>()
const { t } = useI18n()
const filterPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
const sortPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
)
const sortTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
)
const showAssetsTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
)
// This can be removed when cloud implements /jobs and we switch to it.
const showWorkflowFilter = !isCloud
const visibleJobTabs = computed(() =>
props.hasFailedJobs ? jobTabs : jobTabs.filter((tab) => tab !== 'Failed')
)
const onFilterClick = (event: Event) => {
if (filterPopoverRef.value) {
filterPopoverRef.value.toggle(event)
}
}
const selectWorkflowFilter = (value: 'all' | 'current') => {
filterPopoverRef.value?.hide()
emit('update:selectedWorkflowFilter', value)
}
const onSortClick = (event: Event) => {
if (sortPopoverRef.value) {
sortPopoverRef.value.toggle(event)
}
}
const selectSortMode = (value: JobSortMode) => {
sortPopoverRef.value?.hide()
emit('update:selectedSortMode', value)
}
const tabLabel = (tab: JobTab) => {
if (tab === 'All') return t('g.all')
if (tab === 'Completed') return t('g.completed')
return t('g.failed')
}
const sortLabel = (mode: JobSortMode) => {
if (mode === 'mostRecent') {
return t('queue.jobList.sortMostRecent')
}
if (mode === 'totalGenerationTime') {
return t('queue.jobList.sortTotalGenerationTime')
}
return ''
}
</script>

View File

@@ -14,7 +14,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -36,12 +36,12 @@ import SubgraphEditor from './subgraph/SubgraphEditor.vue'
import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionStore)
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
const { findParentGroup } = useGraphHierarchy()
@@ -98,7 +98,7 @@ type RightSidePanelTabList = Array<{
const hasDirectNodeError = computed(() =>
selectedNodes.value.some((node) =>
executionStore.activeGraphErrorNodeIds.has(String(node.id))
executionErrorStore.activeGraphErrorNodeIds.has(String(node.id))
)
)
@@ -106,7 +106,7 @@ const hasContainerInternalError = computed(() => {
if (allErrorExecutionIds.value.length === 0) return false
return selectedNodes.value.some((node) => {
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
return executionStore.hasInternalErrorForNode(node.id)
return executionErrorStore.isContainerWithInternalError(node)
})
})

View File

@@ -94,7 +94,7 @@ describe('TabErrors.vue', () => {
it('renders prompt-level errors (Group title = error message)', async () => {
const wrapper = mountComponent({
execution: {
executionError: {
lastPromptError: {
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
@@ -118,7 +118,7 @@ describe('TabErrors.vue', () => {
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
executionError: {
lastNodeErrors: {
'6': {
class_type: 'CLIPTextEncode',
@@ -143,7 +143,7 @@ describe('TabErrors.vue', () => {
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
executionError: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '10',
@@ -167,7 +167,7 @@ describe('TabErrors.vue', () => {
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
const wrapper = mountComponent({
execution: {
executionError: {
lastNodeErrors: {
'1': {
class_type: 'CLIPTextEncode',
@@ -198,7 +198,7 @@ describe('TabErrors.vue', () => {
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
const wrapper = mountComponent({
execution: {
executionError: {
lastNodeErrors: {
'1': {
class_type: 'TestNode',

View File

@@ -3,15 +3,17 @@ import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { isCloud } from '@/platform/distribution/types'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getNodeByExecutionId,
getExecutionIdByNode,
getRootParentNode
} from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -19,6 +21,7 @@ import { isLGraphNode } from '@/utils/litegraphUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { st } from '@/i18n'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { isNodeExecutionId } from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
@@ -192,38 +195,42 @@ export function useErrorGroups(
searchQuery: Ref<string>,
t: (key: string) => string
) {
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const canvasStore = useCanvasStore()
const collapseState = reactive<Record<string, boolean>>({})
const selectedNodeInfo = computed(() => {
const items = canvasStore.selectedItems
const nodeIds = new Set<string>()
const containerIds = new Set<string>()
const containerExecutionIds = new Set<NodeExecutionId>()
for (const item of items) {
if (!isLGraphNode(item)) continue
nodeIds.add(String(item.id))
if (item instanceof SubgraphNode || isGroupNode(item)) {
containerIds.add(String(item.id))
if (
(item instanceof SubgraphNode || isGroupNode(item)) &&
app.rootGraph
) {
const execId = getExecutionIdByNode(app.rootGraph, item)
if (execId) containerExecutionIds.add(execId)
}
}
return {
nodeIds: nodeIds.size > 0 ? nodeIds : null,
containerIds
containerExecutionIds
}
})
const isSingleNodeSelected = computed(
() =>
selectedNodeInfo.value.nodeIds?.size === 1 &&
selectedNodeInfo.value.containerIds.size === 0
selectedNodeInfo.value.containerExecutionIds.size === 0
)
const errorNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
for (const execId of executionStore.allErrorExecutionIds) {
for (const execId of executionErrorStore.allErrorExecutionIds) {
const node = getNodeByExecutionId(app.rootGraph, execId)
if (node) map.set(execId, node)
}
@@ -237,8 +244,9 @@ export function useErrorGroups(
const graphNode = errorNodeCache.value.get(executionNodeId)
if (graphNode && nodeIds.has(String(graphNode.id))) return true
for (const containerId of selectedNodeInfo.value.containerIds) {
if (executionNodeId.startsWith(`${containerId}:`)) return true
for (const containerExecId of selectedNodeInfo.value
.containerExecutionIds) {
if (executionNodeId.startsWith(`${containerExecId}:`)) return true
}
return false
@@ -262,10 +270,10 @@ export function useErrorGroups(
}
function processPromptError(groupsMap: Map<string, GroupEntry>) {
if (selectedNodeInfo.value.nodeIds || !executionStore.lastPromptError)
if (selectedNodeInfo.value.nodeIds || !executionErrorStore.lastPromptError)
return
const error = executionStore.lastPromptError
const error = executionErrorStore.lastPromptError
const groupTitle = error.message
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
@@ -293,10 +301,10 @@ export function useErrorGroups(
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionStore.lastNodeErrors) return
if (!executionErrorStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
executionStore.lastNodeErrors
executionErrorStore.lastNodeErrors
)) {
addNodeErrorToGroup(
groupsMap,
@@ -316,9 +324,9 @@ export function useErrorGroups(
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionStore.lastExecutionError) return
if (!executionErrorStore.lastExecutionError) return
const e = executionStore.lastExecutionError
const e = executionErrorStore.lastExecutionError
addNodeErrorToGroup(
groupsMap,
String(e.node_id),

View File

@@ -9,7 +9,7 @@ import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
@@ -62,7 +62,7 @@ watchEffect(() => (widgets.value = widgetsProp))
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const nodeDefStore = useNodeDefStore()
const { t } = useI18n()
@@ -110,7 +110,9 @@ const targetNode = computed<LGraphNode | null>(() => {
const hasDirectError = computed(() => {
if (!targetNode.value) return false
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
return executionErrorStore.activeGraphErrorNodeIds.has(
String(targetNode.value.id)
)
})
const hasContainerInternalError = computed(() => {
@@ -119,7 +121,7 @@ const hasContainerInternalError = computed(() => {
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
if (!isContainer) return false
return executionStore.hasInternalErrorForNode(targetNode.value.id)
return executionErrorStore.isContainerWithInternalError(targetNode.value)
})
const nodeHasError = computed(() => {

View File

@@ -53,6 +53,7 @@ import NodeSearchCategoryTreeNode, {
CATEGORY_UNSELECTED_CLASS
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
@@ -64,6 +65,7 @@ const selectedCategory = defineModel<string>('selectedCategory', {
})
const { t } = useI18n()
const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const topCategories = computed(() => [
@@ -79,7 +81,7 @@ const hasEssentialNodes = computed(() =>
const sourceCategories = computed(() => {
const categories = []
if (hasEssentialNodes.value) {
if (flags.nodeLibraryEssentialsEnabled && hasEssentialNodes.value) {
categories.push({ id: 'essentials', label: t('g.essentials') })
}
categories.push({ id: 'custom', label: t('g.custom') })

View File

@@ -1,23 +1,7 @@
<template>
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="!isInFolderView && isQueuePanelV2Enabled && activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
<ActiveMediaAssetCard
v-for="job in activeJobItems"
:key="job.id"
:job="job"
/>
</div>
<!-- Assets Header -->
<div
v-if="assets.length"
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
>
<div v-if="assets.length" class="px-2 2xl:px-4">
<div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
@@ -59,25 +43,18 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
import { useJobList } from '@/composables/queue/useJobList'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isActiveJobState } from '@/utils/queueUtil'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
isSelected,
isInFolderView = false,
assetType = 'output',
showOutputCount,
getOutputCount
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
isInFolderView?: boolean
assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
@@ -92,19 +69,9 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
type AssetGridItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
)
const assetItems = computed<AssetGridItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,

View File

@@ -1,6 +1,9 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { defineComponent } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
@@ -10,141 +13,80 @@ vi.mock('vue-i18n', () => ({
})
}))
vi.mock('@/composables/queue/useJobActions', () => ({
useJobActions: () => ({
cancelAction: { variant: 'ghost', label: 'Cancel', icon: 'pi pi-times' },
canCancelJob: ref(false),
runCancelJob: vi.fn()
})
}))
const mockJobItems = ref<
Array<{
id: string
title: string
meta: string
state: string
createTime?: number
}>
>([])
vi.mock('@/composables/queue/useJobList', () => ({
useJobList: () => ({
jobItems: mockJobItems
})
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
isAssetDeleting: () => false
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => key === 'Comfy.Queue.QPOV2'
const VirtualGridStub = defineComponent({
name: 'VirtualGrid',
props: {
items: {
type: Array,
default: () => []
}
},
template:
'<div><slot v-for="item in items" :key="item.key" name="item" :item="item" /></div>'
})
const buildAsset = (id: string, name: string): AssetItem =>
({
id,
name,
tags: []
}) satisfies AssetItem
const buildOutputItem = (asset: AssetItem): OutputStackListItem => ({
key: `asset-${asset.id}`,
asset
})
const mountListView = (assetItems: OutputStackListItem[] = []) =>
mount(AssetsSidebarListView, {
props: {
assetItems,
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {},
assetType: 'output'
},
global: {
stubs: {
VirtualGrid: VirtualGridStub
}
}
})
}))
vi.mock('@/utils/queueUtil', () => ({
isActiveJobState: (state: string) =>
state === 'pending' || state === 'running'
}))
vi.mock('@/utils/queueDisplay', () => ({
iconForJobState: () => 'pi pi-spinner'
}))
vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: () => undefined
}))
vi.mock('@/platform/assets/utils/mediaIconUtil', () => ({
iconForMediaType: () => 'pi pi-file'
}))
vi.mock('@/utils/formatUtil', () => ({
formatDuration: (d: number) => `${d}s`,
formatSize: (s: number) => `${s}B`,
getMediaTypeFromFilename: () => 'image',
truncateFilename: (name: string) => name
}))
describe('AssetsSidebarListView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockJobItems.value = []
it('shows generated assets header when there are assets', () => {
const wrapper = mountListView([buildOutputItem(buildAsset('a1', 'x.png'))])
expect(wrapper.text()).toContain('sideToolbar.generatedAssetsHeader')
})
const defaultProps = {
assetItems: [],
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {}
}
it('does not show assets header when there are no assets', () => {
const wrapper = mountListView([])
it('displays active jobs in oldest-first order (FIFO)', () => {
mockJobItems.value = [
{
id: 'newest',
title: 'Newest Job',
meta: '',
state: 'pending',
createTime: 3000
},
{
id: 'middle',
title: 'Middle Job',
meta: '',
state: 'running',
createTime: 2000
},
{
id: 'oldest',
title: 'Oldest Job',
meta: '',
state: 'pending',
createTime: 1000
}
]
const wrapper = mount(AssetsSidebarListView, {
props: defaultProps,
shallow: true
})
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(jobListItems).toHaveLength(3)
const displayedTitles = jobListItems.map((item) =>
item.props('primaryText')
)
expect(displayedTitles).toEqual(['Oldest Job', 'Middle Job', 'Newest Job'])
expect(wrapper.text()).not.toContain('sideToolbar.generatedAssetsHeader')
})
it('excludes completed and failed jobs from active jobs section', () => {
mockJobItems.value = [
{ id: 'pending', title: 'Pending', meta: '', state: 'pending' },
{ id: 'completed', title: 'Completed', meta: '', state: 'completed' },
{ id: 'failed', title: 'Failed', meta: '', state: 'failed' },
{ id: 'running', title: 'Running', meta: '', state: 'running' }
]
it('marks mp4 assets as video previews', () => {
const videoAsset = {
...buildAsset('video-asset', 'clip.mp4'),
preview_url: '/api/view/clip.mp4',
user_metadata: {}
} satisfies AssetItem
const wrapper = mount(AssetsSidebarListView, {
props: defaultProps,
shallow: true
})
const wrapper = mountListView([buildOutputItem(videoAsset)])
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(jobListItems).toHaveLength(2)
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
const displayedTitles = jobListItems.map((item) =>
item.props('primaryText')
)
expect(displayedTitles).toContain('Running')
expect(displayedTitles).toContain('Pending')
expect(displayedTitles).not.toContain('Completed')
expect(displayedTitles).not.toContain('Failed')
expect(assetListItem).toBeDefined()
expect(assetListItem?.props('previewUrl')).toBe('/api/view/clip.mp4')
expect(assetListItem?.props('isVideoPreview')).toBe(true)
})
})

View File

@@ -1,48 +1,6 @@
<template>
<div class="flex h-full flex-col">
<div
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
:key="job.id"
:class="
cn(
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-default'
)
"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="onJobEnter(job.id)"
@mouseleave="onJobLeave(job.id)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="canCancelJob"
:variant="cancelAction.variant"
size="icon"
:aria-label="cancelAction.label"
@click.stop="runCancelJob()"
>
<i :class="cancelAction.icon" class="size-4" />
</Button>
</template>
</AssetsListItem>
</div>
<div
v-if="assetItems.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div v-if="assetItems.length" class="px-2">
<div
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
@@ -76,7 +34,7 @@
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
type: getMediaTypeFromFilename(item.asset.name)
type: getAssetMediaType(item.asset)
})
"
:class="
@@ -87,9 +45,8 @@
"
:preview-url="item.asset.preview_url"
:preview-alt="item.asset.name"
:icon-name="
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
:stack-count="getStackCount(item.asset)"
@@ -119,31 +76,25 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { useAssetsStore } from '@/stores/assetsStore'
import { isActiveJobState } from '@/utils/queueUtil'
import {
formatDuration,
formatSize,
getMediaTypeFromFilename,
truncateFilename
} from '@/utils/formatUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assetItems,
@@ -170,24 +121,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
)
const hoveredJob = computed(() =>
hoveredJobId.value
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
null)
: null
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
const listGridStyle = {
display: 'grid',
@@ -200,6 +134,14 @@ function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
}
function getAssetMediaType(asset: AssetItem) {
return getMediaTypeFromFilename(asset.name)
}
function isVideoAsset(asset: AssetItem): boolean {
return getAssetMediaType(asset) === 'video'
}
function getAssetSecondaryText(asset: AssetItem): string {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.executionTimeInSeconds === 'number') {
@@ -240,16 +182,6 @@ function getAssetCardClass(selected: boolean): string {
)
}
function onJobEnter(jobId: string) {
hoveredJobId.value = jobId
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
function onAssetEnter(assetId: string) {
hoveredAssetId.value = assetId
}
@@ -259,13 +191,4 @@ function onAssetLeave(assetId: string) {
hoveredAssetId.value = null
}
}
function getJobIconClass(job: JobListItem): string | undefined {
const classes = []
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
classes.push('animate-spin')
}
return classes.length ? classes.join(' ') : undefined
}
</script>

View File

@@ -53,31 +53,7 @@
class="pb-1 px-2 2xl:px-4"
:show-generation-time-sort="activeTab === 'output'"
/>
<div
v-if="isQueuePanelV2Enabled && !isInFolderView"
class="flex items-center justify-between px-2 py-2 2xl:px-4"
>
<span class="text-sm text-muted-foreground">
{{ activeJobsLabel }}
</span>
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
</span>
<Button
variant="destructive"
size="icon"
:aria-label="
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
"
:disabled="queuedCount === 0"
@click="handleClearQueue"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
</div>
<Divider v-else type="dashed" class="my-2" />
<Divider type="dashed" class="my-2" />
</template>
<template #body>
<div
@@ -126,7 +102,6 @@
v-else
:assets="displayAssets"
:is-selected="isSelected"
:is-in-folder-view="isInFolderView"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@@ -224,18 +199,27 @@ import {
useDebounceFn,
useElementHover,
useResizeObserver,
useStorage
useStorage,
useTimeoutFn
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import { useToast } from 'primevue/usetoast'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
// Lazy-loaded to avoid pulling THREE.js into the main bundle
const Load3dViewerContent = () =>
import('@/components/load3d/Load3dViewerContent.vue')
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
@@ -258,20 +242,12 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { ResultItemImpl } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const { activeJobsCount } = storeToRefs(queueStore)
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()
@@ -284,12 +260,7 @@ const viewMode = useStorage<'list' | 'grid'>(
'Comfy.Assets.Sidebar.ViewMode',
'grid'
)
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isListView = computed(
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
)
const isListView = computed(() => viewMode.value === 'list')
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
@@ -321,16 +292,6 @@ const formattedExecutionTime = computed(() => {
return formatDuration(folderExecutionTime.value * 1000)
})
const queuedCount = computed(() => queueStore.pendingTasks.length)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobs',
{ count: n(count) },
count
)
})
const toast = useToast()
const inputAssets = useMediaAssets('input')
@@ -456,17 +417,12 @@ const isFolderLoading = computed(
const showLoadingState = computed(
() =>
(loading.value || isFolderLoading.value) &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
(loading.value || isFolderLoading.value) && displayAssets.value.length === 0
)
const showEmptyState = computed(
() =>
!loading.value &&
!isFolderLoading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
!loading.value && !isFolderLoading.value && displayAssets.value.length === 0
)
watch(visibleAssets, (newAssets) => {
@@ -537,7 +493,16 @@ function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
handleAssetClick(asset, index, assetList)
}
const { start: scheduleCleanup, stop: cancelCleanup } = useTimeoutFn(
() => {
contextMenuAsset.value = null
},
0,
{ immediate: false }
)
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
cancelCleanup()
contextMenuAsset.value = asset
void nextTick(() => {
contextMenuRef.value?.show(event)
@@ -545,10 +510,7 @@ function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
}
function handleContextMenuHide() {
// Delay clearing to allow command callbacks to emit before component unmounts
requestAnimationFrame(() => {
contextMenuAsset.value = null
})
scheduleCleanup()
}
const handleBulkDownload = (assets: AssetItem[]) => {
@@ -562,16 +524,6 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
}
}
const handleClearQueue = async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
}
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
await addMultipleToWorkflow(assets)
clearSelection()

View File

@@ -0,0 +1,188 @@
<template>
<SidebarTabTemplate :title="$t('queue.jobHistory')">
<template #alt-title>
<div class="ml-auto flex shrink-0 items-center">
<JobHistoryActionsMenu @clear-history="showQueueClearHistoryDialog" />
</div>
</template>
<template #header>
<div class="flex flex-col gap-2 pb-1">
<div class="px-3 py-2">
<JobFilterTabs
v-model:selected-job-tab="selectedJobTab"
:has-failed-jobs="hasFailedJobs"
/>
</div>
<JobFilterActions
v-model:selected-workflow-filter="selectedWorkflowFilter"
v-model:selected-sort-mode="selectedSortMode"
v-model:search-query="searchQuery"
class="px-3"
:hide-show-assets-action="true"
:show-search="true"
:search-placeholder="t('sideToolbar.queueProgressOverlay.searchJobs')"
/>
</div>
<div
class="flex items-center justify-between px-3 pb-1 text-xs leading-none text-text-primary"
>
<span class="text-text-secondary">{{ activeQueueSummary }}</span>
<div class="flex items-center gap-2">
<span class="text-xs text-base-foreground">
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
</span>
<Button
variant="destructive"
size="icon"
:aria-label="
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
"
:disabled="queuedCount === 0"
@click="clearQueuedWorkflows"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
</div>
</template>
<template #body>
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="onViewItem"
@menu="onMenuItem"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<ResultGallery
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
</template>
</SidebarTabTemplate>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
import JobAssetsList from '@/components/queue/job/JobAssetsList.vue'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
const { t, n } = useI18n()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
searchQuery,
hasFailedJobs,
filteredTasks,
groupedJobItems
} = useJobList()
const displayedJobGroups = computed(() => groupedJobItems.value)
const runningCount = computed(() => queueStore.runningTasks.length)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const runningJobsLabel = computed(() =>
t('sideToolbar.queueProgressOverlay.runningJobsLabel', {
count: n(runningCount.value)
})
)
const queuedJobsLabel = computed(() =>
t('sideToolbar.queueProgressOverlay.queuedJobsLabel', {
count: n(queuedCount.value)
})
)
const activeQueueSummary = computed(() => {
if (runningCount.value === 0 && queuedCount.value === 0) {
return t('sideToolbar.queueProgressOverlay.noActiveJobs')
}
if (queuedCount.value === 0) {
return runningJobsLabel.value
}
if (runningCount.value === 0) {
return queuedJobsLabel.value
}
return t('sideToolbar.queueProgressOverlay.runningQueuedSummary', {
running: runningJobsLabel.value,
queued: queuedJobsLabel.value
})
})
const clearQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
})
const {
galleryActiveIndex,
galleryItems,
onViewItem: openResultGallery
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await openResultGallery(item)
})
const onInspectAsset = (item: JobListItem) => {
void onViewItem(item)
}
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { jobMenuEntries, cancelJob } = useJobMenu(
() => currentMenuItem.value,
onInspectAsset
)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await cancelJob(item)
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
})
</script>

View File

@@ -113,7 +113,7 @@ describe('NodeLibrarySidebarTabV2', () => {
const wrapper = mountComponent()
const triggers = wrapper.findAllComponents(TabsTrigger)
expect(triggers.length).toBe(3)
expect(triggers).toHaveLength(3)
})
it('should render search box', () => {

View File

@@ -52,7 +52,7 @@
:value="tab.value"
:class="
cn(
'select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'flex-1 text-center select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'text-sm text-foreground transition-colors',
selectedTab === tab.value
? 'bg-comfy-input font-bold'
@@ -70,7 +70,9 @@
<!-- Tab content (scrollable) -->
<TabsRoot v-model="selectedTab" class="h-full">
<EssentialNodesPanel
v-if="selectedTab === 'essentials'"
v-if="
flags.nodeLibraryEssentialsEnabled && selectedTab === 'essentials'
"
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
@node-click="handleNodeClick"
@@ -94,7 +96,7 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { cn } from '@/utils/tailwindUtil'
import { useLocalStorage } from '@vueuse/core'
import {
DropdownMenuContent,
@@ -109,11 +111,13 @@ import {
TabsRoot,
TabsTrigger
} from 'reka-ui'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBoxV2.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'
import {
DEFAULT_SORTING_ID,
DEFAULT_TAB_ID,
@@ -135,11 +139,22 @@ import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
const { flags } = useFeatureFlags()
const selectedTab = useLocalStorage<TabId>(
'Comfy.NodeLibrary.Tab',
DEFAULT_TAB_ID
)
watchEffect(() => {
if (
!flags.nodeLibraryEssentialsEnabled &&
selectedTab.value === 'essentials'
) {
selectedTab.value = DEFAULT_TAB_ID
}
})
const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
'Comfy.NodeLibrary.SortByTab',
{
@@ -148,15 +163,7 @@ const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
custom: 'alphabetical'
}
)
const sortOrder = computed({
get: () => sortOrderByTab.value[selectedTab.value],
set: (value) => {
sortOrderByTab.value = {
...sortOrderByTab.value,
[selectedTab.value]: value
}
}
})
const sortOrder = usePerTabState(selectedTab, sortOrderByTab)
const sortingOptions = computed(() =>
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
@@ -174,12 +181,7 @@ const expandedKeysByTab = ref<Record<TabId, string[]>>({
all: [],
custom: []
})
const expandedKeys = computed({
get: () => expandedKeysByTab.value[selectedTab.value],
set: (value) => {
expandedKeysByTab.value[selectedTab.value] = value
}
})
const expandedKeys = usePerTabState(selectedTab, expandedKeysByTab)
const nodeDefStore = useNodeDefStore()
const { startDrag } = useNodeDragToCanvas()
@@ -336,11 +338,21 @@ async function handleSearch() {
expandedKeys.value = allKeys
}
const tabs = computed(() => [
{ value: 'essentials', label: t('sideToolbar.nodeLibraryTab.essentials') },
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
])
const tabs = computed(() => {
const baseTabs: Array<{ value: TabId; label: string }> = [
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
]
return flags.nodeLibraryEssentialsEnabled
? [
{
value: 'essentials' as TabId,
label: t('sideToolbar.nodeLibraryTab.essentials')
},
...baseTabs
]
: baseTabs
})
onMounted(() => {
searchBoxRef.value?.focus()

View File

@@ -10,6 +10,7 @@
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
:root="favoritesRoot"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
@@ -26,6 +27,7 @@
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
:root="section.root"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>

View File

@@ -1,13 +1,7 @@
<template>
<div
:class="
cn(
'flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content',
'bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border',
'aspect-square'
)
"
:data-node-name="nodeDef?.display_name"
class="group relative flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border aspect-square"
:data-node-name="node.data?.display_name"
draggable="true"
@click="handleClick"
@dragstart="handleDragStart"
@@ -18,11 +12,12 @@
<div class="flex flex-1 items-center justify-center">
<i :class="cn(nodeIcon, 'size-14 text-muted-foreground')" />
</div>
<span
class="shrink-0 h-8 text-sm font-bold text-center text-foreground line-clamp-2 leading-4"
<TextTickerMultiLine
class="shrink-0 h-8 w-full text-xs font-bold text-foreground leading-4"
>
{{ nodeDef?.display_name }}
</span>
{{ node.data?.display_name }}
</TextTickerMultiLine>
</div>
<Teleport v-if="showPreview" to="body">
@@ -30,7 +25,10 @@
:ref="(el) => (previewRef = el as HTMLElement)"
:style="nodePreviewStyle"
>
<NodePreviewCard :node-def="nodeDef!" :show-inputs-and-outputs="false" />
<NodePreviewCard
:node-def="node.data!"
:show-inputs-and-outputs="false"
/>
</div>
</Teleport>
</template>
@@ -39,6 +37,7 @@
import { kebabCase } from 'es-toolkit/string'
import { computed, inject } from 'vue'
import TextTickerMultiLine from '@/components/common/TextTickerMultiLine.vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { SidebarContainerKey } from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
@@ -54,9 +53,8 @@ const emit = defineEmits<{
click: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
const nodeDef = computed(() => node.data)
const panelRef = inject(SidebarContainerKey, undefined)
const nodeDef = computed(() => node.data)
const {
previewRef,
@@ -69,13 +67,13 @@ const {
} = useNodePreviewAndDrag(nodeDef, { panelRef })
const nodeIcon = computed(() => {
const nodeName = nodeDef.value?.name
const nodeName = node.data?.name
const iconName = nodeName ? kebabCase(nodeName) : 'node'
return `icon-[comfy--${iconName}]`
})
function handleClick() {
if (!nodeDef.value) return
if (!node.data) return
emit('click', node)
}
</script>

View File

@@ -10,7 +10,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
@@ -31,10 +30,8 @@ withDefaults(
}
)
const { t } = useI18n()
const cloudBadge = computed<TopbarBadgeType>(() => ({
label: t('g.beta'),
icon: 'icon-[lucide--cloud]',
text: 'Comfy Cloud'
}))
</script>

View File

@@ -113,12 +113,13 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
}))
// Mock the useSubscriptionDialog composable
const mockSubscriptionDialogShow = vi.fn()
const mockShowPricingTable = vi.fn()
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: vi.fn(() => ({
show: mockSubscriptionDialogShow,
show: vi.fn(),
showPricingTable: mockShowPricingTable,
hide: vi.fn()
}))
})
@@ -318,8 +319,8 @@ describe('CurrentUserPopoverLegacy', () => {
await plansPricingItem.trigger('click')
// Verify subscription dialog show was called
expect(mockSubscriptionDialogShow).toHaveBeenCalled()
// Verify showPricingTable was called
expect(mockShowPricingTable).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()

View File

@@ -46,6 +46,16 @@
class="icon-[lucide--circle-help] cursor-help text-base text-muted-foreground mr-auto"
/>
<Button
v-if="isFreeTier"
variant="gradient"
size="sm"
data-testid="upgrade-to-add-credits-button"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
</Button>
<Button
v-else
variant="secondary"
size="sm"
class="text-base-foreground"
@@ -61,7 +71,7 @@
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
size="sm"
variant="gradient"
button-variant="gradient"
@subscribed="handleSubscribed"
/>
</div>
@@ -170,6 +180,7 @@ const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
isFreeTier,
subscriptionTierName,
subscriptionTier,
fetchStatus
@@ -195,7 +206,10 @@ const formattedBalance = computed(() => {
const canUpgrade = computed(() => {
const tier = subscriptionTier.value
return (
tier === 'FOUNDERS_EDITION' || tier === 'STANDARD' || tier === 'CREATOR'
tier === 'FREE' ||
tier === 'FOUNDERS_EDITION' ||
tier === 'STANDARD' ||
tier === 'CREATOR'
)
})
@@ -205,7 +219,7 @@ const handleOpenUserSettings = () => {
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.show()
subscriptionDialog.showPricingTable()
emit('close')
}
@@ -234,6 +248,11 @@ const handleOpenPartnerNodesInfo = () => {
emit('close')
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable()
emit('close')
}
const handleLogout = async () => {
await handleSignOut()
emit('close')

View File

@@ -0,0 +1,25 @@
<template>
<Button
v-if="isFreeTier"
class="mr-2 shrink-0 whitespace-nowrap"
variant="gradient"
size="sm"
data-testid="topbar-subscribe-button"
@click="handleClick"
>
{{ $t('subscription.subscribeForMore') }}
</Button>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
const { isFreeTier } = useBillingContext()
const subscriptionDialog = useSubscriptionDialog()
function handleClick() {
subscriptionDialog.showPricingTable()
}
</script>

View File

@@ -76,7 +76,7 @@
v-if="isLoggedIn"
:show-arrow="false"
compact
class="shrink-0 p-1"
class="shrink-0 p-1 grid w-10"
/>
<LoginButton v-else-if="isDesktop" class="p-1" />
</div>

View File

@@ -15,10 +15,16 @@ defineOptions({
inheritAttrs: false
})
defineProps<{
const {
entries,
icon,
to,
showArrow = true
} = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
showArrow?: boolean
}>()
</script>
@@ -39,7 +45,7 @@ defineProps<{
v-bind="$attrs"
class="z-1700 rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
>
<slot>
<slot :close>
<div class="flex flex-col p-1">
<template v-for="item in entries ?? []" :key="item.label">
<div
@@ -71,7 +77,10 @@ defineProps<{
</template>
</div>
</slot>
<PopoverArrow class="fill-base-background stroke-border-subtle" />
<PopoverArrow
v-if="showArrow"
class="fill-base-background stroke-border-subtle"
/>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>

View File

@@ -19,7 +19,9 @@ export const buttonVariants = cva({
'text-muted-foreground bg-transparent hover:bg-secondary-background-hover',
'destructive-textonly':
'text-destructive-background bg-transparent hover:bg-destructive-background/10',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90'
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
gradient:
'bg-subscription-gradient text-white border-transparent hover:opacity-90'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -47,7 +49,8 @@ const variants = [
'textonly',
'muted-textonly',
'destructive-textonly',
'overlay-white'
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
ButtonVariants['size']

View File

@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Input from './Input.vue'
const meta: Meta<typeof Input> = {
title: 'Components/Input',
component: Input,
tags: ['autodocs'],
render: (args) => ({
components: { Input },
setup: () => ({ args }),
template: '<Input v-bind="args" placeholder="Enter text..." />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithValue: Story = {
args: {
modelValue: 'Hello, world!'
}
}
export const Disabled: Story = {
render: (args) => ({
components: { Input },
setup: () => ({ args }),
template: '<Input v-bind="args" placeholder="Disabled input" disabled />'
})
}

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useTemplateRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const modelValue = defineModel<string | number>()
const inputRef = useTemplateRef<HTMLInputElement>('inputEl')
defineExpose({
focus: () => inputRef.value?.focus(),
select: () => inputRef.value?.select()
})
</script>
<template>
<input
ref="inputEl"
v-model="modelValue"
:class="
cn(
'flex h-10 w-full min-w-0 appearance-none rounded-lg border-none bg-secondary-background px-4 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-default disabled:pointer-events-none disabled:opacity-50',
className
)
"
/>
</template>

View File

@@ -16,9 +16,15 @@ import type { FocusCallback } from './tagsInputContext'
const {
disabled = false,
alwaysEditing = false,
class: className,
...restProps
} = defineProps<TagsInputRootProps<T> & { class?: HTMLAttributes['class'] }>()
} = defineProps<
TagsInputRootProps<T> & {
class?: HTMLAttributes['class']
alwaysEditing?: boolean
}
>()
const emits = defineEmits<TagsInputRootEmits<T>>()
const isEditing = ref(false)
@@ -28,9 +34,10 @@ const focusInput = ref<FocusCallback>()
provide(tagsInputFocusKey, (callback: FocusCallback) => {
focusInput.value = callback
})
provide(tagsInputIsEditingKey, isEditing)
const isEditingEnabled = computed(() => alwaysEditing || isEditing.value)
provide(tagsInputIsEditingKey, isEditingEnabled)
const internalDisabled = computed(() => disabled || !isEditing.value)
const internalDisabled = computed(() => disabled || !isEditingEnabled.value)
const delegatedProps = computed(() => ({
...restProps,
@@ -40,7 +47,7 @@ const delegatedProps = computed(() => ({
const forwarded = useForwardPropsEmits(delegatedProps, emits)
async function enableEditing() {
if (!disabled && !isEditing.value) {
if (!disabled && !alwaysEditing && !isEditing.value) {
isEditing.value = true
await nextTick()
focusInput.value?.()
@@ -48,7 +55,9 @@ async function enableEditing() {
}
onClickOutside(rootEl, () => {
isEditing.value = false
if (!alwaysEditing) {
isEditing.value = false
}
})
</script>
@@ -61,7 +70,7 @@ onClickOutside(rootEl, () => {
'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',
!disabled && !isEditingEnabled && 'cursor-pointer',
className
)
"
@@ -69,7 +78,7 @@ onClickOutside(rootEl, () => {
>
<slot :is-empty="modelValue.length === 0" />
<i
v-if="!disabled && !isEditing"
v-if="!disabled && !isEditingEnabled"
aria-hidden="true"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground transition-opacity opacity-0 group-hover:opacity-100"
/>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restAttrs } = defineProps<{
class?: HTMLAttributes['class']
}>()
const modelValue = defineModel<string | number>()
</script>
<template>
<textarea
v-bind="restAttrs"
v-model="modelValue"
:class="
cn(
'flex min-h-16 w-full rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-default disabled:pointer-events-none disabled:opacity-50',
className
)
"
/>
</template>

View File

@@ -77,9 +77,7 @@
>
{{ contentTitle }}
</h2>
<div
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
>
<div :class="contentContainerClass">
<slot name="content" />
</div>
</main>
@@ -153,15 +151,20 @@ const SIZE_CLASSES = {
} as const
type ModalSize = keyof typeof SIZE_CLASSES
type ContentPadding = 'default' | 'compact' | 'none'
const {
contentTitle,
rightPanelTitle,
size = 'lg'
size = 'lg',
leftPanelWidth = '14rem',
contentPadding = 'default'
} = defineProps<{
contentTitle: string
rightPanelTitle?: string
size?: ModalSize
leftPanelWidth?: string
contentPadding?: ContentPadding
}>()
const sizeClasses = computed(() => SIZE_CLASSES[size])
@@ -197,10 +200,18 @@ const showLeftPanel = computed(() => {
return shouldShow
})
const contentContainerClass = computed(() =>
cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto scrollbar-custom',
contentPadding === 'default' && 'px-6 pt-0 pb-10',
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
)
)
const gridStyle = computed(() => ({
gridTemplateColumns: hasRightPanel.value
? `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
: `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr`
? `${hasLeftPanel.value && showLeftPanel.value ? leftPanelWidth : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
: `${hasLeftPanel.value && showLeftPanel.value ? leftPanelWidth : '0rem'} 1fr`
}))
const toggleLeftPanel = () => {

View File

@@ -247,7 +247,7 @@ General-purpose composables:
| `useTreeExpansion` | Handles tree node expansion state |
| `useValueTransform` | Transforms values between formats |
| `useWorkflowAutoSave` | Handles automatic workflow saving |
| `useWorkflowPersistence` | Manages workflow persistence |
| `useWorkflowPersistenceV2` | Manages workflow persistence |
| `useWorkflowValidation` | Validates workflow integrity |
## Usage Guidelines

View File

@@ -9,6 +9,7 @@ import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
@@ -51,6 +52,17 @@ export const useFirebaseAuthActions = () => {
}
const logout = wrapWithErrorHandlingAsync(async () => {
const workflowStore = useWorkflowStore()
if (workflowStore.modifiedWorkflows.length > 0) {
const dialogService = useDialogService()
const confirmed = await dialogService.confirm({
title: t('auth.signOut.unsavedChangesTitle'),
message: t('auth.signOut.unsavedChangesMessage'),
type: 'dirtyClose'
})
if (!confirmed) return
}
await authStore.logout()
toastStore.add({
severity: 'success',

View File

@@ -70,6 +70,11 @@ export interface BillingState {
* Equivalent to `subscription.value?.isActive ?? false`
*/
isActiveSubscription: ComputedRef<boolean>
/**
* Whether the current billing context has a FREE tier subscription.
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
*/
isFreeTier: ComputedRef<boolean>
}
export interface BillingContext extends BillingState, BillingActions {

View File

@@ -120,6 +120,8 @@ function useBillingContextInternal(): BillingContext {
toValue(activeContext.value.isActiveSubscription)
)
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
@@ -238,6 +240,7 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
isFreeTier,
getMaxSeats,
initialize,

View File

@@ -40,6 +40,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
const error = ref<string | null>(null)
const isActiveSubscription = computed(() => legacyIsActiveSubscription.value)
const isFreeTier = computed(() => subscriptionTier.value === 'FREE')
const subscription = computed<SubscriptionInfo | null>(() => {
if (!legacyIsActiveSubscription.value && !subscriptionTier.value) {
@@ -85,6 +86,10 @@ export function useLegacyBilling(): BillingState & BillingActions {
error.value = null
try {
await Promise.all([fetchStatus(), fetchBalance()])
// Re-fetch balance if free tier credits were just lazily granted
if (isFreeTier.value && balance.value?.amountMicros === 0) {
await fetchBalance()
}
isInitialized.value = true
} catch (err) {
error.value =
@@ -173,6 +178,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
isLoading,
error,
isActiveSubscription,
isFreeTier,
// Actions
initialize,

View File

@@ -1,6 +1,6 @@
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useCommandStore } from '@/stores/commandStore'
@@ -23,7 +23,7 @@ export function useImageMenuOptions() {
if (!img) return
const url = new URL(img.src)
url.searchParams.delete('preview')
window.open(url.toString(), '_blank')
void openFileInNewTab(url.toString())
}
const copyImage = async (node: LGraphNode) => {

View File

@@ -100,7 +100,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
expect(options[0]?.action).toBe(mocks.convertToSubgraph)
})
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
it('includes convert and unpack but hides add to library when multiple items with subgraphs are selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: true,
@@ -109,16 +109,11 @@ describe('useSelectionMenuOptions - subgraph options', () => {
const labels = options.map((option) => option.label)
expect(labels).toContain('contextMenu.Convert to Subgraph')
expect(labels).toContain('contextMenu.Add Subgraph to Library')
expect(labels).not.toContain('contextMenu.Add Subgraph to Library')
expect(labels).toContain('contextMenu.Unpack Subgraph')
const convertOption = options.find(
(option) => option.label === 'contextMenu.Convert to Subgraph'
)
expect(convertOption?.action).toBe(mocks.convertToSubgraph)
})
it('hides convert option when only a single subgraph is selected', () => {
it('shows add to library and unpack when a single subgraph is selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: true,
@@ -131,5 +126,10 @@ describe('useSelectionMenuOptions - subgraph options', () => {
'contextMenu.Add Subgraph to Library',
'contextMenu.Unpack Subgraph'
])
const addToLibraryOption = options.find(
(option) => option.label === 'contextMenu.Add Subgraph to Library'
)
expect(addToLibraryOption?.action).toBe(mocks.addSubgraphToLibrary)
})
})

View File

@@ -85,18 +85,18 @@ export function useSelectionMenuOptions() {
}
if (hasSubgraphs) {
options.push(
{
if (!hasMultipleSelection) {
options.push({
label: t('contextMenu.Add Subgraph to Library'),
icon: 'icon-[lucide--folder-plus]',
action: addSubgraphToLibrary
},
{
label: t('contextMenu.Unpack Subgraph'),
icon: 'icon-[lucide--expand]',
action: unpackSubgraph
}
)
})
}
options.push({
label: t('contextMenu.Unpack Subgraph'),
icon: 'icon-[lucide--expand]',
action: unpackSubgraph
})
}
return options

View File

@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => ({
publishSubgraph: vi.fn(),
selectedItems: [] as unknown[]
}))
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
useSelectedLiteGraphItems: () => ({
getSelectedNodes: vi.fn(() => [])
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: vi.fn(),
get selectedItems() {
return mocks.selectedItems
},
updateSelectedItems: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
})
}))
vi.mock('@/stores/imagePreviewStore', () => ({
useNodeOutputStore: () => ({
revokeSubgraphPreviews: vi.fn()
})
}))
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: () => ({
publishSubgraph: mocks.publishSubgraph
})
}))
function createSubgraphNode(): SubgraphNode {
const node = Object.create(SubgraphNode.prototype)
return node
}
describe('useSubgraphOperations', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.selectedItems = []
})
it('addSubgraphToLibrary calls publishSubgraph when single SubgraphNode selected', async () => {
mocks.selectedItems = [createSubgraphNode()]
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { addSubgraphToLibrary } = useSubgraphOperations()
await addSubgraphToLibrary()
expect(mocks.publishSubgraph).toHaveBeenCalledOnce()
})
it('addSubgraphToLibrary does not call publishSubgraph when no items selected', async () => {
mocks.selectedItems = []
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { addSubgraphToLibrary } = useSubgraphOperations()
await addSubgraphToLibrary()
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
it('addSubgraphToLibrary does not call publishSubgraph when multiple items selected', async () => {
mocks.selectedItems = [createSubgraphNode(), createSubgraphNode()]
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { addSubgraphToLibrary } = useSubgraphOperations()
await addSubgraphToLibrary()
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
})

View File

@@ -3,9 +3,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { useSubgraphStore } from '@/stores/subgraphStore'
/**
* Composable for handling subgraph-related operations
@@ -15,8 +13,7 @@ export function useSubgraphOperations() {
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const nodeOutputStore = useNodeOutputStore()
const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const subgraphStore = useSubgraphStore()
const convertToSubgraph = () => {
const canvas = canvasStore.getCanvas()
@@ -73,48 +70,13 @@ export function useSubgraphOperations() {
const addSubgraphToLibrary = async () => {
const selectedItems = Array.from(canvasStore.selectedItems)
// Handle single node selection like BookmarkButton.vue
if (selectedItems.length === 1) {
const item = selectedItems[0]
if (isLGraphNode(item)) {
const nodeDef = nodeDefStore.fromLGraphNode(item)
if (nodeDef) {
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
return
}
}
}
// Handle multiple nodes - convert to subgraph first then bookmark
const selectedNodes = getSelectedNodes()
if (selectedNodes.length === 0) {
return
}
// Check if selection contains subgraph nodes
const hasSubgraphs = selectedNodes.some(
(node) => node instanceof SubgraphNode
const subgraphNodes = selectedItems.filter(
(item): item is SubgraphNode => item instanceof SubgraphNode
)
if (!hasSubgraphs) {
// Convert regular nodes to subgraph first
convertToSubgraph()
if (subgraphNodes.length !== 1) {
return
}
// For subgraph nodes, bookmark them
let bookmarkedCount = 0
for (const node of selectedNodes) {
if (node instanceof SubgraphNode) {
const nodeDef = nodeDefStore.fromLGraphNode(node)
if (nodeDef) {
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
bookmarkedCount++
}
}
}
await subgraphStore.publishSubgraph()
}
const isSubgraphSelected = (): boolean => {

View File

@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import type { TaskItemImpl } from '@/stores/queueStore'
import { isActiveJobState } from '@/utils/queueUtil'
export type JobAction = {
@@ -18,7 +19,7 @@ export function useJobActions(
) {
const { t } = useI18n()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { cancelJob } = useJobMenu()
const { cancelJob, removeFailedJob } = useJobMenu()
const cancelAction: JobAction = {
icon: 'icon-[lucide--x]',
@@ -26,6 +27,12 @@ export function useJobActions(
variant: 'destructive'
}
const deleteAction: JobAction = {
icon: 'icon-[lucide--circle-minus]',
label: t('queue.jobMenu.removeJob'),
variant: 'destructive'
}
const jobRef = computed(() => toValue(job) ?? null)
const canCancelJob = computed(() => {
@@ -37,6 +44,15 @@ export function useJobActions(
return currentJob.showClear !== false && isActiveJobState(currentJob.state)
})
const canDeleteJob = computed(() => {
const currentJob = jobRef.value
if (!currentJob) {
return false
}
return currentJob.state === 'failed'
})
const runCancelJob = wrapWithErrorHandlingAsync(async () => {
const currentJob = jobRef.value
if (!currentJob) {
@@ -46,9 +62,22 @@ export function useJobActions(
await cancelJob(currentJob)
})
const runDeleteJob = wrapWithErrorHandlingAsync(async () => {
const currentJob = jobRef.value
const task = currentJob?.taskRef as TaskItemImpl | undefined
if (!task) {
return
}
await removeFailedJob(task)
})
return {
cancelAction,
canCancelJob,
runCancelJob
runCancelJob,
deleteAction,
canDeleteJob,
runDeleteJob
}
}

View File

@@ -421,6 +421,52 @@ describe('useJobList', () => {
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['wf-1'])
})
it('filters jobs by search query', async () => {
vi.useFakeTimers()
queueStoreMock.historyTasks = [
createTask({
jobId: 'alpha',
queueIndex: 2,
mockState: 'completed',
createTime: 2000,
executionEndTimestamp: 2000
}),
createTask({
jobId: 'beta',
queueIndex: 1,
mockState: 'failed',
createTime: 1000,
executionEndTimestamp: 1000
})
]
const instance = initComposable()
await flush()
expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
'alpha',
'beta'
])
instance.searchQuery.value = 'beta'
await vi.advanceTimersByTimeAsync(200)
await flush()
expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
'beta'
])
instance.searchQuery.value = 'failed meta'
await vi.advanceTimersByTimeAsync(200)
await flush()
expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
'beta'
])
instance.searchQuery.value = 'does-not-exist'
await vi.advanceTimersByTimeAsync(200)
await flush()
expect(instance.filteredTasks.value).toEqual([])
})
it('hydrates job items with active progress and compute hours', async () => {
queueStoreMock.runningTasks = [
createTask({

View File

@@ -1,3 +1,4 @@
import { refDebounced } from '@vueuse/core'
import { orderBy } from 'es-toolkit/array'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -197,6 +198,8 @@ export function useJobList() {
const selectedJobTab = ref<JobTab>('All')
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
const selectedSortMode = ref<JobSortMode>('mostRecent')
const searchQuery = ref('')
const debouncedSearchQuery = refDebounced(searchQuery, 150)
const mostRecentTimestamp = (task: TaskItemImpl) => task.createTime ?? 0
@@ -248,8 +251,12 @@ export function useJobList() {
return entries
})
const normalizedSearchQuery = computed(() =>
debouncedSearchQuery.value.trim().toLocaleLowerCase()
)
const filteredTasks = computed<TaskItemImpl[]>(() =>
filteredTaskEntries.value.map(({ task }) => task)
searchableTaskEntries.value.map(({ task }) => task)
)
const jobItems = computed<JobListItem[]>(() => {
@@ -307,11 +314,31 @@ export function useJobList() {
return m
})
const searchableTaskEntries = computed<TaskWithState[]>(() => {
if (!normalizedSearchQuery.value) return filteredTaskEntries.value
return filteredTaskEntries.value.filter(({ task }) => {
const taskId = String(task.jobId ?? '').toLocaleLowerCase()
const item = jobItemById.value.get(String(task.jobId))
if (!item) {
return taskId.includes(normalizedSearchQuery.value)
}
const title = item.title.toLocaleLowerCase()
const meta = item.meta.toLocaleLowerCase()
return (
title.includes(normalizedSearchQuery.value) ||
meta.includes(normalizedSearchQuery.value) ||
taskId.includes(normalizedSearchQuery.value)
)
})
})
const groupedJobItems = computed<JobGroup[]>(() => {
const groups: JobGroup[] = []
const index = new Map<string, number>()
const localeValue = locale.value
for (const { task, state } of filteredTaskEntries.value) {
for (const { task, state } of searchableTaskEntries.value) {
let ts: number | undefined
if (state === 'completed' || state === 'failed') {
ts = task.executionEndTimestamp
@@ -356,6 +383,7 @@ export function useJobList() {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
searchQuery,
hasFailedJobs,
// data sources
allTasksSorted,

View File

@@ -217,10 +217,11 @@ export function useJobMenu(
}
}
const removeFailedJob = async () => {
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
if (!task) return
await queueStore.delete(task)
const removeFailedJob = async (task?: TaskItemImpl | null) => {
const target =
task ?? (currentMenuItem()?.taskRef as TaskItemImpl | undefined)
if (!target) return
await queueStore.delete(target)
}
const jobMenuOpenWorkflowLabel = computed(() =>
@@ -369,6 +370,7 @@ export function useJobMenu(
jobMenuEntries,
openJobWorkflow,
copyJobId,
cancelJob
cancelJob,
removeFailedJob
}
}

View File

@@ -0,0 +1,32 @@
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
import { useDialogStore } from '@/stores/dialogStore'
export const useQueueClearHistoryDialog = () => {
const dialogStore = useDialogStore()
const showQueueClearHistoryDialog = () => {
dialogStore.showDialog({
key: 'queue-clear-history',
component: QueueClearHistoryDialog,
dialogComponentProps: {
headless: true,
closable: false,
closeOnEscape: true,
dismissableMask: true,
pt: {
root: {
class: 'max-w-90 w-auto bg-transparent border-none shadow-none'
},
content: {
class: 'bg-transparent',
style: 'padding: 0'
}
}
}
})
}
return {
showQueueClearHistoryDialog
}
}

View File

@@ -0,0 +1,16 @@
import { markRaw } from 'vue'
import JobHistorySidebarTab from '@/components/sidebar/tabs/JobHistorySidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useJobHistorySidebarTab = (): SidebarTabExtension => {
return {
id: 'job-history',
icon: 'icon-[lucide--history]',
title: 'queue.jobHistory',
tooltip: 'queue.jobHistory',
label: 'queue.jobHistory',
component: markRaw(JobHistorySidebarTab),
type: 'vue'
}
}

View File

@@ -21,7 +21,11 @@ export enum ServerFeatureFlag {
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements'
NODE_REPLACEMENTS = 'node_replacements',
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
WORKFLOW_SHARING_ENABLED = 'workflow_sharing_enabled',
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled'
}
/**
@@ -100,6 +104,38 @@ export function useFeatureFlags() {
},
get nodeReplacementsEnabled() {
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
},
get nodeLibraryEssentialsEnabled() {
if (isNightly || import.meta.env.DEV) return true
return (
remoteConfig.value.node_library_essentials_enabled ??
api.getServerFeature(
ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED,
false
)
)
},
get workflowSharingEnabled() {
return (
remoteConfig.value.workflow_sharing_enabled ??
api.getServerFeature(ServerFeatureFlag.WORKFLOW_SHARING_ENABLED, false)
)
},
get comfyHubUploadEnabled() {
return (
remoteConfig.value.comfyhub_upload_enabled ??
api.getServerFeature(ServerFeatureFlag.COMFYHUB_UPLOAD_ENABLED, false)
)
},
get comfyHubProfileGateEnabled() {
return (
remoteConfig.value.comfyhub_profile_gate_enabled ??
api.getServerFeature(
ServerFeatureFlag.COMFYHUB_PROFILE_GATE_ENABLED,
false
)
)
}
})

View File

@@ -0,0 +1,73 @@
import { ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { usePerTabState } from './usePerTabState'
type TabId = 'a' | 'b' | 'c'
describe('usePerTabState', () => {
function setup(initialTab: TabId = 'a') {
const selectedTab = ref<TabId>(initialTab)
const stateByTab = ref<Record<TabId, string[]>>({
a: [],
b: [],
c: []
})
const state = usePerTabState(selectedTab, stateByTab)
return { selectedTab, stateByTab, state }
}
it('should return state for the current tab', () => {
const { selectedTab, stateByTab, state } = setup()
stateByTab.value.a = ['key1', 'key2']
stateByTab.value.b = ['key3']
expect(state.value).toEqual(['key1', 'key2'])
selectedTab.value = 'b'
expect(state.value).toEqual(['key3'])
})
it('should set state only for the current tab', () => {
const { stateByTab, state } = setup()
state.value = ['new-key1', 'new-key2']
expect(stateByTab.value.a).toEqual(['new-key1', 'new-key2'])
expect(stateByTab.value.b).toEqual([])
expect(stateByTab.value.c).toEqual([])
})
it('should preserve state when switching tabs', () => {
const { selectedTab, stateByTab, state } = setup()
state.value = ['a-key']
selectedTab.value = 'b'
state.value = ['b-key']
selectedTab.value = 'c'
state.value = ['c-key']
expect(stateByTab.value.a).toEqual(['a-key'])
expect(stateByTab.value.b).toEqual(['b-key'])
expect(stateByTab.value.c).toEqual(['c-key'])
selectedTab.value = 'a'
expect(state.value).toEqual(['a-key'])
})
it('should not share state between tabs', () => {
const { selectedTab, state } = setup()
state.value = ['only-a']
selectedTab.value = 'b'
expect(state.value).toEqual([])
selectedTab.value = 'c'
expect(state.value).toEqual([])
selectedTab.value = 'a'
expect(state.value).toEqual(['only-a'])
})
})

View File

@@ -0,0 +1,14 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
export function usePerTabState<K extends string, V>(
selectedTab: Ref<K>,
stateByTab: Ref<Record<K, V>>
) {
return computed({
get: () => stateByTab.value[selectedTab.value],
set: (value) => {
stateByTab.value[selectedTab.value] = value
}
})
}

View File

@@ -0,0 +1,41 @@
/**
* Toolkit (Essentials) node detection constants.
*
* Used by telemetry to track toolkit node adoption and popularity.
* Only novel nodes — basic nodes (LoadImage, SaveImage, etc.) are excluded.
*
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
*/
/**
* Canonical node type names for individual toolkit nodes.
*/
export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
// Image Tools
'ImageCrop',
'ImageRotate',
'ImageBlur',
'ImageInvert',
'ImageCompare',
'Canny',
// Video Tools
'Video Slice',
// API Nodes
'RecraftRemoveBackgroundNode',
'RecraftVectorizeImageNode',
'KlingOmniProEditVideoNode',
// Shader Nodes
'GLSLShader'
])
/**
* python_module values that identify toolkit blueprint nodes.
* Essentials blueprints are registered with node_pack 'comfy_essentials',
* which maps to python_module on the node def.
*/
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
'comfy_essentials'
])

View File

@@ -5,8 +5,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
@@ -125,6 +125,94 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.widgets[0].computedHeight = 10
expect(subgraphNode.widgets[0].value).toBe('value')
})
test('Proxy widget label shows widgetName, not "nodeId: widgetName"', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
expect(proxyWidget.label).toBe('seed')
expect(proxyWidget.name).toBe('1: seed')
})
test('Proxy widget label reflects linked widget label', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
expect(proxyWidget.label).toBe('seed')
innerNodes[0].widgets![0].label = 'My Inner Label'
// Trigger re-resolve of linked widget
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('My Inner Label')
})
test('Proxy widget user rename takes priority over linked widget label', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
proxyWidget.label = 'My Custom Seed'
expect(proxyWidget.label).toBe('My Custom Seed')
innerNodes[0].widgets![0].label = 'Inner Override'
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('My Custom Seed')
})
test('Proxy widget label resets to linked widget on undefined', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
proxyWidget.label = 'Custom'
expect(proxyWidget.label).toBe('Custom')
proxyWidget.label = undefined
innerNodes[0].widgets![0].label = 'Inner Label'
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('Inner Label')
})
test('Proxy widget labels are correct when loaded from serialized data', () => {
// Intentionally constructs SubgraphNode via constructor (not setupSubgraph)
// to exercise the deserialization/onConfigure path from blueprint JSON.
const subgraph = createTestSubgraph()
const innerNode = new LGraphNode('InnerNode')
subgraph.add(innerNode)
innerNode.addWidget('text', 'seed', 'value', () => {})
innerNode.addWidget('text', 'steps', 'value', () => {})
const parentGraph = new LGraph()
const subgraphNode = new SubgraphNode(parentGraph, subgraph, {
id: 1,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
properties: {
proxyWidgets: [
['1', 'seed'],
['1', 'steps']
]
},
flags: {},
mode: 0,
order: 0
})
expect(subgraphNode.widgets).toHaveLength(2)
expect(subgraphNode.widgets[0].label).toBe('seed')
expect(subgraphNode.widgets[0].name).toBe('1: seed')
expect(subgraphNode.widgets[1].label).toBe('steps')
expect(subgraphNode.widgets[1].name).toBe('1: steps')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})

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