Compare commits

..

90 Commits

Author SHA1 Message Date
jaeone94
8c3bb91f1e feat(storybook): [DRAFT] enhance error tab stories for design feedback
This commit adds 7 comprehensive Storybook stories for the error tab to
collect design feedback.
2026-02-20 01:30:29 +09:00
jaeone94
864b14b302 Merge branch 'main' into feat/node-specific-error-tab 2026-02-19 20:48:10 +09:00
Comfy Org PR Bot
7060133ff9 1.40.8 (#8968)
Patch version increment to 1.40.8

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8968-1-40-8-30c6d73d3650817d8a59e3bd9bdeb95c)
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-19 02:34:05 -08:00
Christian Byrne
cc2c10745b fix: use getAuthHeader in createCustomer for API key auth support (#8983)
## Summary

Re-apply the fix from PR #8408 that was accidentally reverted by PR
#8508 — `createCustomer` must use `getAuthHeader()` (not
`getFirebaseAuthHeader()`) so API key authentication works.

## Changes

- **What**: Changed `createCustomer` in `firebaseAuthStore.ts` to use
`getAuthHeader()` which falls back through workspace token → Firebase
token → API key. Added regression tests covering API key auth, Firebase
auth, and no-auth paths.

## Review Focus

This is the same one-line fix from #8408. PR #8508 ("Feat/workspaces 6
billing") overwrote it during merge because it was branched before #8408
landed. The regression test should prevent this from happening again.

Fixes COM-15060

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8983-fix-use-getAuthHeader-in-createCustomer-for-API-key-auth-support-30c6d73d365081c2aab6d5defa5298d6)
by [Unito](https://www.unito.io)
2026-02-18 20:47:12 -08:00
Christian Byrne
8ab9a7b887 feat(persistence): add workspace-scoped storage keys and types (#8517)
## Summary

Adds the foundational types and key generation utilities for
workspace-scoped workflow draft persistence. This enables storing drafts
per-workspace to prevent data leakage between different ComfyUI
instances.

[Screencast from 2026-02-08
18-17-45.webm](https://github.com/user-attachments/assets/f16226e9-c1db-469d-a0b7-aa6af725db53)

## Changes

- **What**: Type definitions for draft storage (`DraftIndexV2`,
`DraftPayloadV2`, session pointers) and key generation utilities with
workspace/client scoping
- **Why**: The current persistence system stores all drafts globally,
causing cross-workspace data leakage when users work with multiple
ComfyUI instances

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-18 19:31:24 -08:00
Christian Byrne
2dbd7e86c3 test: mark failing Vue Nodes Image Preview browser tests as fixme (#8980)
Mark the two browser tests added in #8143 as `test.fixme` — they are
failing on main.

- `opens mask editor from image preview button`
- `shows image context menu options`

- Fixes #8143 (browser test failures)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-19 02:46:55 +00:00
Alexander Brown
faede75bb4 fix: show skeleton loading state in asset folder view (#8979)
## Description

When clicking a multi-output job to enter folder view,
`resolveOutputAssetItems` fetches job details asynchronously. During
this fetch, the panel showed "No generated files found" because there
was no loading state for the folder resolution—only the media list fetch
had one.

This replaces the empty state flash with skeleton cards that match the
asset grid layout, using the known output count from metadata to render
the correct number of placeholders.

Supersedes #8960.

### Changes

- **Add shadcn/vue `Skeleton` component**
(`src/components/ui/skeleton/Skeleton.vue`)
- **Use `useAsyncState`** from VueUse to track folder asset resolution,
providing `isLoading` automatically
- **Wire `folderLoading`** into `showLoadingState` and `showEmptyState`
computeds
- **Replace `ProgressSpinner`** with a skeleton grid that mirrors the
asset card layout
- **Use `metadata.outputCount`** to predict skeleton count; falls back
to 6

### Before / After

| Before | After |
|--------|-------|
| "No generated files found" flash | Skeleton cards matching grid layout
|

## Checklist

- [x] Code follows project conventions
- [x] No `any` types introduced
- [x] Lint and typecheck pass

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8979-fix-show-skeleton-loading-state-in-asset-folder-view-30c6d73d365081fa9809f616204ed234)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-18 18:35:36 -08:00
Alexander Brown
8099cce232 feat: bulk asset export with ZIP download (#8712)
## Summary

Adds bulk asset export with ZIP download for cloud users. When selecting
2+ assets and clicking download, the frontend now requests a server-side
ZIP export instead of triggering individual file downloads.

## Changes

### New files
- **`AssetExportProgressDialog.vue`** — HoneyToast-based progress dialog
showing per-job export status with progress percentages, error
indicators, and a manual re-download button for completed exports
- **`assetExportStore.ts`** — Pinia store that tracks export jobs,
handles `asset_export` WebSocket events for real-time progress, polls
stale exports via the task API as a fallback, and auto-triggers ZIP
download on completion

### Modified files
- **`useMediaAssetActions.ts`** — `downloadMultipleAssets` now routes to
ZIP export (via `createAssetExport`) in cloud mode when 2+ assets are
selected; single assets and OSS mode still use direct download
- **`assetService.ts`** — Added `createAssetExport()` and
`getExportDownloadUrl()` endpoints
- **`apiSchema.ts`** — Added `AssetExportWsMessage` type for the
WebSocket event
- **`api.ts`** — Wired up `asset_export` WebSocket event
- **`GraphView.vue`** — Mounted `AssetExportProgressDialog`
- **`main.json`** — Added i18n keys for export toast UI

## How it works

1. User selects multiple assets and clicks download
2. Frontend calls `POST /assets/export` with asset/job IDs
3. Backend creates a ZIP task and streams progress via `asset_export`
WebSocket events
4. `AssetExportProgressDialog` shows real-time progress
5. On completion, the ZIP is auto-downloaded via a presigned URL from
`GET /assets/exports/{name}`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8712-feat-bulk-asset-export-with-ZIP-download-3006d73d365081839ec3dd3e7b0d3b77)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-18 16:36:59 -08:00
Christian Byrne
27d4a34435 fix: sync node.imgs for legacy context menu in Vue Nodes mode (#8143)
## Summary

Fixes missing "Copy Image", "Open Image", "Save Image", and "Open in
Mask Editor" context menu options on SaveImage nodes when Vue Nodes mode
is enabled.

## Changes

- Add `syncLegacyNodeImgs` store method to sync loaded image elements to
`node.imgs`
- Call sync on image load in ImagePreview component
- Simplify mask editor handling to call composable directly

## Technical Details

- Only runs when `vueNodesMode` is enabled (no impact on legacy mode)
- Reuses already-loaded `<img>` element from Vue (no duplicate network
requests)
- Store owns the sync logic, component just hands off the element

Supersedes #7416

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8143-fix-sync-node-imgs-for-legacy-context-menu-in-Vue-Nodes-mode-2ec6d73d365081c59d42cd1722779b61)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-18 16:34:45 -08:00
Terry Jia
e1e560403e feat: reuse WidgetInputNumberInput for BoundingBox numeric inputs (#8895)
## Summary
Make WidgetInputNumberInput usable without the widget system by making
the widget prop optional and adding simple min/max/step/disabled props.

BoundingBox now uses this component instead of a separate
ScrubableNumberInput

## Screenshots (if applicable)
<img width="828" height="1393" alt="image"
src="https://github.com/user-attachments/assets/68e012cf-baae-4a53-b4f8-70917cf05554"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8895-feat-add-scrub-drag-to-adjust-to-BoundingBox-numeric-inputs-3086d73d36508194b4b5e9bc823b34d1)
by [Unito](https://www.unito.io)
2026-02-18 16:32:03 -08:00
Johnpaul Chiwetelu
aff0ebad50 fix: reload template workflows when locale changes (#8963)
## Summary
- Templates were fetched once with the initial locale and cached behind
an `isLoaded` guard. Changing language updated i18n UI strings but never
re-fetched locale-specific template data (names, descriptions) from the
server.
- Extracts core template fetching into `fetchCoreTemplates()` and adds a
`watch` on `i18n.global.locale` to re-fetch when the language changes.

## Test plan
- [ ] Open the templates panel
- [ ] Change language in settings (e.g. English -> French)
- [ ] Verify template names and descriptions update without a page
refresh
- [ ] Verify initial load still works correctly

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8963-fix-reload-template-workflows-when-locale-changes-30b6d73d36508178a2f8c2c8947b5955)
by [Unito](https://www.unito.io)
2026-02-18 15:59:37 -08:00
Christian Byrne
44dc208339 fix: app mode gets stale assets history (#8918)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8918-app-mode-fix-stale-assets-history-3096d73d36508114b81df071d7289c23)
by [Unito](https://www.unito.io)
2026-02-18 15:29:00 -08:00
Christian Byrne
388c21a88d fix: lint-format CI failing on fork PRs due to missing secret (#8948)
## Summary

Fall back to `github.token` when `PR_GH_TOKEN` secret is unavailable on
fork PRs, fixing checkout failure.

## Changes

- **What**: The `ci-lint-format` workflow uses `secrets.PR_GH_TOKEN` for
checkout. Repository secrets are not available to workflows triggered by
fork PRs, causing `Input required and not supplied: token` errors (e.g.
[run
22124451916](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/22124451916)).
The fix uses `github.token` as a fallback for fork PRs — this is always
available with read-only access, which is sufficient since the workflow
already skips commit/push for forks.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8948-fix-lint-format-CI-failing-on-fork-PRs-due-to-missing-secret-30b6d73d365081dfb78cf05362a6653c)
by [Unito](https://www.unito.io)
2026-02-18 15:28:48 -08:00
Alexander Brown
b28f46d237 Regenerate images (#8959)
```



```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8959-Regenerate-image-30b6d73d3650811e9116cb7c6c9002cb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-18 11:28:47 -08:00
Johnpaul Chiwetelu
2900e5e52e fix: asset browser filters stick when navigating categories (#8945)
## Summary

File format and base model filters in the asset browser persisted when
navigating to categories that don't contain matching assets, showing
empty results with no way to clear the filter.

## Changes

- **What**: Apply the same scope-aware filtering pattern from the
template selector dialog. Selected filters that don't exist in the
current category become inactive (excluded from filtering) but are
preserved so they reactivate when navigating back. Uses writable
computeds in `AssetFilterBar` (matching
`WorkflowTemplateSelectorDialog`) and active filter intersection in
`useAssetBrowser` (matching `useTemplateFiltering`).

## Before



https://github.com/user-attachments/assets/5c61e844-7ea0-489c-9c44-e0864dc916bc





## After


https://github.com/user-attachments/assets/8372e174-107c-41e2-b8cf-b7ef59fe741b



## Review Focus

The pattern mirrors `selectedModelObjects`/`selectedUseCaseObjects` in
`WorkflowTemplateSelectorDialog.vue` and `activeModels`/`activeUseCases`
in `useTemplateFiltering.ts`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8945-fix-asset-browser-filters-stick-when-navigating-categories-30b6d73d365081609ac5c3982a1a03fc)
by [Unito](https://www.unito.io)
2026-02-18 12:55:05 +01:00
jaeone94
e02d58da0c fix: clear lastNodeErrors on execution start and guard node_id in allErrorExecutionIds
- Add lastNodeErrors.value = null in handleExecutionStart to prevent
  stale node errors from persisting into the next execution
- Guard against null/undefined node_id before pushing to allErrorExecutionIds
2026-02-18 20:30:36 +09:00
jaeone94
f48ae4619d fix: fix service-level error guard and null exception_type in message
- Replace isEmpty(node_id) with explicit presence check to prevent
  numeric node IDs from being misclassified as service-level errors
- Guard exception_type in message template to avoid 'null: ...' output
  when exception_type is absent
- Remove unused isEmpty import from es-toolkit/compat
2026-02-18 20:12:57 +09:00
Comfy Org PR Bot
07e64a7f44 1.40.7 (#8944)
Patch version increment to 1.40.7

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8944-1-40-7-30b6d73d365081679aa8cba674700980)
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-18 02:23:34 -08:00
jaeone94
cc151d3c22 fix: remove redundant cn() in ErrorOverlay and fix prompt error cleared by reset
- Replace :class='cn('...')' with static class and remove unused cn import
  in ErrorOverlay
- Fix prompt-only cloud validation errors being wiped by resetExecutionState
  by capturing the error before reset and applying it after
2026-02-18 19:13:03 +09:00
jaeone94
c54b470ca8 chore: remove unused useRightSidePanelStore import from app.ts 2026-02-18 18:43:02 +09:00
jaeone94
33f136b38b fix: use allErrorGroups in expandFocusedErrorGroup to restore See Error navigation & format 2026-02-18 18:31:54 +09:00
jaeone94
4fbf89ae4c refactor: consolidate error logic and split errorGroups by scope
- Add allErrorExecutionIds computed to executionStore to centralize
  error ID collection from lastNodeErrors and lastExecutionError
- Add hasInternalErrorForNode helper to executionStore to
  encapsulate prefix-based container error detection
- Replace duplicated error ID collection and prefix checks in
  RightSidePanel and SectionWidgets with store computed/helper
- Split errorGroups into allErrorGroups (unfiltered) and
  tabErrorGroups (selection-filtered) in useErrorGroups
- Add filterBySelection param (default: false) to
  addNodeErrorToGroup, processNodeErrors, processExecutionError
- Add groupedErrorMessages computed derived from allErrorGroups
  for deduped message list used by ErrorOverlay
- Migrate ErrorOverlay business logic to useErrorGroups composable,
  removing inline groupedErrors computed and redundant totalErrorCount wrapper
2026-02-18 18:15:14 +09:00
jaeone94
aa1c25f98e feat: node-specific error tab, selection-aware grouping, and error overlay
- Remove TabError.vue; consolidate all error display into TabErrors.vue and
  remove the separate 'error' tab type from rightSidePanelStore
- Single-node selection mode: regroup errors by message instead of class_type
  and render ErrorNodeCard in compact mode (hiding redundant header/message)
- Container node support: detect internal errors in subgraph/group nodes by
  matching execution ID prefixes against selected container node IDs
- SectionWidgets: show error badge and 'See Error' button for subgraph/group
  nodes that contain child-node errors, navigating directly to the errors tab
- Add ErrorOverlay component: floating card after execution failure showing a
  deduplicated error summary with 'Dismiss' and 'See Errors' actions;
  'See Errors' deselects all nodes and opens Errors tab in overview mode
- Add isErrorOverlayOpen, showErrorOverlay, dismissErrorOverlay to
  executionStore; reset overlay state on execution_start
2026-02-18 17:09:04 +09:00
Benjamin Lu
34e21f3267 fix(queue): address follow-up review comments from #8740 (#8880)
## Summary
Address follow-up review feedback from #8740 by consolidating
completed-banner thumbnail fields and tightening queue count
sanitization.

## Changes
- **What**:
- Consolidated completed notification thumbnail data to `thumbnailUrls`
only by removing legacy `thumbnailUrl` support from the queue banner
notification type and renderer.
- Updated queue notification banner stories to use `thumbnailUrls`
consistently.
- Simplified `sanitizeCount` to treat any non-positive or non-number
value as the fallback count (`1`).

## Review Focus
Confirm the completed notification payload shape is now consistently
`thumbnailUrls` and no consumer relies on `thumbnailUrl`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8880-fix-queue-address-follow-up-review-comments-from-8740-3076d73d365081719a70d860691c5f05)
by [Unito](https://www.unito.io)
2026-02-17 21:26:02 -08:00
jaeone94
1349fffbce Feat/errors tab panel (#8807)
## Summary

Add a dedicated **Errors tab** to the Right Side Panel that displays
prompt-level, node validation, and runtime execution errors in a
unified, searchable, grouped view — replacing the need to rely solely on
modal dialogs for error inspection.

## Changes

- **What**:
  - **New components** (`errors/` directory):
- `TabErrors.vue` — Main error tab with search, grouping by class type,
and canvas navigation (locate node / enter subgraph).
- `ErrorNodeCard.vue` — Renders a single error card with node ID badge,
title, action buttons, and error details.
- `types.ts` — Shared type definitions (`ErrorItem`, `ErrorCardData`,
`ErrorGroup`).
- **`executionStore.ts`** — Added `PromptError` interface,
`lastPromptError` ref and `hasAnyError` computed getter. Clears
`lastPromptError` alongside existing error state on execution start and
graph clear.
- **`rightSidePanelStore.ts`** — Registered `'errors'` as a valid tab
value.
- **`app.ts`** — On prompt submission failure (`PromptExecutionError`),
stores prompt-level errors (when no node errors exist) into
`lastPromptError`. On both runtime execution error and prompt error,
deselects all nodes and opens the errors tab automatically.
- **`RightSidePanel.vue`** — Shows the `'errors'` tab (with ⚠ icon) when
errors exist and no node is selected. Routes to `TabErrors` component.
- **`TopMenuSection.vue`** — Highlights the action bar with a red border
when any error exists, using `hasAnyError`.
- **`SectionWidgets.vue`** — Detects per-node errors by matching
execution IDs to graph node IDs. Shows an error icon (⚠) and "See Error"
button that navigates to the errors tab.
- **`en/main.json`** — Added i18n keys: `errors`, `noErrors`,
`enterSubgraph`, `seeError`, `promptErrors.*`, and `errorHelp*`.
- **Testing**: 6 unit tests (`TabErrors.test.ts`) covering
prompt/node/runtime errors, search filtering, and clipboard copy.
- **Storybook**: 7 stories (`ErrorNodeCard.stories.ts`) for badge
visibility, subgraph buttons, multiple errors, runtime tracebacks, and
prompt-only errors.
- **Breaking**: None
- **Dependencies**: None — uses only existing project dependencies
(`vue-i18n`, `pinia`, `primevue`)

## Related Work

> **Note**: Upstream PR #8603 (`New bottom button and badges`)
introduced a separate `TabError.vue` (singular) that shows per-node
errors when a specific node is selected. Our `TabErrors.vue` (plural)
provides the **global error overview** — a different scope. The two tabs
coexist:
> - `'error'` (singular) → appears when a node with errors is selected →
shows only that node's errors
> - `'errors'` (plural) → appears when no node is selected and errors
exist → shows all errors grouped by class type
>
> A future consolidation of these two tabs may be desirable after design
review.

## Architecture

```
executionStore
├── lastPromptError: PromptError | null     ← NEW (prompt-level errors without node IDs)
├── lastNodeErrors: Record<string, NodeError>  (existing)
├── lastExecutionError: ExecutionError         (existing)
└── hasAnyError: ComputedRef<boolean>       ← NEW (centralized error detection)

TabErrors.vue (errors tab - global view)
├── errorGroups: ComputedRef<ErrorGroup[]>  ← normalizes all 3 error sources
├── filteredGroups                          ← search-filtered view
├── locateNode()                            ← pan canvas to node
├── enterSubgraph()                         ← navigate into subgraph
└── ErrorNodeCard.vue                       ← per-node card with copy/locate actions

types.ts
├── ErrorItem      { message, details?, isRuntimeError? }
├── ErrorCardData  { id, title, nodeId?, errors[] }
└── ErrorGroup     { title, cards[], priority }
```

## Review Focus

1. **Error normalization logic** (`TabErrors.vue` L75–150): Three
different error sources (prompt, node validation, runtime) are
normalized into a common `ErrorGroup → ErrorCardData → ErrorItem`
hierarchy. Edge cases to verify:
- Prompt errors with known vs unknown types (known types use localized
descriptions)
   - Multiple errors on the same node (grouped into one card)
   - Runtime errors with long tracebacks (capped height with scroll)

2. **Canvas navigation** (`TabErrors.vue` L210–250): The `locateNode`
and `enterSubgraph` functions navigate to potentially nested subgraphs.
The double `requestAnimationFrame` is required due to LiteGraph's
asynchronous subgraph switching — worth verifying this timing is
sufficient.

3. **Store getter consolidation**: `hasAnyError` replaces duplicated
logic in `TopMenuSection` and `RightSidePanel`. Confirm that the
reactive dependency chain works correctly (it depends on 3 separate
refs).

4. **Coexistence with upstream `TabError.vue`**: The singular `'error'`
tab (upstream, PR #8603) and our plural `'errors'` tab serve different
purposes but share similar naming. Consider whether a unified approach
is preferred.

## Test Results

```
✓ renders "no errors" state when store is empty
✓ renders prompt-level errors (Group title = error message)
✓ renders node validation errors grouped by class_type
✓ renders runtime execution errors from WebSocket
✓ filters errors based on search query
✓ calls copyToClipboard when copy button is clicked

Test Files  1 passed (1)
     Tests  6 passed (6)
```

## Screenshots (if applicable)
<img width="1238" height="1914" alt="image"
src="https://github.com/user-attachments/assets/ec39b872-cca1-4076-8795-8bc7c05dc665"
/>
<img width="669" height="1028" alt="image"
src="https://github.com/user-attachments/assets/bdcaa82a-34b0-46a5-a08f-14950c5a479b"
/>
<img width="644" height="1005" alt="image"
src="https://github.com/user-attachments/assets/ffef38c6-8f42-4c01-a0de-11709d54b638"
/>
<img width="672" height="505" alt="image"
src="https://github.com/user-attachments/assets/5cff7f57-8d79-4808-a71e-9ad05bab6e17"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8807-Feat-errors-tab-panel-3046d73d36508127981ac670a70da467)
by [Unito](https://www.unito.io)
2026-02-17 21:01:15 -08:00
Terry Jia
cde872fcf7 fix: eliminate visual shaking when adjusting crop region (#8896)
## Summary
Replace the dual-image approach (dimmed <img> + background-image in crop
box) with a single <img> and a box-shadow overlay on the crop box. The
previous approach required two independently positioned images to be
pixel-perfectly aligned, which caused visible jitter from sub-pixel
rounding differences, especially when zoomed in.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8896-fix-eliminate-visual-shaking-when-adjusting-crop-region-3086d73d365081309703d3ab0d4ad44d)
by [Unito](https://www.unito.io)
2026-02-17 16:56:46 -08:00
Terry Jia
596df0f0c6 fix: resolve ImageCrop input image through subgraph nodes (#8899)
## Summary
When ImageCrop's input comes from a subgraph node, getInputNode returns
the subgraph node itself which has no image outputs.
Use resolveSubgraphOutputLink to trace through to the actual source node
(e.g. LoadImage) inside the subgraph.

Use canvas.graph (the currently active graph/subgraph) as the primary
lookup, falling back to rootGraph. When the ImageCrop node is inside a
subgraph, rootGraph cannot find it since it only contains root-level
nodes.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/c3995f7c-6bcd-41fe-bc41-cfd87f9be94a


after


https://github.com/user-attachments/assets/ac660f58-6e6a-46ad-a441-84c7b88d28e2

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8899-fix-resolve-ImageCrop-input-image-through-subgraph-nodes-3086d73d36508172a759d7747190591f)
by [Unito](https://www.unito.io)
2026-02-17 16:56:24 -08:00
Johnpaul Chiwetelu
d3c0e331eb fix: detect video output from data in Nodes 2.0 (#8943)
## Summary

- Fixes SaveWebM node showing "Error loading image" in Vue nodes mode
- Extracts `isAnimatedOutput`/`isVideoOutput` utility functions from
inline logic in `unsafeUpdatePreviews` so both the litegraph canvas
renderer and Vue nodes renderer can detect video output directly from
execution data
- Uses output-based detection in `imagePreviewStore.isImageOutputs` to
avoid applying image preview format conversion to video files

## Background

In Vue nodes mode, `nodeMedia` relied on `node.previewMediaType` to
determine if output is video. This property is only set via
`onDrawBackground` → `unsafeUpdatePreviews` in the litegraph canvas
path, which doesn't run in Vue nodes mode. This caused webm output to
render via `<img>` instead of `<video>`.

## Before


https://github.com/user-attachments/assets/36f8a033-0021-4351-8f82-d19e3faa80c2


## After


https://github.com/user-attachments/assets/6558d261-d70e-4968-9637-6c24532e23ac
## Test plan

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] `pnpm test:unit` passes (4500 tests)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8943-fix-detect-video-output-from-data-in-Vue-nodes-mode-30a6d73d365081e98e91d6d1dcc88785)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-17 16:17:23 -08:00
Dante
b47414a52f fix: prevent duplicate node search filters (#8935)
## Summary

- Add duplicate check in `addFilter` to prevent identical filter chips
(same `filterDef.id` and `value`) from being added to the node search
box

## Related Issue

- Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/3559

## Changes

- `NodeSearchBoxPopover.vue`: Guard `addFilter` with `isDuplicate` check
comparing `filterDef.id` and `value`
- `NodeSearchBoxPopover.test.ts`: Add unit tests covering duplicate
prevention, distinct id, and distinct value cases

## QA

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] `pnpm format:check` passes
- [x] Unit tests pass (4/4)
- [x] Bug reproduced with Playwright before fix

### as-is
<img width="719" height="269" alt="스크린샷 2026-02-17 오후 5 45 48"
src="https://github.com/user-attachments/assets/403bf53a-53dd-4257-945f-322717f304b3"
/>

### to-be
<img width="765" height="291" alt="스크린샷 2026-02-17 오후 5 44 25"
src="https://github.com/user-attachments/assets/7995b15e-d071-4955-b054-5e0ca7c5c5bf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8935-fix-prevent-duplicate-node-search-filters-30a6d73d3650816797cfcc524228f270)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:53:08 -08:00
Simula_r
631d484901 refactor: workspaces DDD (#8921)
## Summary

Refactor: workspaces related functionality into DDD structure.

Note: this is the 1st PR of 2 more refactoring.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8921-refactor-DDD-3096d73d3650812bb7f6eb955f042663)
by [Unito](https://www.unito.io)
2026-02-17 12:28:47 -08:00
Christian Byrne
e83e396c09 feat: gate node replacement loading on server feature flag (#8750)
## Summary

Gates the node replacement store's `load()` call behind the
`node_replacements` server feature flag, so the frontend only calls
`/api/node_replacements` when the backend advertises support.

## Changes

- Added `NODE_REPLACEMENTS = 'node_replacements'` to `ServerFeatureFlag`
enum
- Added `nodeReplacementsEnabled` getter to `useFeatureFlags()`
- Added `api.serverSupportsFeature('node_replacements')` guard in
`useNodeReplacementStore.load()`

## Context

Without this guard, the frontend would attempt to fetch node
replacements from backends that don't support the endpoint, causing 404
errors.

Companion backend PR: https://github.com/Comfy-Org/ComfyUI/pull/12362

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8750-feat-gate-node-replacement-loading-on-server-feature-flag-3026d73d365081ec9246d77ad88f5bdc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-17 11:39:07 -08:00
Benjamin Lu
821c1e74ff fix: use gtag get for checkout attribution (#8930)
## Summary

Replace checkout attribution GA identity sourcing from
`window.__ga_identity__` with GA4 `gtag('get', ...)` calls keyed by
remote config measurement ID.

## Changes

- **What**:
  - Add typed global `gtag` get definitions and shared GA field types.
- Fetch `client_id`, `session_id`, and `session_number` via `gtag('get',
measurementId, field, callback)` with timeout-based fallback.
- Normalize numeric GA values to strings before emitting checkout
attribution metadata.
- Update checkout attribution tests to mock `gtag` retrieval and verify
requested fields + numeric normalization.
  - Add `ga_measurement_id` to remote config typings.

## Review Focus

Validate the `gtag('get', ...)` retrieval path and failure handling
(`undefined` fallback on timeout/errors) and confirm analytics field
names match GA4 expectations.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8930-fix-use-gtag-get-for-checkout-attribution-30a6d73d365081dcb773da945daceee6)
by [Unito](https://www.unito.io)
2026-02-17 02:43:34 -08:00
Comfy Org PR Bot
d06cc0819a 1.40.6 (#8927)
Patch version increment to 1.40.6

**Base branch:** `main`

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

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-02-17 02:22:09 -08:00
pythongosssss
f5f5a77435 Add support for dragging in multiple workflow files at once (#8757)
## Summary

Allows users to drag in multiple files that are/have embedded workflows
and loads each of them as tabs.
Previously it would only load the first one.

## Changes

- **What**: 
- process all files from drop event
- add defered errors so you don't get errors for non-visible workflows

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8757-Add-support-for-dragging-in-multiple-workflow-files-at-once-3026d73d365081c096e9dfb18ba01253)
by [Unito](https://www.unito.io)
2026-02-16 23:45:22 -08:00
Jin Yi
efe78b799f [feat] Node replacement UI (#8604)
## Summary
Add node replacement UI to the missing nodes dialog. Users can select
and replace deprecated/missing nodes with compatible alternatives
directly from the dialog.

## Changes
- Classify missing nodes into **Replaceable** (quick fix) and **Install
Required** sections
- Add select-all checkbox + per-node checkboxes for batch replacement
- `useNodeReplacement` composable handles in-place node replacement on
the graph:
  - Simple replacement (configure+copy) for nodes without mapping
  - Input/output connection remapping for nodes with mapping
  - Widget value transfer via `old_widget_ids`
  - Dot-notation input handling for Autogrow/DynamicCombo
  - Undo/redo support via `changeTracker` (try/finally)
  - Title and properties preservation
- Footer UX: "Skip for Now" button when all nodes are replaceable (cloud
+ OSS)
- Auto-close dialog when all replaceable nodes are replaced and no
non-replaceable remain
- Settings navigation link from "Don't show again" checkbox
- 505-line unit test suite for `useNodeReplacement`

## Review Focus
- `useNodeReplacement.ts` — core graph manipulation logic
- `MissingNodesContent.vue` — checkbox selection state management
- `MissingNodesFooter.vue` — conditional button rendering (cloud vs OSS
vs all-replaceable)


[screen-capture.webm](https://github.com/user-attachments/assets/7dae891c-926c-4f26-987f-9637c4a2ca16)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8604-feat-Node-replacement-UI-2fd6d73d36508148a371dabb8f4115af)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-16 23:33:41 -08:00
Benjamin Lu
e70484d596 fix: move queue assets action into filter controls (#8926)
## Summary

Move the queue overlay "Show assets" action into the filter controls as
an icon button, so the action is available inline with other list
controls while keeping existing behavior.

## Changes

- **What**:
- Remove the full-width "Show assets" button from
`QueueOverlayExpanded`.
- Add a secondary icon button in `JobFiltersBar` with tooltip +
aria-label and emit `showAssets` on click.
- Wire `showAssets` from `JobFiltersBar` through `QueueOverlayExpanded`
to the existing handler.
- Add `JobFiltersBar` unit coverage to verify `showAssets` is emitted
when the icon button is clicked.

## Review Focus

- Verify the icon button placement in the filter row is sensible and
discoverable.
- Verify clicking the new button opens the assets panel as before.
- Verify tooltip and accessibility label copy are correct.

## Screenshots (if applicable)
Design:
https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3924-38560&m=dev
<img width="349" height="52" alt="Screenshot 2026-02-16 at 4 53 34 PM"
src="https://github.com/user-attachments/assets/347772d6-5536-457a-a65f-de251e35a0e4"
/>
2026-02-16 18:19:16 -08:00
Benjamin Lu
3dba245dd3 fix: move clear queued controls into queue header (#8920)
## Summary
- Move queued-count summary and clear-queued action into the Queue Overlay header so controls remain visible while expanded content scrolls.

## What changed
- `QueueOverlayExpanded.vue`
  - Passes `queuedCount` and `clearQueued` through to the header.
  - Removes duplicated summary/action content from the lower section.
- `QueueOverlayHeader.vue`
  - Accepts new header data/actions for queued count and clear behavior.
  - Renders queued summary and clear button beside the title.
  - Adjusts layout to support persistent header actions.
- Updated header unit tests to cover queued summary rendering and clear action behavior.

## Testing
- Header unit tests were updated for the new behavior.
- No additional test execution was requested.

## Notes
- UI composition change only; queue execution semantics are unchanged.

Design: https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3924-38560&m=dev

<img width="356" height="59" alt="Screenshot 2026-02-16 at 3 30 44 PM" src="https://github.com/user-attachments/assets/987e42bd-9e24-4e65-9158-3f96b5338199" />
2026-02-16 18:03:52 -08:00
Benjamin Lu
2ca0c30cf7 fix: localize queue overlay running and queued summary (#8919)
## Summary
- Localize QueueProgressOverlay header counts with dedicated i18n pluralization keys for running and queued jobs.
- Replace the previous aggregate active-job wording with a translated running/queued summary.

## What changed
- Updated `QueueProgressOverlay.vue` to derive `runningJobsLabel`, `queuedJobsLabel`, and `runningQueuedSummary` via `useI18n`.
- Added `QueueProgressOverlay.test.ts` coverage for expanded-header text in both active and empty queue states.
- Added new English locale keys in `src/locales/en/main.json`:
  - `runningJobsLabel`
  - `queuedJobsLabel`
  - `runningQueuedSummary`

## Testing
- `Storybook Build Status` passed.
- `Playwright Tests` were still running at the last check; merge should wait for completion.

## Notes
- Behavioral scope is limited to queue overlay header text/rendering.

Design: https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3924-38560&m=dev

<img width="356" height="59" alt="Screenshot 2026-02-16 at 3 30 44 PM" src="https://github.com/user-attachments/assets/987e42bd-9e24-4e65-9158-3f96b5338199" />
2026-02-16 17:48:47 -08:00
Benjamin Lu
c8ba5f7300 fix: add pulsing active-jobs indicator on queue button (#8915)
## Summary

Add a small pulsing blue indicator dot to the top-right of the `N
active` queue button when there are active jobs.

## Changes

- **What**: Reused `StatusBadge` (`variant="dot"`) in `TopMenuSection`
as a top-right indicator on the queue toggle button, shown only when
`activeJobsCount > 0` and animated with `animate-pulse`.
- **What**: Added tests to verify the indicator appears for nonzero
active jobs and is hidden when there are no active jobs.

## Review Focus

- Dot positioning on the queue button (`-top-0.5 -right-0.5`) across top
menu layouts.
- Indicator visibility behavior tied to `activeJobsCount > 0`.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/9bdb7675-3e58-485b-abdd-446a76b2dafc

won't be shown on 0 active, I was just testing locally

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8915-fix-add-pulsing-active-jobs-indicator-on-queue-button-3096d73d36508181abf5c27662e0d9ae)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-17 01:32:33 +00:00
AustinMroz
39cc8ab97a A heavy-handed fix for middlemouse pan (#8865)
Sometimes, middle mouse clicks would fail to initiate a canvas pan,
depending on the target of the initial pan. This PR adds a capturing
event handler to the transform pane that forwards the pointer event to
canvas if
- It is a middle mouse click
- The target element is not a focused text element

Resolves #6911

While testing this, I encountered infrequent cases of "some nodes
unintentionally translating continually to the left". Reproduction was
too unreliable to properly track down, but did appear unrelated to this
PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8865-A-heavy-handed-fix-for-middlemouse-pan-3076d73d365081ea9a4ddd5786fc647a)
by [Unito](https://www.unito.io)
2026-02-16 15:40:36 -08:00
Alexander Brown
2ee0a1337c fix: prevent XSS vulnerability in context menu labels (#8887)
Replace innerHTML with textContent when setting context menu item labels
to prevent XSS attacks via malicious filenames. This fixes a security
vulnerability where filenames like "<img src=x onerror=alert()>" could
execute arbitrary JavaScript when displayed in dropdowns.

https://claude.ai/code/session_01LALt1HEgGvpWD7hhqcp2Gu

## Summary

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

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

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

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8887-fix-prevent-XSS-vulnerability-in-context-menu-labels-3086d73d365081ccbe3cdb35cd7e5cb1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-16 15:31:00 -08:00
Christian Byrne
980f280b3c fix: align in-app pricing copy with comfy.org/cloud/pricing (#8725)
## Summary

Align stale in-app pricing strings and links with the current
comfy.org/cloud/pricing page.

## Changes

- **What**: Update video estimate numbers (standard 120→380, creator
211→670, pro 600→1915), fix template URL (`video_wan2_2_14B_fun_camera`
→ `video_wan2_2_14B_i2v`), fix `templateNote` to reference Wan 2.2
Image-to-Video, align `videoEstimateExplanation` wording order with
website, remove stale "$10" from `benefit1` string.

## Review Focus

Copy-only changes across 4 files — no logic or UI changes. Source of
truth: https://www.comfy.org/cloud/pricing

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8725-fix-align-in-app-pricing-copy-with-comfy-org-cloud-pricing-3006d73d3650811faf11c248e6bf27c3)
by [Unito](https://www.unito.io)
2026-02-16 11:06:20 -08:00
Comfy Org PR Bot
4856fb0802 1.40.5 (#8905)
Patch version increment to 1.40.5

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8905-1-40-5-3096d73d365081c2aecccb7ae55def79)
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: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-02-16 10:06:15 -08:00
Christian Byrne
82ace36982 fix: show user-friendly message for network errors (#8748)
## Summary

Replaces cryptic "Failed to fetch" error messages with user-friendly
"Disconnected from backend" messages.

## Changes

- **What**: Detect network fetch errors in the central toast error
handler and display a helpful message with suggested action ("Check if
the server is running")

## Review Focus

The fix is intentionally minimal—only the central `toastErrorHandler` is
modified since all user-facing API errors flow through it.

Fixes COM-1839

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8748-fix-show-user-friendly-message-for-network-errors-3016d73d365081b3869bf19550c9af93)
by [Unito](https://www.unito.io)
2026-02-16 03:01:17 -08:00
Terry Jia
3d88d0a6ab fix: resolve errors when converting ImageCrop node to subgraph (#8898)
## Summary

- Pass callback directly to addWidget instead of null to eliminate
'addWidget without a callback or property assigned' warning
- Serialize object widget values as plain objects in
LGraphNode.serialize to prevent DataCloneError when structuredClone
encounters Vue reactive proxies

## Screenshots (if applicable)
before

https://github.com/user-attachments/assets/af20bd84-3e0e-4eca-b095-eaf4d5bb6884

after


https://github.com/user-attachments/assets/5a17772e-04bc-4f3e-abec-78c540e0efa3

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8898-fix-resolve-errors-when-converting-ImageCrop-node-to-subgraph-3086d73d365081b2ae34db31225683ad)
by [Unito](https://www.unito.io)
2026-02-16 05:46:46 -05:00
Comfy Org PR Bot
21cfd44a2d 1.40.4 (#8885)
Patch version increment to 1.40.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8885-1-40-4-3086d73d3650814d81c4fd16ab570538)
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-15 04:15:03 -08:00
Christian Byrne
d8d0dcbf71 feat: add reset-to-default for widget parameters in right side panel (#8861)
## Summary

Add per-widget and reset-all-parameters functionality to the right side
panel, allowing users to quickly revert widget values to their defaults.

## Changes

- **What**: Per-widget "Reset to default" option in the WidgetActions
overflow menu, plus a "Reset all parameters" button in each
SectionWidgets header. Defaults are derived from the InputSpec (explicit
default, then type-specific fallbacks: 0 for INT/FLOAT, false for
BOOLEAN, empty string for STRING, first option for COMBO).
- **Dependencies**: Builds on #8594 (WidgetValueStore) for reactive UI
updates after reset.

## Review Focus

- `getWidgetDefaultValue` fallback logic in `src/utils/widgetUtil.ts` —
are the type-specific defaults appropriate?
- Deep equality check (`isEqual`) for disabling the reset button when
the value already matches the default.
- Event flow: WidgetActions emits `resetToDefault` → WidgetItem forwards
→ SectionWidgets handles via `writeWidgetValue` (sets value, triggers
callback, marks canvas dirty).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8861-feat-add-reset-to-default-for-widget-parameters-in-right-side-panel-3076d73d365081d1aa08d5b965a16cf4)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-02-15 00:55:04 -08:00
Christian Byrne
066a1f1f11 fix: drag-and-drop screenshot creates LoadImage node instead of showing error (#8886)
## Summary

Fix drag-and-drop of local screenshots onto the canvas failing to create
a LoadImage node.

## Changes

- **What**: Replace `_.isEmpty(workflowData)` check in `handleFile` with
a check for workflow-relevant keys (`workflow`, `prompt`, `parameters`,
`templates`). PNG screenshots often contain non-workflow `tEXt` metadata
(e.g. `Software`, `Creation Time`) which made `_.isEmpty()` return
`false`, skipping the LoadImage fallback and showing an error instead.

## Review Focus

The root cause is that `getPngMetadata` extracts all `tEXt`/`iTXt` PNG
chunks indiscriminately. Rather than filtering at the parser level
(which could break extensions relying on arbitrary metadata), the fix
checks for workflow-relevant keys before deciding whether to treat the
file as a workflow or a plain image.

Fixes #7752

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8886-fix-drag-and-drop-screenshot-creates-LoadImage-node-instead-of-showing-error-3086d73d3650817d86c5f1386aa041c2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-14 22:25:59 -08:00
Jin Yi
2b896a722b [bugfix] Restore scroll-to-setting in SettingDialog (#8833)
## Summary
Restores the scroll-to-setting and highlight animation that was lost
during the BaseModalLayout migration in #8270. Originally implemented in
#8761.

## Changes
- **What**: Re-added scroll-into-view + pulse highlight logic and CSS
animation to `SettingDialog.vue`, ported from the deleted
`SettingDialogContent.vue`

Fixes #3437

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8833-bugfix-Restore-scroll-to-setting-in-SettingDialog-3056d73d36508161abeee047a40dc1e5)
by [Unito](https://www.unito.io)
2026-02-15 03:39:31 +00:00
Jin Yi
96b9e886ea feat: classify missing nodes by replacement availability (#8483)
## Summary
- Extend `MissingNodeType` with `isReplaceable` and `replacement` fields
- Classify missing nodes by checking
`nodeReplacementStore.getReplacementFor()` during graph load
- Wrap hardcoded node patches (T2IAdapterLoader, ConditioningAverage,
etc.) in `if (!isEnabled)` guard so they only run when node replacement
setting is disabled
- Change `useNodeReplacementStore().load()` from fire-and-forget
(`void`) to `await` so replacement data is available before missing node
detection
- Fix guard condition order in `nodeReplacementStore.load()`: check
`isEnabled` before `isLoaded`
- Align `InputMap` types with actual API response (flat
`old_id`/`set_value` fields instead of nested `assign` wrapper)

## Test plan
- [x] Load workflow with deprecated nodes (T2IAdapterLoader,
Load3DAnimation, SDV_img2vid_Conditioning)
- [x] Verify missing nodes are classified with `isReplaceable: true` and
`replacement` object
- [x] Verify hardcoded patches only run when node replacement setting is
OFF
- [x] Verify `nodeReplacementStore.load()` is not called when setting is
disabled
- [x] Unit tests pass (`nodeReplacementStore.test.ts` - 16 tests)
- [x] Typecheck passes

🤖 Generated with [Claude Code](https://claude.ai/code)
2026-02-15 11:26:46 +09:00
Yourz
58182ddda7 fix: skip loading drafts when Comfy.Workflow.Persist is disabled (#8851)
## Summary

Skip draft loading and clear stored drafts when `Comfy.Workflow.Persist`
is disabled, preventing unsaved changes from reappearing.

## Changes

- **What**: Guard draft loading in `ComfyWorkflow.load()` with
`Comfy.Workflow.Persist` setting check. Clear all localStorage drafts
when Persist is toggled from true to false.

## Review Focus

`ComfyWorkflow.load()` previously read drafts unconditionally regardless
of the Persist setting. This meant that after disabling Persist,
previously stored drafts would still be applied when opening a saved
workflow. The fix adds a guard in two places:
1. `comfyWorkflow.ts`: `load()` now checks `Comfy.Workflow.Persist`
before calling `getDraft()`
2. `useWorkflowPersistence.ts`: A `watch` on the Persist setting calls
`draftStore.reset()` when disabled

Fixes Comfy-Org/ComfyUI#12323

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8851-fix-skip-loading-drafts-when-Comfy-Workflow-Persist-is-disabled-3066d73d36508119ac2ce13564e18c01)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-02-15 10:01:54 +08:00
Terry Jia
0f0029ca29 fix: hide output images for ImageCropV2 node (#8873)
## Summary
using the new hideOutputImages flag for image crop.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8873-fix-hide-output-images-for-ImageCropV2-node-3076d73d365081079839dfc9b801606c)
by [Unito](https://www.unito.io)
2026-02-14 17:10:51 -05:00
AustinMroz
ba7f622fbd Fix primitive assets (#8879)
#8598 made primitve widgets connected to an asset have the asset type,
but the `nodeType` parameter required to actually resolve valid models
wasn't getting passed correctly.

This `nodeType`, introduced by me back in #7576, was a mistake. I'm
pulling it out now and instead passing nodeType as an option. Of note:
code changes are only required to pass the option, not to utilize it.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/f1abfbd1-2502-4b82-841c-7ef697b3a431"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/099cd511-0101-496c-b24e-ee2c19f23384"/>|

The backport PR was made first in #8878. Fixing the bug was time
sensitive and backport would not be clean due to the `widget.value`
changes. Changes were still simpler than expected. I probably should
have made this PR first and then backported, but I misjudged the
complexity of conflict resolution.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8879-Fix-primitive-assets-3076d73d365081b89ed4e6400dbf8e74)
by [Unito](https://www.unito.io)
2026-02-14 12:49:54 -08:00
Benjamin Lu
fcb4341c98 feat(queue): introduce queue notification banners and remove completion summary flow (#8740)
## Summary
Replace the old completion-summary overlay path with queue notification
banners for queueing/completed/failed lifecycle feedback.

## Key changes
- Added `QueueNotificationBanner`, `QueueNotificationBannerHost`,
stories, and tests.
- Added `useQueueNotificationBanners` to handle:
  - immediate `queuedPending` on `promptQueueing`
  - transition to `queued` on `promptQueued` (request-id aware)
  - completed/failed notification sequencing from finished batch history
  - timed notification queueing/dismissal
- Removed completion-summary implementation:
  - `useCompletionSummary`
  - `CompletionSummaryBanner`
  - `QueueOverlayEmpty`
- Simplified `QueueProgressOverlay` to `hidden | active | expanded`
states.
- Top menu behavior:
  - restored `QueueInlineProgressSummary` as separate UI
  - ordering is inline summary first, notification banner below
- notification banner remains under the top menu section (not teleported
to floating actionbar target)
- Kept established API-event signaling pattern
(`promptQueueing`/`promptQueued`) instead of introducing a separate bus.
- Updated tests for top-menu visibility/ordering and notification
behavior across QPOV2 enabled/disabled.

## Notes
- Completion notifications now support stacked thumbnails (cap: 3).
-
https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3843-20314&m=dev

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8740-feat-Queue-Notification-Toasts-3016d73d3650814c8a50d9567a40f44d)
by [Unito](https://www.unito.io)
2026-02-14 12:14:55 -08:00
AustinMroz
27da781029 Fix labels on output slots in vue mode (#8846)
Vue output slots were not respecting the `slot.label`. As a result, a
renamed subgraph output slot would still display the original name when
in vue mode.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8846-Fix-labels-on-output-slots-in-vue-mode-3066d73d3650811c986cffee03f56ac2)
by [Unito](https://www.unito.io)
2026-02-14 10:05:32 -08:00
Christian Byrne
36d59f26cd fix: SaveImage node not updating outputs during batch runs (vue-nodes) (#8862)
## Summary

Fix vue-node outputs not updating during batch runs by creating a new
object reference on merge.

## Changes

- **What**: Spread merged output object in `setOutputsByLocatorId` so
Vue detects the assignment as a change. Adds regression test asserting
reference identity changes on merge.

## Review Focus

One-line fix at `imagePreviewStore.ts:155`: `{ ...existingOutput }`
instead of `existingOutput`. This matches the spread pattern already
used in `restoreOutputs` (line 368).

The root cause: Vue skips reactivity triggers for same-reference
assignments. The merge path mutated `existingOutput` in-place then
reassigned the same object, so `nodeMedia` computed in `LGraphNode.vue`
never re-evaluated.

> Notion:
https://www.notion.so/comfy-org/3066d73d36508165873fcbb9673dece7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8862-fix-SaveImage-node-not-updating-outputs-during-batch-runs-vue-nodes-3076d73d36508133b1faeae66dcccf01)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-02-14 07:16:14 -08:00
Christian Byrne
5f7a6e7aba fix: clear draft on workflow close to prevent stale state on reopen (#8854)
## Summary

Clear the workflow draft from localStorage when any workflow tab is
closed, preventing stale cached state from being served when the
workflow is re-opened.

## Changes

- **What**: `closeWorkflow()` in `workflowStore.ts` now calls
`removeDraft()` for all workflows, not just temporary ones.
`closeWorkflow()` in `workflowService.ts` removes the draft before
switching tabs, preventing `beforeLoadNewGraph()` from re-saving it.

## Review Focus

- Draft is removed before the tab switch in
`workflowService.closeWorkflow()` to prevent `beforeLoadNewGraph()` from
re-saving it during the switch
- Crash recovery is preserved: drafts are only cleared on explicit
close, not on unload/crash
- Tab restore on restart is unaffected: drafts for intentionally-open
tabs are saved on graph change events, not on close

Fixes #8778
Fixes https://github.com/Comfy-Org/ComfyUI/issues/12323

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8854-fix-clear-draft-on-workflow-close-to-prevent-stale-state-on-reopen-3066d73d365081a2a633c9b352d0b0d1)
by [Unito](https://www.unito.io)
2026-02-14 02:50:05 -08:00
Comfy Org PR Bot
2c07bedbb1 1.40.3 (#8859)
Patch version increment to 1.40.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8859-1-40-3-3076d73d36508130ab36d2b00a9fb1f3)
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-14 02:41:47 -08:00
Terry Jia
78635294ce feat: add hideOutputImages flag for nodes with custom preview (#8857)
## Summary

Prerequisite for upcoming native color correction nodes (ColorCorrect,
ColorBalance, ColorCurves) which render their own WebGL preview and need
to suppress the default output image display while keeping data in the
store for downstream nodes.

## Screenshots (if applicable)
before
<img width="980" height="1580" alt="image"
src="https://github.com/user-attachments/assets/2e08869f-1cad-4637-8174-96d034da524c"
/>

after
<img width="783" height="1276" alt="image"
src="https://github.com/user-attachments/assets/3f9b50ee-268c-48f4-9e63-89ef8d732157"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8857-feat-add-hideOutputImages-flag-for-nodes-with-custom-preview-3076d73d365081a2aa55d34280601b47)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-14 03:55:36 -05:00
Alexander Brown
2f09c6321e Regenerate images (#8866)
```
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8866-Regenerate-images-3076d73d365081018009ff8e79c5a418)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-14 00:43:04 -08:00
Christian Byrne
38edba7024 fix: exclude missing assets from cloud mode dropdown (COM-14333) (#8747)
## Summary

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

## Changes

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

## Context

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

## Testing

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

Fixes COM-14333



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

---------

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

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8842-1-40-2-3066d73d365081638b92c580d8d8579d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-02-13 08:12:53 -08:00
pythongosssss
71d26eb4d9 Fix storybook build (#8849)
## Summary

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

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

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

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

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

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

## Screenshots (if applicable)


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

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

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

## Screenshots
before


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


after


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

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

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

## Changes

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

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

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

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

## Changes

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

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

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8815-1-40-1-3056d73d365081ed8adcf690bd07e1cd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-12 02:48:38 -08:00
Terry Jia
6cf0357b3e fix(vueNodes): sync node size changes from extensions to Vue components (#7993)
## Summary
When extensions like KJNodes call node.setSize(), the Vue component now
properly updates its CSS variables to reflect the new size.

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

## Screenshots (if applicable)
before

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


after

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

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

---------

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

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

## Changes

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

- Fixes follow-up from #8644

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

https://claude.ai/code/session_01EpCtunck6b89gpUFfWGKmZ

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

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

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

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

## Changes

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

## Screenshots

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



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

---------

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

## Screenshots (if applicable)
before 



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


after


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

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

---------

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

Misaligned option setting when building the SimplifiedWidget.

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

---------

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

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

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

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

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

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

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

## Screenshots (if applicable)

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

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

---------

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

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

## Changes

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

## Review Focus

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

Fixes #8800

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

## Changes Made

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

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

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

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

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

---------

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

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

## Changes

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

## Review Focus

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

## Screenshots (if applicable)

N/A

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

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

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

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

## Changes

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

## Before


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



## After


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


## Review Focus

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

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

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

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


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

---------

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

Update from beta.8 to beta.12

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

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

## Changes

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

## Why

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

## Backward Compatibility

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

## Testing

-  4252 unit tests pass
-  Build succeeds

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8594-feat-add-WidgetValueStore-for-centralized-widget-value-management-2fc6d73d36508160886fcb9f3ebd941e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-10 19:37:17 -08:00
Csongor Czezar
d044bed9b2 feat: use object info display_name as fallback before node name (#7622)
## Description
Implements fallback to object info display_name before using internal
node name when display_name is unavailable.

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

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

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

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

**Base branch:** `main`

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

---------

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-10 19:06:48 -08:00
352 changed files with 19335 additions and 4093 deletions

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
token: ${{ secrets.PR_GH_TOKEN }}
token: ${{ !github.event.pull_request.head.repo.fork && secrets.PR_GH_TOKEN || github.token }}
- name: Setup frontend
uses: ./.github/actions/setup-frontend

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
{
"id": "save-image-and-webm-test",
"revision": 0,
"last_node_id": 12,
"last_link_id": 2,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 100],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1, 2]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 11,
"type": "SaveImage",
"pos": [450, 100],
"size": [210, 270],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 12,
"type": "SaveWEBM",
"pos": [450, 450],
"size": [210, 368],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI", "vp9", 6, 32]
}
],
"links": [
[1, 10, 0, 11, 0, "IMAGE"],
[2, 10, 0, 12, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.17.0",
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -215,6 +215,14 @@ test.describe('Node search box', { tag: '@node' }, () => {
await expectFilterChips(comfyPage, ['MODEL', 'CLIP'])
})
test('Does not add duplicate filter with same type and value', async ({
comfyPage
}) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await expectFilterChips(comfyPage, ['MODEL'])
})
test('Can remove filter', async ({ comfyPage }) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.removeFilter(0)

View File

@@ -0,0 +1,42 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Save Image and WEBM preview',
{ tag: ['@screenshot', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('Can preview both SaveImage and SaveWEBM outputs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'widgets/save_image_and_animated_webp'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.runButton.click()
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
// Wait for SaveImage to render an img inside .image-preview
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
timeout: 30000
})
// Wait for SaveWEBM to render a video inside .video-preview
await expect(saveWebmNode.locator('.video-preview video')).toBeVisible({
timeout: 30000
})
await expect(comfyPage.page).toHaveScreenshot(
'save-image-and-webm-preview.png'
)
})
}
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -5,13 +5,6 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Graph.DeduplicateSubgraphNodeIds',
true
)
})
test('All node IDs are globally unique after loading', async ({
comfyPage
}) => {

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1,57 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
test.describe('Vue Nodes Image Preview', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})
async function loadImageOnNode(
comfyPage: Awaited<
ReturnType<(typeof test)['info']>
>['fixtures']['comfyPage']
) {
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = comfyPage.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return imagePreview
}
test.fixme('opens mask editor from image preview button', async ({
comfyPage
}) => {
const imagePreview = await loadImageOnNode(comfyPage)
await imagePreview.locator('[role="img"]').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
})
test.fixme('shows image context menu options', async ({ comfyPage }) => {
await loadImageOnNode(comfyPage)
const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
await nodeHeader.click()
await nodeHeader.click({ button: 'right' })
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
await expect(contextMenu.getByText('Open Image')).toBeVisible()
await expect(contextMenu.getByText('Copy Image')).toBeVisible()
await expect(contextMenu.getByText('Save Image')).toBeVisible()
await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -320,5 +320,15 @@ export default defineConfig([
}
]
}
},
// Storybook-only mock components (__stories__/**/*.vue)
// These are not shipped to production and do not require i18n or strict Vue patterns.
{
files: ['**/__stories__/**/*.vue'],
rules: {
'@intlify/vue-i18n/no-raw-text': 'off',
'vue/no-unused-properties': 'off'
}
}
])

25
global.d.ts vendored
View File

@@ -10,9 +10,28 @@ interface ImpactQueueFunction {
a?: unknown[][]
}
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
interface GtagGetFieldValueMap {
client_id: string | number | undefined
session_id: string | number | undefined
session_number: string | number | undefined
}
interface GtagFunction {
<TField extends GtagGetFieldName>(
command: 'get',
targetId: string,
fieldName: TField,
callback: (value: GtagGetFieldValueMap[TField]) => void
): void
(...args: unknown[]): void
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
ga_measurement_id?: string
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
@@ -36,12 +55,8 @@ interface Window {
badge?: string
}
}
__ga_identity__?: {
client_id?: string
session_id?: string
session_number?: string
}
dataLayer?: Array<Record<string, unknown>>
gtag?: GtagFunction
ire_o?: string
ire?: ImpactQueueFunction
}

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.2 KiB

556
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,11 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
import type { Component } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import type {
@@ -19,7 +20,11 @@ import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const mockData = vi.hoisted(() => ({ isLoggedIn: false, isDesktop: false }))
const mockData = vi.hoisted(() => ({
isLoggedIn: false,
isDesktop: false,
setShowConflictRedDot: (_value: boolean) => {}
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => {
@@ -36,6 +41,36 @@ vi.mock('@/platform/distribution/types', () => ({
return mockData.isDesktop
}
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: () => ({
shouldShowRedDot: computed(() => true)
})
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => {
const shouldShowConflictRedDot = ref(false)
mockData.setShowConflictRedDot = (value: boolean) => {
shouldShowConflictRedDot.value = value
}
return {
useConflictAcknowledgment: () => ({
shouldShowRedDot: shouldShowConflictRedDot
})
}
}
)
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: computed(() => true),
openManager: vi.fn()
})
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
currentUser: null,
@@ -79,6 +114,7 @@ function createWrapper({
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
QueueNotificationBannerHost: true,
CurrentUserButton: true,
LoginButton: true,
ContextMenu: {
@@ -108,12 +144,25 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(createJob(id, status))
}
function createComfyActionbarStub(actionbarTarget: HTMLElement) {
return defineComponent({
name: 'ComfyActionbar',
setup(_, { emit }) {
onMounted(() => {
emit('update:progressTarget', actionbarTarget)
})
return () => h('div')
}
})
}
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
mockData.isDesktop = false
mockData.isLoggedIn = false
mockData.setShowConflictRedDot(false)
})
describe('authentication state', () => {
@@ -166,6 +215,17 @@ describe('TopMenuSection', () => {
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
true
)
})
it('hides the active jobs indicator when no jobs are active', () => {
const wrapper = createWrapper()
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
false
)
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
@@ -281,15 +341,7 @@ describe('TopMenuSection', () => {
const executionStore = useExecutionStore(pinia)
executionStore.activePromptId = 'prompt-1'
const ComfyActionbarStub = defineComponent({
name: 'ComfyActionbar',
setup(_, { emit }) {
onMounted(() => {
emit('update:progressTarget', actionbarTarget)
})
return () => h('div')
}
})
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
const wrapper = createWrapper({
pinia,
@@ -311,6 +363,103 @@ describe('TopMenuSection', () => {
})
})
describe(QueueNotificationBannerHost, () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
}
it('renders queue notification banners when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
).toBe(true)
})
it('renders queue notification banners when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
).toBe(true)
})
it('renders inline summary above banners when both are visible', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
await nextTick()
const html = wrapper.html()
const inlineSummaryIndex = html.indexOf(
'queue-inline-progress-summary-stub'
)
const queueBannerIndex = html.indexOf(
'queue-notification-banner-host-stub'
)
expect(inlineSummaryIndex).toBeGreaterThan(-1)
expect(queueBannerIndex).toBeGreaterThan(-1)
expect(inlineSummaryIndex).toBeLessThan(queueBannerIndex)
})
it('does not teleport queue notification banners when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')
document.body.appendChild(actionbarTarget)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activePromptId = 'prompt-1'
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
const wrapper = createWrapper({
pinia,
attachTo: document.body,
stubs: {
ComfyActionbar: ComfyActionbarStub,
QueueNotificationBannerHost: true
}
})
try {
await nextTick()
expect(
actionbarTarget.querySelector('queue-notification-banner-host-stub')
).toBeNull()
expect(
wrapper
.findComponent({ name: 'QueueNotificationBannerHost' })
.exists()
).toBe(true)
} finally {
wrapper.unmount()
actionbarTarget.remove()
}
})
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
@@ -330,4 +479,16 @@ describe('TopMenuSection', () => {
const model = menu.props('model') as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
it('shows manager red dot only for manager conflicts', async () => {
const wrapper = createWrapper()
// Release red dot is mocked as true globally for this test file.
expect(wrapper.find('span.bg-red-500').exists()).toBe(false)
mockData.setShowConflictRedDot(true)
await nextTick()
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
})
})

View File

@@ -36,7 +36,14 @@
<div
ref="actionbarContainerRef"
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
:class="
cn(
'actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
)
"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
@@ -60,7 +67,7 @@
? isQueueOverlayExpanded
: undefined
"
class="px-3"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
@@ -68,6 +75,12 @@
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
@@ -97,6 +110,7 @@
</Button>
</div>
</div>
<ErrorOverlay />
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
@@ -105,7 +119,7 @@
</div>
</div>
<div>
<div class="flex flex-col items-end gap-1">
<Teleport
v-if="inlineProgressSummaryTarget"
:to="inlineProgressSummaryTarget"
@@ -121,6 +135,10 @@
class="pr-1"
:hidden="isQueueOverlayExpanded"
/>
<QueueNotificationBannerHost
v-if="shouldShowQueueNotificationBanners"
class="pr-1"
/>
</div>
</div>
</template>
@@ -135,8 +153,11 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
@@ -145,7 +166,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -157,6 +177,7 @@ import { isDesktop } from '@/platform/distribution/types'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { cn } from '@/utils/tailwindUtil'
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
@@ -173,8 +194,6 @@ const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
@@ -207,6 +226,9 @@ const isQueueProgressOverlayEnabled = computed(
const shouldShowInlineProgressSummary = computed(
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
)
const shouldShowQueueNotificationBanners = computed(
() => isActionbarEnabled.value
)
const progressTarget = ref<HTMLElement | null>(null)
function updateProgressTarget(target: HTMLElement | null) {
progressTarget.value = target
@@ -236,12 +258,12 @@ const queueContextMenuItems = computed<MenuItem[]>(() => [
}
])
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
return shouldShowConflictRedDot.value
})
const { hasAnyError } = storeToRefs(executionStore)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>

View File

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

View File

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

View File

@@ -0,0 +1,175 @@
<template>
<div
ref="container"
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
>
<slot name="background" />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.decrement')"
data-testid="decrement"
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canDecrement"
tabindex="-1"
@click="modelValue = clamp(modelValue - step)"
>
<i class="pi pi-minus" />
</Button>
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
<input
ref="inputField"
v-bind="inputAttrs"
:value="displayValue ?? modelValue"
:disabled
:class="
cn(
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
)
"
inputmode="decimal"
autocomplete="off"
autocorrect="off"
spellcheck="false"
@blur="handleBlur"
@keyup.enter="handleBlur"
@dragstart.prevent
/>
<div
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize',
textEdit && 'pointer-events-none hidden'
)
"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="resetDrag"
/>
</div>
<slot />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.increment')"
data-testid="increment"
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canIncrement"
tabindex="-1"
@click="modelValue = clamp(modelValue + step)"
>
<i class="pi pi-plus" />
</Button>
</div>
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
min,
max,
step = 1,
disabled = false,
hideButtons = false,
displayValue,
parseValue
} = defineProps<{
min?: number
max?: number
step?: number
disabled?: boolean
hideButtons?: boolean
displayValue?: string
parseValue?: (raw: string) => number | undefined
inputAttrs?: Record<string, unknown>
}>()
const { t } = useI18n()
const modelValue = defineModel<number>({ default: 0 })
const container = useTemplateRef<HTMLDivElement>('container')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const textEdit = ref(false)
onClickOutside(container, () => {
if (textEdit.value) textEdit.value = false
})
function clamp(value: number): number {
const lo = min ?? -Infinity
const hi = max ?? Infinity
return Math.min(hi, Math.max(lo, value))
}
const canDecrement = computed(
() => modelValue.value > (min ?? -Infinity) && !disabled
)
const canIncrement = computed(
() => modelValue.value < (max ?? Infinity) && !disabled
)
const dragging = ref(false)
const dragDelta = ref(0)
const hasDragged = ref(false)
function handleBlur(e: Event) {
const target = e.target as HTMLInputElement
const raw = target.value.trim()
const parsed = parseValue
? parseValue(raw)
: raw === ''
? undefined
: Number(raw)
if (parsed != null && !isNaN(parsed)) {
modelValue.value = clamp(parsed)
} else {
target.value = displayValue ?? String(modelValue.value)
}
textEdit.value = false
}
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
if (disabled) return
const target = e.target as HTMLElement
target.setPointerCapture(e.pointerId)
dragging.value = true
dragDelta.value = 0
hasDragged.value = false
}
function handlePointerMove(e: PointerEvent) {
if (!dragging.value) return
dragDelta.value += e.movementX
const steps = (dragDelta.value / 10) | 0
if (steps === 0) return
hasDragged.value = true
const unclipped = modelValue.value + steps * step
dragDelta.value %= 10
modelValue.value = clamp(unclipped)
}
function handlePointerUp() {
if (!dragging.value) return
if (!hasDragged.value) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
resetDrag()
}
function resetDrag() {
dragging.value = false
dragDelta.value = 0
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -116,7 +116,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import type { ConfirmationDialogType } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -134,10 +134,7 @@ const onCancel = () => useDialogStore().closeDialog()
function openBlueprintOverwriteSetting() {
useDialogStore().closeDialog()
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.WarnBlueprintOverwrite'
)
useSettingsDialog().show(undefined, 'Comfy.Workflow.WarnBlueprintOverwrite')
}
const doNotAskAgain = ref(false)

View File

@@ -64,7 +64,7 @@ import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
// TODO: Read this from server internal API rather than hardcoding here
@@ -105,10 +105,7 @@ const doNotAskAgain = ref(false)
function openShowMissingModelsSetting() {
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.ShowMissingModelsWarning'
)
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
}
const modelDownloads = ref<Record<string, ModelInfo>>({})

View File

@@ -90,7 +90,7 @@ import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
@@ -118,10 +118,7 @@ const handleGotItClick = () => {
function openShowMissingNodesSetting() {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.ShowMissingNodesWarning'
)
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingNodesWarning')
}
const { missingNodePacks, isLoading, error } = useMissingNodes()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,100 @@
<template>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="-translate-y-3 opacity-0"
enter-to-class="translate-y-0 opacity-100"
>
<div v-if="isVisible" class="flex justify-end w-full pointer-events-none">
<div
class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-interface-stroke bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
>
<!-- Header -->
<div class="flex h-12 items-center gap-2 px-4">
<span class="flex-1 text-sm font-bold text-destructive-background">
{{ errorCountLabel }}
</span>
<Button
variant="muted-textonly"
size="icon-sm"
:aria-label="t('g.close')"
@click="dismiss"
>
<i class="icon-[lucide--x] block size-5 leading-none" />
</Button>
</div>
<!-- Body -->
<div class="px-4 pb-3">
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
<li
v-for="(message, idx) in groupedErrorMessages"
:key="idx"
class="flex items-baseline gap-2 text-sm leading-snug text-muted-foreground"
>
<span
class="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground"
/>
<span>{{ message }}</span>
</li>
</ul>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-3">
<Button variant="muted-textonly" size="unset" @click="dismiss">
{{ t('g.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="seeErrors">
{{ t('errorOverlay.seeErrors') }}
</Button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionStore } from '@/stores/executionStore'
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 rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionStore)
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
const errorCountLabel = computed(() =>
t(
'errorOverlay.errorCount',
{ count: totalErrorCount.value },
totalErrorCount.value
)
)
const isVisible = computed(
() => isErrorOverlayOpen.value && totalErrorCount.value > 0
)
function dismiss() {
executionStore.dismissErrorOverlay()
}
function seeErrors() {
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionStore.dismissErrorOverlay()
}
</script>

View File

@@ -60,6 +60,9 @@
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
:canvas="comfyApp.canvas"
@wheel.capture="canvasInteractions.forwardEventToCanvas"
@pointerdown.capture="forwardPanEvent"
@pointerup.capture="forwardPanEvent"
@pointermove.capture="forwardPanEvent"
>
<!-- Vue nodes rendered based on graph nodes -->
<LGraphNode
@@ -114,6 +117,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
@@ -160,6 +164,7 @@ import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useNewUserService } from '@/services/useNewUserService'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
@@ -540,4 +545,13 @@ onMounted(async () => {
onUnmounted(() => {
vueNodeLifecycle.cleanup()
})
function forwardPanEvent(e: PointerEvent) {
if (
(shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target) ||
!isMiddlePointerInput(e)
)
return
canvasInteractions.forwardEventToCanvas(e)
}
</script>

View File

@@ -67,18 +67,6 @@ describe('HoneyToast', () => {
wrapper.unmount()
})
it('applies collapsed max-height class when collapsed', async () => {
const wrapper = mountComponent({ visible: true, expanded: false })
await nextTick()
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
wrapper.unmount()
})
it('has aria-live="polite" for accessibility', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
@@ -127,11 +115,6 @@ describe('HoneyToast', () => {
expect(content?.textContent).toBe('expanded')
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
wrapper.unmount()
})
})

View File

@@ -26,13 +26,13 @@ function toggle() {
v-if="visible"
role="status"
aria-live="polite"
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
class="fixed inset-x-0 bottom-6 z-9999 mx-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg min-w-0 w-min transition-all duration-300"
>
<div
:class="
cn(
'overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-[400px]' : 'max-h-0'
'overflow-hidden transition-all duration-300 min-w-0 max-w-full',
isExpanded ? 'w-[max(400px,40vw)] max-h-100' : 'w-0 max-h-0'
)
"
>

View File

@@ -28,7 +28,7 @@
:src="imageUrl"
:alt="$t('imageCrop.cropPreviewAlt')"
draggable="false"
class="block size-full object-contain select-none brightness-50"
class="block size-full object-contain select-none"
@load="handleImageLoad"
@error="handleImageError"
@dragstart.prevent
@@ -36,14 +36,12 @@
<div
v-if="imageUrl && !isLoading"
class="absolute box-content cursor-move overflow-hidden border-2 border-white"
class="absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
:style="cropBoxStyle"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"
>
<div class="pointer-events-none size-full" :style="cropImageStyle" />
</div>
/>
<div
v-for="handle in resizeHandles"
@@ -131,7 +129,6 @@ const {
isLockEnabled,
cropBoxStyle,
cropImageStyle,
resizeHandles,
handleImageLoad,

View File

@@ -1,73 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
const meta: Meta<typeof CompletionSummaryBanner> = {
title: 'Queue/CompletionSummaryBanner',
component: CompletionSummaryBanner,
parameters: {
layout: 'padded'
}
}
export default meta
type Story = StoryObj<typeof meta>
const thumb = (hex: string) =>
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='%23${hex}'/></svg>`
const thumbs = [thumb('ff6b6b'), thumb('4dabf7'), thumb('51cf66')]
export const AllSuccessSingle: Story = {
args: {
mode: 'allSuccess',
completedCount: 1,
failedCount: 0,
thumbnailUrls: [thumbs[0]]
}
}
export const AllSuccessPlural: Story = {
args: {
mode: 'allSuccess',
completedCount: 3,
failedCount: 0,
thumbnailUrls: thumbs
}
}
export const MixedSingleSingle: Story = {
args: {
mode: 'mixed',
completedCount: 1,
failedCount: 1,
thumbnailUrls: thumbs.slice(0, 2)
}
}
export const MixedPluralPlural: Story = {
args: {
mode: 'mixed',
completedCount: 2,
failedCount: 3,
thumbnailUrls: thumbs
}
}
export const AllFailedSingle: Story = {
args: {
mode: 'allFailed',
completedCount: 0,
failedCount: 1,
thumbnailUrls: []
}
}
export const AllFailedPlural: Story = {
args: {
mode: 'allFailed',
completedCount: 0,
failedCount: 4,
thumbnailUrls: []
}
}

View File

@@ -1,91 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
jobsCompleted: '{count} job completed | {count} jobs completed',
jobsFailed: '{count} job failed | {count} jobs failed'
}
}
}
}
})
const mountComponent = (props: Record<string, unknown>) =>
mount(CompletionSummaryBanner, {
props: {
mode: 'allSuccess',
completedCount: 0,
failedCount: 0,
...props
},
global: {
plugins: [i18n]
}
})
describe('CompletionSummaryBanner', () => {
it('renders success mode text, thumbnails, and aria label', () => {
const wrapper = mountComponent({
mode: 'allSuccess',
completedCount: 3,
failedCount: 0,
thumbnailUrls: [
'https://example.com/thumb-a.png',
'https://example.com/thumb-b.png'
],
ariaLabel: 'Open queue summary'
})
const button = wrapper.get('button')
expect(button.attributes('aria-label')).toBe('Open queue summary')
expect(wrapper.text()).toContain('3 jobs completed')
const thumbnailImages = wrapper.findAll('img')
expect(thumbnailImages).toHaveLength(2)
expect(thumbnailImages[0].attributes('src')).toBe(
'https://example.com/thumb-a.png'
)
expect(thumbnailImages[1].attributes('src')).toBe(
'https://example.com/thumb-b.png'
)
const thumbnailContainers = wrapper.findAll('.inline-block.h-6.w-6')
expect(thumbnailContainers[1].attributes('style')).toContain(
'margin-left: -12px'
)
expect(wrapper.html()).not.toContain('icon-[lucide--circle-alert]')
})
it('renders mixed mode with success and failure counts', () => {
const wrapper = mountComponent({
mode: 'mixed',
completedCount: 2,
failedCount: 1
})
const summaryText = wrapper.text().replace(/\s+/g, ' ').trim()
expect(summaryText).toContain('2 jobs completed, 1 job failed')
})
it('renders failure mode icon without thumbnails', () => {
const wrapper = mountComponent({
mode: 'allFailed',
completedCount: 0,
failedCount: 4
})
expect(wrapper.text()).toContain('4 jobs failed')
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
expect(wrapper.findAll('img')).toHaveLength(0)
})
})

View File

@@ -1,109 +0,0 @@
<template>
<Button
variant="secondary"
size="lg"
class="group w-full justify-between gap-3 p-1 text-left font-normal hover:cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="props.ariaLabel"
@click="emit('click', $event)"
>
<span class="inline-flex items-center gap-2">
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
<i
class="ml-1 icon-[lucide--circle-alert] block size-4 leading-none text-destructive-background"
/>
</span>
<span class="inline-flex items-center gap-2">
<span
v-if="props.mode !== 'allFailed'"
class="relative inline-flex h-6 items-center"
>
<span
v-for="(url, idx) in props.thumbnailUrls"
:key="url + idx"
class="inline-block h-6 w-6 overflow-hidden rounded-[6px] border-0 bg-secondary-background"
:style="{ marginLeft: idx === 0 ? '0' : '-12px' }"
>
<img
:src="url"
:alt="$t('sideToolbar.queueProgressOverlay.preview')"
class="h-full w-full object-cover"
/>
</span>
</span>
<span class="text-[14px] font-normal text-text-primary">
<template v-if="props.mode === 'allSuccess'">
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
:plural="props.completedCount"
>
<template #count>
<span class="font-bold">{{ props.completedCount }}</span>
</template>
</i18n-t>
</template>
<template v-else-if="props.mode === 'mixed'">
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
:plural="props.completedCount"
>
<template #count>
<span class="font-bold">{{ props.completedCount }}</span>
</template>
</i18n-t>
<span>, </span>
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
:plural="props.failedCount"
>
<template #count>
<span class="font-bold">{{ props.failedCount }}</span>
</template>
</i18n-t>
</template>
<template v-else>
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
:plural="props.failedCount"
>
<template #count>
<span class="font-bold">{{ props.failedCount }}</span>
</template>
</i18n-t>
</template>
</span>
</span>
</span>
<span
class="flex items-center justify-center rounded p-1 text-text-secondary transition-colors duration-200 ease-in-out"
>
<i class="icon-[lucide--chevron-down] block size-4 leading-none" />
</span>
</Button>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import type {
CompletionSummary,
CompletionSummaryMode
} from '@/composables/queue/useCompletionSummary'
type Props = {
mode: CompletionSummaryMode
completedCount: CompletionSummary['completedCount']
failedCount: CompletionSummary['failedCount']
thumbnailUrls?: CompletionSummary['thumbnailUrls']
ariaLabel?: string
}
const props = withDefaults(defineProps<Props>(), {
thumbnailUrls: () => []
})
const emit = defineEmits<{
(e: 'click', event: MouseEvent): void
}>()
</script>

View File

@@ -0,0 +1,140 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { QueueNotificationBanner as QueueNotificationBannerItem } from '@/composables/queue/useQueueNotificationBanners'
import QueueNotificationBanner from './QueueNotificationBanner.vue'
const meta: Meta<typeof QueueNotificationBanner> = {
title: 'Queue/QueueNotificationBanner',
component: QueueNotificationBanner,
parameters: {
layout: 'padded'
}
}
export default meta
type Story = StoryObj<typeof meta>
const thumbnail = (hex: string) =>
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'><rect width='256' height='256' fill='%23${hex}'/></svg>`
const args = (notification: QueueNotificationBannerItem) => ({ notification })
export const Queueing: Story = {
args: args({
type: 'queuedPending',
count: 1
})
}
export const QueueingMultiple: Story = {
args: args({
type: 'queuedPending',
count: 3
})
}
export const Queued: Story = {
args: args({
type: 'queued',
count: 1
})
}
export const QueuedMultiple: Story = {
args: args({
type: 'queued',
count: 4
})
}
export const Completed: Story = {
args: args({
type: 'completed',
count: 1,
thumbnailUrls: [thumbnail('4dabf7')]
})
}
export const CompletedMultiple: Story = {
args: args({
type: 'completed',
count: 4
})
}
export const CompletedMultipleWithThumbnail: Story = {
args: args({
type: 'completed',
count: 4,
thumbnailUrls: [
thumbnail('ff6b6b'),
thumbnail('4dabf7'),
thumbnail('51cf66')
]
})
}
export const Failed: Story = {
args: args({
type: 'failed',
count: 1
})
}
export const Gallery: Story = {
render: () => ({
components: { QueueNotificationBanner },
setup() {
const queueing = args({
type: 'queuedPending',
count: 1
})
const queued = args({
type: 'queued',
count: 2
})
const completed = args({
type: 'completed',
count: 1,
thumbnailUrls: [thumbnail('ff6b6b')]
})
const completedMultiple = args({
type: 'completed',
count: 4
})
const completedMultipleWithThumbnail = args({
type: 'completed',
count: 4,
thumbnailUrls: [
thumbnail('51cf66'),
thumbnail('ffd43b'),
thumbnail('ff922b')
]
})
const failed = args({
type: 'failed',
count: 2
})
return {
queueing,
queued,
completed,
completedMultiple,
completedMultipleWithThumbnail,
failed
}
},
template: `
<div class="flex flex-col gap-2">
<QueueNotificationBanner v-bind="queueing" />
<QueueNotificationBanner v-bind="queued" />
<QueueNotificationBanner v-bind="completed" />
<QueueNotificationBanner v-bind="completedMultiple" />
<QueueNotificationBanner v-bind="completedMultipleWithThumbnail" />
<QueueNotificationBanner v-bind="failed" />
</div>
`
})
}

View File

@@ -0,0 +1,136 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import QueueNotificationBanner from '@/components/queue/QueueNotificationBanner.vue'
import type { QueueNotificationBanner as QueueNotificationBannerItem } from '@/composables/queue/useQueueNotificationBanners'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
queue: {
jobAddedToQueue: 'Job added to queue',
jobQueueing: 'Job queueing'
},
sideToolbar: {
queueProgressOverlay: {
preview: 'Preview',
jobCompleted: 'Job completed',
jobFailed: 'Job failed',
jobsAddedToQueue:
'{count} job added to queue | {count} jobs added to queue',
jobsCompleted: '{count} job completed | {count} jobs completed',
jobsFailed: '{count} job failed | {count} jobs failed'
}
}
}
}
})
const mountComponent = (notification: QueueNotificationBannerItem) =>
mount(QueueNotificationBanner, {
props: { notification },
global: {
plugins: [i18n]
}
})
describe(QueueNotificationBanner, () => {
it('renders singular queued message without count prefix', () => {
const wrapper = mountComponent({
type: 'queued',
count: 1
})
expect(wrapper.text()).toContain('Job added to queue')
expect(wrapper.text()).not.toContain('1 job')
})
it('renders queued message with pluralization', () => {
const wrapper = mountComponent({
type: 'queued',
count: 2
})
expect(wrapper.text()).toContain('2 jobs added to queue')
expect(wrapper.html()).toContain('icon-[lucide--check]')
})
it('renders queued pending message with spinner icon', () => {
const wrapper = mountComponent({
type: 'queuedPending',
count: 1
})
expect(wrapper.text()).toContain('Job queueing')
expect(wrapper.html()).toContain('icon-[lucide--loader-circle]')
expect(wrapper.html()).toContain('animate-spin')
})
it('renders failed message and alert icon', () => {
const wrapper = mountComponent({
type: 'failed',
count: 1
})
expect(wrapper.text()).toContain('Job failed')
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
})
it('renders completed message with thumbnail preview when provided', () => {
const wrapper = mountComponent({
type: 'completed',
count: 3,
thumbnailUrls: ['https://example.com/preview.png']
})
expect(wrapper.text()).toContain('3 jobs completed')
const image = wrapper.get('img')
expect(image.attributes('src')).toBe('https://example.com/preview.png')
expect(image.attributes('alt')).toBe('Preview')
})
it('renders two completion thumbnail previews', () => {
const wrapper = mountComponent({
type: 'completed',
count: 4,
thumbnailUrls: [
'https://example.com/preview-1.png',
'https://example.com/preview-2.png'
]
})
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe(
'https://example.com/preview-1.png'
)
expect(images[1].attributes('src')).toBe(
'https://example.com/preview-2.png'
)
})
it('caps completion thumbnail previews at two', () => {
const wrapper = mountComponent({
type: 'completed',
count: 4,
thumbnailUrls: [
'https://example.com/preview-1.png',
'https://example.com/preview-2.png',
'https://example.com/preview-3.png',
'https://example.com/preview-4.png'
]
})
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe(
'https://example.com/preview-1.png'
)
expect(images[1].attributes('src')).toBe(
'https://example.com/preview-2.png'
)
})
})

View File

@@ -0,0 +1,149 @@
<template>
<div class="inline-flex overflow-hidden rounded-lg bg-secondary-background">
<div class="flex items-center gap-2 p-1 pr-3">
<div
:class="
cn(
'relative shrink-0 items-center rounded-[4px]',
showsCompletionPreview && showThumbnails
? 'flex h-8 overflow-visible p-0'
: showsCompletionPreview
? 'flex size-8 justify-center overflow-hidden p-0'
: 'flex size-8 justify-center p-1'
)
"
>
<template v-if="showThumbnails">
<div class="flex h-8 shrink-0 items-center">
<div
v-for="(thumbnailUrl, index) in thumbnailUrls"
:key="`completion-preview-${index}`"
:class="
cn(
'relative size-8 shrink-0 overflow-hidden rounded-[4px]',
index > 0 && '-ml-3 ring-2 ring-secondary-background'
)
"
>
<img
:src="thumbnailUrl"
:alt="t('sideToolbar.queueProgressOverlay.preview')"
class="size-full object-cover"
/>
</div>
</div>
</template>
<div
v-else-if="showCompletionGradientFallback"
class="size-full bg-linear-to-br from-coral-500 via-coral-500 to-azure-600"
/>
<i
v-else
:class="cn(iconClass, 'size-4', iconColorClass)"
aria-hidden="true"
/>
</div>
<div class="flex h-full items-center">
<span
class="overflow-hidden text-ellipsis text-center font-inter text-[12px] leading-normal font-normal text-base-foreground"
>
{{ bannerText }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { QueueNotificationBanner } from '@/composables/queue/useQueueNotificationBanners'
import { cn } from '@/utils/tailwindUtil'
const { notification } = defineProps<{
notification: QueueNotificationBanner
}>()
const { t, n } = useI18n()
const thumbnailUrls = computed(() => {
if (notification.type !== 'completed') {
return []
}
return notification.thumbnailUrls?.slice(0, 2) ?? []
})
const showThumbnails = computed(() => {
if (notification.type !== 'completed') {
return false
}
return thumbnailUrls.value.length > 0
})
const showCompletionGradientFallback = computed(
() => notification.type === 'completed' && !showThumbnails.value
)
const showsCompletionPreview = computed(
() => showThumbnails.value || showCompletionGradientFallback.value
)
const bannerText = computed(() => {
const count = notification.count
if (notification.type === 'queuedPending') {
return t('queue.jobQueueing')
}
if (notification.type === 'queued') {
if (count === 1) {
return t('queue.jobAddedToQueue')
}
return t(
'sideToolbar.queueProgressOverlay.jobsAddedToQueue',
{ count: n(count) },
count
)
}
if (notification.type === 'failed') {
if (count === 1) {
return t('sideToolbar.queueProgressOverlay.jobFailed')
}
return t(
'sideToolbar.queueProgressOverlay.jobsFailed',
{ count: n(count) },
count
)
}
if (count === 1) {
return t('sideToolbar.queueProgressOverlay.jobCompleted')
}
return t(
'sideToolbar.queueProgressOverlay.jobsCompleted',
{ count: n(count) },
count
)
})
const iconClass = computed(() => {
if (notification.type === 'queuedPending') {
return 'icon-[lucide--loader-circle]'
}
if (notification.type === 'queued') {
return 'icon-[lucide--check]'
}
if (notification.type === 'failed') {
return 'icon-[lucide--circle-alert]'
}
return 'icon-[lucide--image]'
})
const iconColorClass = computed(() => {
if (notification.type === 'queuedPending') {
return 'animate-spin text-slate-100'
}
if (notification.type === 'failed') {
return 'text-danger-200'
}
return 'text-slate-100'
})
</script>

View File

@@ -0,0 +1,18 @@
<template>
<div
v-if="currentNotification"
class="flex justify-end"
role="status"
aria-live="polite"
aria-atomic="true"
>
<QueueNotificationBanner :notification="currentNotification" />
</div>
</template>
<script setup lang="ts">
import QueueNotificationBanner from '@/components/queue/QueueNotificationBanner.vue'
import { useQueueNotificationBanners } from '@/composables/queue/useQueueNotificationBanners'
const { currentNotification } = useQueueNotificationBanners()
</script>

View File

@@ -1,69 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import QueueOverlayEmpty from './QueueOverlayEmpty.vue'
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
expandCollapsedQueue: 'Expand job queue',
noActiveJobs: 'No active jobs'
}
}
}
}
})
const CompletionSummaryBannerStub = {
name: 'CompletionSummaryBanner',
props: [
'mode',
'completedCount',
'failedCount',
'thumbnailUrls',
'ariaLabel'
],
emits: ['click'],
template: '<button class="summary-banner" @click="$emit(\'click\')"></button>'
}
const mountComponent = (summary: CompletionSummary) =>
mount(QueueOverlayEmpty, {
props: { summary },
global: {
plugins: [i18n],
components: { CompletionSummaryBanner: CompletionSummaryBannerStub }
}
})
describe('QueueOverlayEmpty', () => {
it('renders completion summary banner and proxies click', async () => {
const summary: CompletionSummary = {
mode: 'mixed',
completedCount: 2,
failedCount: 1,
thumbnailUrls: ['thumb-a']
}
const wrapper = mountComponent(summary)
const summaryBanner = wrapper.findComponent(CompletionSummaryBannerStub)
expect(summaryBanner.exists()).toBe(true)
expect(summaryBanner.props()).toMatchObject({
mode: 'mixed',
completedCount: 2,
failedCount: 1,
thumbnailUrls: ['thumb-a'],
ariaLabel: 'Expand job queue'
})
await summaryBanner.trigger('click')
expect(wrapper.emitted('summaryClick')).toHaveLength(1)
})
})

View File

@@ -1,27 +0,0 @@
<template>
<div class="pointer-events-auto">
<CompletionSummaryBanner
:mode="summary.mode"
:completed-count="summary.completedCount"
:failed-count="summary.failedCount"
:thumbnail-urls="summary.thumbnailUrls"
:aria-label="t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')"
@click="$emit('summaryClick')"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import CompletionSummaryBanner from '@/components/queue/CompletionSummaryBanner.vue'
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
defineProps<{ summary: CompletionSummary }>()
defineEmits<{
(e: 'summaryClick'): void
}>()
const { t } = useI18n()
</script>

View File

@@ -4,46 +4,17 @@
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
:queued-count="queuedCount"
@clear-history="$emit('clearHistory')"
@clear-queued="$emit('clearQueued')"
/>
<div class="flex items-center justify-between px-3">
<Button
class="grow gap-1 justify-center"
variant="secondary"
size="sm"
@click="$emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
</Button>
<div class="ml-4 inline-flex items-center">
<div
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
>
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</div>
<Button
v-if="queuedCount > 0"
class="ml-2"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
</div>
<JobFiltersBar
:selected-job-tab="selectedJobTab"
:selected-workflow-filter="selectedWorkflowFilter"
:selected-sort-mode="selectedSortMode"
:has-failed-jobs="hasFailedJobs"
@show-assets="$emit('showAssets')"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
@@ -71,9 +42,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type {
JobGroup,
JobListItem,
@@ -112,8 +81,6 @@ const emit = defineEmits<{
(e: 'viewItem', item: JobListItem): void
}>()
const { t } = useI18n()
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)

View File

@@ -40,6 +40,8 @@ const i18n = createI18n({
sideToolbar: {
queueProgressOverlay: {
running: 'running',
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
moreOptions: 'More options',
clearHistory: 'Clear history'
}
@@ -54,6 +56,7 @@ const mountHeader = (props = {}) =>
headerTitle: 'Job queue',
showConcurrentIndicator: true,
concurrentWorkflowCount: 2,
queuedCount: 3,
...props
},
global: {
@@ -80,6 +83,25 @@ describe('QueueOverlayHeader', () => {
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
})
it('shows queued summary and emits clear queued', async () => {
const wrapper = mountHeader({ queuedCount: 4 })
expect(wrapper.text()).toContain('4')
expect(wrapper.text()).toContain('queued')
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
await clearQueuedButton.trigger('click')
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
})
it('hides clear queued button when queued count is zero', () => {
const wrapper = mountHeader({ queuedCount: 0 })
expect(wrapper.find('button[aria-label="Clear queued"]').exists()).toBe(
false
)
})
it('toggles popover and emits clear history', async () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')

View File

@@ -1,8 +1,8 @@
<template>
<div
class="flex h-12 items-center justify-between gap-2 border-b border-interface-stroke px-2"
class="flex h-12 items-center gap-2 border-b border-interface-stroke px-2"
>
<div class="px-2 text-[14px] font-normal text-text-primary">
<div class="min-w-0 flex-1 px-2 text-[14px] font-normal text-text-primary">
<span>{{ headerTitle }}</span>
<span
v-if="showConcurrentIndicator"
@@ -17,6 +17,25 @@
</span>
</span>
</div>
<div
class="inline-flex h-6 items-center gap-2 text-[12px] leading-none text-text-primary"
>
<span class="opacity-90">
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</span>
<Button
v-if="queuedCount > 0"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<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"
@@ -78,10 +97,12 @@ defineProps<{
headerTitle: string
showConcurrentIndicator: boolean
concurrentWorkflowCount: number
queuedCount: number
}>()
const emit = defineEmits<{
(e: 'clearHistory'): void
(e: 'clearQueued'): void
}>()
const { t } = useI18n()

View File

@@ -0,0 +1,99 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
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'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const QueueOverlayExpandedStub = defineComponent({
name: 'QueueOverlayExpanded',
props: {
headerTitle: {
type: String,
required: true
}
},
template: '<div data-testid="expanded-title">{{ headerTitle }}</div>'
})
function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl({
id,
status,
create_time: 0,
priority: 0
})
}
const mountComponent = (
runningTasks: TaskItemImpl[],
pendingTasks: TaskItemImpl[]
) => {
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
const queueStore = useQueueStore(pinia)
queueStore.runningTasks = runningTasks
queueStore.pendingTasks = pendingTasks
return mount(QueueProgressOverlay, {
props: {
expanded: true
},
global: {
plugins: [pinia, i18n],
stubs: {
QueueOverlayExpanded: QueueOverlayExpandedStub,
QueueOverlayActive: true,
ResultGallery: true
},
directives: {
tooltip: () => {}
}
}
})
}
describe('QueueProgressOverlay', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
})
it('shows expanded header with running and queued labels', () => {
const wrapper = mountComponent(
[
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
],
[createTask('pending-1', 'pending')]
)
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'2 running, 1 queued'
)
})
it('shows only running label when queued count is zero', () => {
const wrapper = mountComponent([createTask('running-1', 'in_progress')], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'1 running'
)
})
it('shows job queue title when there are no active jobs', () => {
const wrapper = mountComponent([], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'Job Queue'
)
})
})

View File

@@ -44,12 +44,6 @@
@clear-queued="cancelQueuedWorkflows"
@view-all-jobs="viewAllJobs"
/>
<QueueOverlayEmpty
v-else-if="completionSummary"
:summary="completionSummary"
@summary-click="onSummaryClick"
/>
</div>
</div>
@@ -64,11 +58,9 @@ import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
import QueueOverlayEmpty from '@/components/queue/QueueOverlayEmpty.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 { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
@@ -84,7 +76,7 @@ import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
type OverlayState = 'hidden' | 'active' | 'expanded'
const props = withDefaults(
defineProps<{
@@ -100,7 +92,7 @@ const emit = defineEmits<{
(e: 'update:expanded', value: boolean): void
}>()
const { t } = useI18n()
const { t, n } = useI18n()
const queueStore = useQueueStore()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
@@ -130,26 +122,20 @@ const isExpanded = computed({
}
})
const { summary: completionSummary, clearSummary } = useCompletionSummary()
const hasCompletionSummary = computed(() => completionSummary.value !== null)
const runningCount = computed(() => queueStore.runningTasks.length)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const isExecuting = computed(() => !executionStore.isIdle)
const hasActiveJob = computed(() => runningCount.value > 0 || isExecuting.value)
const activeJobsCount = computed(() => runningCount.value + queuedCount.value)
const overlayState = computed<OverlayState>(() => {
if (isExpanded.value) return 'expanded'
if (hasActiveJob.value) return 'active'
if (hasCompletionSummary.value) return 'empty'
return 'hidden'
})
const showBackground = computed(
() =>
overlayState.value === 'expanded' ||
overlayState.value === 'empty' ||
(overlayState.value === 'active' && isOverlayHovered.value)
)
@@ -169,11 +155,34 @@ const bottomRowClass = computed(
: 'opacity-0 pointer-events-none'
}`
)
const headerTitle = computed(() =>
hasActiveJob.value
? `${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
: t('sideToolbar.queueProgressOverlay.jobQueue')
const runningJobsLabel = computed(() =>
t('sideToolbar.queueProgressOverlay.runningJobsLabel', {
count: n(runningCount.value)
})
)
const queuedJobsLabel = computed(() =>
t('sideToolbar.queueProgressOverlay.queuedJobsLabel', {
count: n(queuedCount.value)
})
)
const headerTitle = computed(() => {
if (!hasActiveJob.value) {
return t('sideToolbar.queueProgressOverlay.jobQueue')
}
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 concurrentWorkflowCount = computed(
() => executionStore.runningWorkflowCount
@@ -230,19 +239,10 @@ const setExpanded = (expanded: boolean) => {
isExpanded.value = expanded
}
const openExpandedFromEmpty = () => {
setExpanded(true)
}
const viewAllJobs = () => {
setExpanded(true)
}
const onSummaryClick = () => {
openExpandedFromEmpty()
clearSummary()
}
const openAssetsSidebar = () => {
sidebarTabStore.activeSidebarTabId = 'assets'
}

View File

@@ -0,0 +1,79 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent } from 'vue'
vi.mock('primevue/popover', () => {
const PopoverStub = defineComponent({
name: 'Popover',
setup(_, { slots, expose }) {
expose({
hide: () => undefined,
toggle: (_event: Event) => undefined
})
return () => slots.default?.()
}
})
return { default: PopoverStub }
})
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
import JobFiltersBar from '@/components/queue/job/JobFiltersBar.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
all: 'All',
completed: 'Completed'
},
queue: {
jobList: {
sortMostRecent: 'Most recent',
sortTotalGenerationTime: 'Total generation time'
}
},
sideToolbar: {
queueProgressOverlay: {
filterJobs: 'Filter jobs',
filterBy: 'Filter by',
sortJobs: 'Sort jobs',
sortBy: 'Sort by',
showAssets: 'Show assets',
showAssetsPanel: 'Show assets panel',
filterAllWorkflows: 'All workflows',
filterCurrentWorkflow: 'Current workflow'
}
}
}
}
})
describe('JobFiltersBar', () => {
it('emits showAssets when the assets icon button is clicked', async () => {
const wrapper = mount(JobFiltersBar, {
props: {
selectedJobTab: 'All',
selectedWorkflowFilter: 'all',
selectedSortMode: 'mostRecent',
hasFailedJobs: false
},
global: {
plugins: [i18n],
directives: { tooltip: () => undefined }
}
})
const showAssetsButton = wrapper.get(
'button[aria-label="Show assets panel"]'
)
await showAssetsButton.trigger('click')
expect(wrapper.emitted('showAssets')).toHaveLength(1)
})
})

View File

@@ -127,6 +127,15 @@
</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>
</div>
</template>
@@ -150,6 +159,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
(e: 'showAssets'): void
(e: 'update:selectedJobTab', value: JobTab): void
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
(e: 'update:selectedSortMode', value: JobSortMode): void
@@ -165,6 +175,9 @@ const filterTooltipConfig = computed(() =>
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

View File

@@ -14,10 +14,12 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import TabInfo from './info/TabInfo.vue'
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
@@ -31,12 +33,16 @@ import {
useFlatAndCategorizeSelectedItems
} from './shared'
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionStore)
const { findParentGroup } = useGraphHierarchy()
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
@@ -87,11 +93,42 @@ function closePanel() {
type RightSidePanelTabList = Array<{
label: () => string
value: RightSidePanelTab
icon?: string
}>
const hasDirectNodeError = computed(() =>
selectedNodes.value.some((node) =>
executionStore.activeGraphErrorNodeIds.has(String(node.id))
)
)
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)
})
})
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
return hasDirectNodeError.value || hasContainerInternalError.value
})
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []
if (
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
hasRelevantErrors.value
) {
list.push({
label: () => t('rightSidePanel.errors'),
value: 'errors',
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
})
}
list.push({
label: () =>
flattedItems.value.length > 1
@@ -271,6 +308,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
:value="tab.value"
>
{{ tab.label() }}
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
</Tab>
</TabList>
</nav>
@@ -278,7 +316,8 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
<!-- Panel Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<template v-if="!hasSelection">
<TabErrors v-if="activeTab === 'errors'" />
<template v-else-if="!hasSelection">
<TabGlobalParameters v-if="activeTab === 'parameters'" />
<TabNodes v-else-if="activeTab === 'nodes'" />
<TabGlobalSettings v-else-if="activeTab === 'settings'" />

View File

@@ -0,0 +1,162 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
/**
* ErrorNodeCard displays a single error card inside the error tab.
* It shows the node header (ID badge, title, action buttons)
* and the list of error items (message, traceback, copy button).
*/
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
component: ErrorNodeCard,
parameters: {
layout: 'centered'
},
argTypes: {
showNodeIdBadge: { control: 'boolean' }
},
decorators: [
(story) => ({
components: { story },
template:
'<div class="w-[330px] bg-base-surface border border-interface-stroke rounded-lg p-4"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
const singleErrorCard: ErrorCardData = {
id: 'node-10',
title: 'CLIPTextEncode',
nodeId: '10',
nodeTitle: 'CLIP Text Encode (Prompt)',
isSubgraphNode: false,
errors: [
{
message: 'Required input "text" is missing.',
details: 'Input: text\nExpected: STRING'
}
]
}
const multipleErrorsCard: ErrorCardData = {
id: 'node-24',
title: 'VAEDecode',
nodeId: '24',
nodeTitle: 'VAE Decode',
isSubgraphNode: false,
errors: [
{
message: 'Required input "samples" is missing.',
details: ''
},
{
message: 'Value "NaN" is not a valid number for "strength".',
details: 'Expected: FLOAT [0.0 .. 1.0]'
}
]
}
const runtimeErrorCard: ErrorCardData = {
id: 'exec-45',
title: 'KSampler',
nodeId: '45',
nodeTitle: 'KSampler',
isSubgraphNode: false,
errors: [
{
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
details: [
'Traceback (most recent call last):',
' File "ksampler.py", line 142, in sample',
' samples = model.apply(latent)',
'RuntimeError: CUDA out of memory.'
].join('\n'),
isRuntimeError: true
}
]
}
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: '3:15',
nodeTitle: 'Nested KSampler',
isSubgraphNode: true,
errors: [
{
message: 'Latent input is required.',
details: ''
}
]
}
const promptOnlyCard: ErrorCardData = {
id: '__prompt__',
title: 'Prompt has no outputs.',
errors: [
{
message:
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
}
]
}
/** Single validation error with node ID badge visible */
export const WithNodeIdBadge: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
}
}
/** Single validation error without node ID badge */
export const WithoutNodeIdBadge: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: false
}
}
/** Subgraph node error — shows "Enter subgraph" button */
export const WithEnterSubgraphButton: Story = {
args: {
card: subgraphErrorCard,
showNodeIdBadge: true
}
}
/** Regular node error — no "Enter subgraph" button */
export const WithoutEnterSubgraphButton: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
}
}
/** Multiple validation errors on one node */
export const MultipleErrors: Story = {
args: {
card: multipleErrorsCard,
showNodeIdBadge: true
}
}
/** Runtime execution error with full traceback */
export const RuntimeError: Story = {
args: {
card: runtimeErrorCard,
showNodeIdBadge: true
}
}
/** Prompt-level error (no node header) */
export const PromptError: Story = {
args: {
card: promptOnlyCard,
showNodeIdBadge: false
}
}

View File

@@ -0,0 +1,132 @@
<template>
<div class="overflow-hidden">
<!-- Card Header -->
<div
v-if="card.nodeId && !compact"
class="flex flex-wrap items-center gap-2 py-2"
>
<span
v-if="showNodeIdBadge"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold"
>
#{{ card.nodeId }}
</span>
<span
v-if="card.nodeTitle"
class="flex-1 text-sm text-muted-foreground truncate font-medium"
>
{{ card.nodeTitle }}
</span>
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="rounded-lg text-sm shrink-0"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
>
<i class="icon-[lucide--locate] size-3.5" />
</Button>
</div>
<!-- Multiple Errors within one Card -->
<div class="divide-y divide-interface-stroke/20 space-y-4">
<!-- Card Content -->
<div
v-for="(error, idx) in card.errors"
:key="idx"
class="flex flex-col gap-3"
>
<!-- Error Message -->
<p
v-if="error.message && !compact"
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5"
>
{{ error.message }}
</p>
<!-- Traceback / Details -->
<div
v-if="error.details"
:class="
cn(
'rounded-lg bg-secondary-background-hover p-2.5 overflow-y-auto border border-interface-stroke/30',
error.isRuntimeError ? 'max-h-[10lh]' : 'max-h-[6lh]'
)
"
>
<p
class="m-0 text-xs text-muted-foreground break-words whitespace-pre-wrap font-mono leading-relaxed"
>
{{ error.details }}
</p>
</div>
<Button
variant="secondary"
size="sm"
class="w-full justify-center gap-2 h-8 text-xs"
@click="handleCopyError(error)"
>
<i class="icon-[lucide--copy] size-3.5" />
{{ t('g.copy') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import type { ErrorCardData, ErrorItem } from './types'
const {
card,
showNodeIdBadge = false,
compact = false
} = defineProps<{
card: ErrorCardData
showNodeIdBadge?: boolean
/** Hide card header and error message (used in single-node selection mode) */
compact?: boolean
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
enterSubgraph: [nodeId: string]
copyToClipboard: [text: string]
}>()
const { t } = useI18n()
function handleLocateNode() {
if (card.nodeId) {
emit('locateNode', card.nodeId)
}
}
function handleEnterSubgraph() {
if (card.nodeId) {
emit('enterSubgraph', card.nodeId)
}
}
function handleCopyError(error: ErrorItem) {
emit(
'copyToClipboard',
[error.message, error.details].filter(Boolean).join('\n\n')
)
}
</script>

View File

@@ -0,0 +1,137 @@
/**
* @file TabErrors.stories.ts
*
* Error Tab Missing Node Packs UX Flow Stories (OSS environment)
*/
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StoryOSSMissingNodePackFlow from './__stories__/StoryOSSMissingNodePackFlow.vue'
import MockOSSMissingNodePack from './__stories__/MockOSSMissingNodePack.vue'
import MockCloudMissingNodePack from './__stories__/MockCloudMissingNodePack.vue'
import MockCloudMissingModel from './__stories__/MockCloudMissingModel.vue'
import MockCloudMissingModelBasic from './__stories__/MockCloudMissingModelBasic.vue'
import MockOSSMissingModel from './__stories__/MockOSSMissingModel.vue'
// Storybook Meta
const meta = {
title: 'RightSidePanel/Errors/TabErrors',
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: `
## Error Tab Missing Node Packs UX Flow (OSS environment)
### Right Panel Structure
- **Nav Item**: "Workflow Overview" + panel-right button
- **Tab bar**: Error (octagon-alert icon) | Inputs | Nodes | Global settings
- **Search bar**: 12px, #8a8a8a placeholder
- **Missing Node Packs section**: octagon-alert (red) + label + Install All + chevron
- **Each widget row** (72px): name (truncate) + info + locate | Install node pack ↓
> In Cloud environments, the Install button is not displayed.
`
}
}
}
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
// Stories
/**
* **[Local OSS] Missing Node Packs**
*
* A standalone story for the Right Side Panel's Error Tab mockup.
* This allows testing the tab's interactions (install, locate, etc.) in isolation.
*/
export const OSS_ErrorTabOnly: Story = {
name: '[Local OSS] Missing Node Packs',
render: () => ({
components: { MockOSSMissingNodePack },
template: `
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
<MockOSSMissingNodePack @log="msg => console.log('Log:', msg)" />
</div>
`
})
}
/**
* **[Local OSS] UX Flow - Missing Node Pack**
*
* Full ComfyUI layout simulation:
*/
export const OSS_MissingNodePacksFullFlow: Story = {
name: '[Local OSS] UX Flow - Missing Node Pack',
render: () => ({
components: { StoryOSSMissingNodePackFlow },
template: `<div style="width:100vw;height:100vh;"><StoryOSSMissingNodePackFlow /></div>`
}),
parameters: {
layout: 'fullscreen'
}
}
/**
* **[Cloud] Missing Node Pack**
*/
export const Cloud_MissingNodePacks: Story = {
name: '[Cloud] Missing Node Pack',
render: () => ({
components: { MockCloudMissingNodePack },
template: `
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
<MockCloudMissingNodePack @log="msg => console.log('Log:', msg)" />
</div>
`
})
}
/**
* **[Local OSS] Missing Model**
*/
export const OSS_MissingModels: Story = {
name: '[Local OSS] Missing Model',
render: () => ({
components: { MockOSSMissingModel },
template: `
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
<MockOSSMissingModel @locate="name => console.log('Locate:', name)" />
</div>
`
})
}
/**
* **[Cloud] Missing Model**
*/
export const Cloud_MissingModels: Story = {
name: '[Cloud] Missing Model',
render: () => ({
components: { MockCloudMissingModelBasic },
template: `
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
<MockCloudMissingModelBasic @locate="name => console.log('Locate:', name)" />
</div>
`
})
}
/**
* **[Cloud] Missing Model - with model type selector**
*/
export const Cloud_MissingModelsWithSelector: Story = {
name: '[Cloud] Missing Model - with model type selector',
render: () => ({
components: { MockCloudMissingModel },
template: `
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
<MockCloudMissingModel @locate="name => console.log('Locate:', name)" />
</div>
`
})
}

View File

@@ -0,0 +1,218 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
// Mock dependencies
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
forEachNode: vi.fn()
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: vi.fn(() => ({
copyToClipboard: vi.fn()
}))
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: vi.fn(() => ({
fitView: vi.fn()
}))
}))
describe('TabErrors.vue', () => {
let i18n: ReturnType<typeof createI18n>
beforeEach(() => {
i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
workflow: 'Workflow',
copy: 'Copy'
},
rightSidePanel: {
noErrors: 'No errors',
noneSearchDesc: 'No results found',
promptErrors: {
prompt_no_outputs: {
desc: 'Prompt has no outputs'
}
}
}
}
}
})
})
function mountComponent(initialState = {}) {
return mount(TabErrors, {
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState
})
],
stubs: {
FormSearchInput: {
template:
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
},
PropertiesAccordionItem: {
template: '<div><slot name="label" /><slot /></div>'
},
Button: {
template: '<button><slot /></button>'
}
}
}
})
}
it('renders "no errors" state when store is empty', () => {
const wrapper = mountComponent()
expect(wrapper.text()).toContain('No errors')
})
it('renders prompt-level errors (Group title = error message)', async () => {
const wrapper = mountComponent({
execution: {
lastPromptError: {
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
details: 'Error details'
}
}
})
// Group title should be the raw message from store
expect(wrapper.text()).toContain('Server Error: No outputs')
// Item message should be localized desc
expect(wrapper.text()).toContain('Prompt has no outputs')
// Details should not be rendered for prompt errors
expect(wrapper.text()).not.toContain('Error details')
})
it('renders node validation errors grouped by class_type', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'CLIP Text Encode'
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
lastNodeErrors: {
'6': {
class_type: 'CLIPTextEncode',
errors: [
{ message: 'Required input is missing', details: 'Input: text' }
]
}
}
}
})
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).toContain('#6')
expect(wrapper.text()).toContain('CLIP Text Encode')
expect(wrapper.text()).toContain('Required input is missing')
})
it('renders runtime execution errors from WebSocket', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '10',
node_type: 'KSampler',
exception_message: 'Out of memory',
exception_type: 'RuntimeError',
traceback: ['Line 1', 'Line 2'],
timestamp: Date.now()
}
}
})
expect(wrapper.text()).toContain('KSampler')
expect(wrapper.text()).toContain('#10')
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
expect(wrapper.text()).toContain('Line 1')
})
it('filters errors based on search query', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
const wrapper = mountComponent({
execution: {
lastNodeErrors: {
'1': {
class_type: 'CLIPTextEncode',
errors: [{ message: 'Missing text input' }]
},
'2': {
class_type: 'KSampler',
errors: [{ message: 'Out of memory' }]
}
}
}
})
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).toContain('KSampler')
const searchInput = wrapper.find('input')
await searchInput.setValue('Missing text input')
expect(wrapper.text()).toContain('CLIPTextEncode')
expect(wrapper.text()).not.toContain('KSampler')
})
it('calls copyToClipboard when copy button is clicked', async () => {
const { useCopyToClipboard } =
await import('@/composables/useCopyToClipboard')
const mockCopy = vi.fn()
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
const wrapper = mountComponent({
execution: {
lastNodeErrors: {
'1': {
class_type: 'TestNode',
errors: [{ message: 'Test message', details: 'Test details' }]
}
}
}
})
// Find the copy button (rendered inside ErrorNodeCard)
const copyButtons = wrapper.findAll('button')
const copyButton = copyButtons.find((btn) => btn.text().includes('Copy'))
expect(copyButton).toBeTruthy()
await copyButton!.trigger('click')
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
})

View File

@@ -0,0 +1,164 @@
<template>
<div class="flex flex-col h-full min-w-0">
<!-- Search bar -->
<div
class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke shrink-0 min-w-0"
>
<FormSearchInput v-model="searchQuery" />
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto min-w-0">
<div
v-if="filteredGroups.length === 0"
class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15"
>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<div v-else>
<!-- Group by Class Type -->
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.title"
:collapse="collapseState[group.title] ?? false"
class="border-b border-interface-stroke"
@update:collapse="collapseState[group.title] = $event"
>
<template #label>
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="flex-1 flex items-center gap-2 min-w-0">
<i
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
/>
<span class="text-destructive-background-hover truncate">
{{ group.title }}
</span>
<span
v-if="group.cards.length > 1"
class="text-destructive-background-hover"
>
({{ group.cards.length }})
</span>
</span>
</div>
</template>
<!-- Cards in Group (default slot) -->
<div class="px-4 space-y-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:show-node-id-badge="showNodeIdBadge"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
</PropertiesAccordionItem>
</div>
</div>
<!-- Fixed Footer: Help Links -->
<div class="shrink-0 border-t border-interface-stroke p-4 min-w-0">
<i18n-t
keypath="rightSidePanel.errorHelp"
tag="p"
class="m-0 text-sm text-muted-foreground leading-tight break-words"
>
<template #github>
<Button
variant="textonly"
size="unset"
class="inline underline text-inherit text-sm whitespace-nowrap"
@click="openGitHubIssues"
>
{{ t('rightSidePanel.errorHelpGithub') }}
</Button>
</template>
<template #support>
<Button
variant="textonly"
size="unset"
class="inline underline text-inherit text-sm whitespace-nowrap"
@click="contactSupport"
>
{{ t('rightSidePanel.errorHelpSupport') }}
</Button>
</template>
</i18n-t>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import Button from '@/components/ui/button/Button.vue'
import { useErrorGroups } from './useErrorGroups'
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { staticUrls } = useExternalLink()
const settingStore = useSettingStore()
const searchQuery = ref('')
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
NodeBadgeMode.None
)
const { filteredGroups, collapseState, isSingleNodeSelected, errorNodeCache } =
useErrorGroups(searchQuery, t)
function handleLocateNode(nodeId: string) {
focusNode(nodeId, errorNodeCache.value)
}
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
async function contactSupport() {
useTelemetry()?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
try {
await useCommandStore().execute('Comfy.ContactSupport')
} catch (error) {
console.error(error)
useToastStore().addAlert(t('rightSidePanel.contactSupportFailed'))
}
}
</script>

View File

@@ -0,0 +1,521 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
// Props / Emits
const emit = defineEmits<{
'locate': [name: string],
}>()
// Mock Data
interface MissingModel {
id: string
name: string
type: string
}
const INITIAL_MISSING_MODELS: Record<string, MissingModel[]> = {
'Lora': [
{ id: 'm1', name: 'Flat_color_anime.safetensors', type: 'Lora' },
{ id: 'm2', name: 'Bokeh_blur_xl.safetensors', type: 'Lora' },
{ id: 'm3', name: 'Skin_texture_realism.safetensors', type: 'Lora' }
],
'VAE': [
{ id: 'v1', name: 'vae-ft-mse-840000-ema-pruned.safetensors', type: 'VAE' },
{ id: 'v2', name: 'clear-vae-v1.safetensors', type: 'VAE' }
]
}
const MODEL_TYPES = [
'AnimateDiff Model',
'AnimateDiff Motion LoRA',
'Audio Encoders',
'Chatterbox/chatterbox',
'Chatterbox/chatterbox Multilingual',
'Chatterbox/chatterbox Turbo',
'Chatterbox/chatterbox Vc',
'Checkpoints',
'CLIP Vision'
]
const LIBRARY_MODELS = [
'v1-5-pruned-emaonly.safetensors',
'sd_xl_base_1.0.safetensors',
'dreamshaper_8.safetensors',
'realisticVisionV51_v51VAE.safetensors'
]
// State
const collapsedCategories = ref<Record<string, boolean>>({
'VAE': true
})
// Stores the URL input for each model
const modelInputs = ref<Record<string, string>>({})
// Tracks which models have finished their "revealing" delay
const revealingModels = ref<Record<string, boolean>>({})
const inputTimeouts = ref<Record<string, ReturnType<typeof setTimeout>>>({})
// Model Status: 'idle' | 'downloading' | 'downloaded' | 'using_library'
const importStatus = ref<Record<string, 'idle' | 'downloading' | 'downloaded' | 'using_library'>>({})
const downloadProgress = ref<Record<string, number>>({})
const downloadTimers = ref<Record<string, ReturnType<typeof setInterval>>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
// Track hidden models (removed after clicking check button)
const removedModels = ref<Record<string, boolean>>({})
// Watch for input changes to trigger the 1s delay
watch(modelInputs, (newVal) => {
for (const id in newVal) {
const value = newVal[id]
if (value && !revealingModels.value[id] && !inputTimeouts.value[id]) {
inputTimeouts.value[id] = setTimeout(() => {
revealingModels.value[id] = true
delete inputTimeouts.value[id]
}, 1000)
} else if (!value) {
revealingModels.value[id] = false
if (inputTimeouts.value[id]) {
clearTimeout(inputTimeouts.value[id])
delete inputTimeouts.value[id]
}
}
}
}, { deep: true })
// Compute which categories have at least one visible model
const activeCategories = computed(() => {
const result: Record<string, boolean> = {}
for (const cat in INITIAL_MISSING_MODELS) {
result[cat] = INITIAL_MISSING_MODELS[cat].some(m => !removedModels.value[m.id])
}
return result
})
// Stores the selected type for each model
const selectedModelTypes = ref<Record<string, string>>({})
// Tracks which model's type dropdown is currently open
const activeDropdown = ref<string | null>(null)
// Tracks which model's library dropdown is currently open
const activeLibraryDropdown = ref<string | null>(null)
// Actions
function toggleDropdown(modelId: string) {
activeLibraryDropdown.value = null
if (activeDropdown.value === modelId) {
activeDropdown.value = null
} else {
activeDropdown.value = modelId
}
}
function toggleLibraryDropdown(modelId: string) {
activeDropdown.value = null
if (activeLibraryDropdown.value === modelId) {
activeLibraryDropdown.value = null
} else {
activeLibraryDropdown.value = modelId
}
}
function selectType(modelId: string, type: string) {
selectedModelTypes.value[modelId] = type
activeDropdown.value = null
}
function selectFromLibrary(modelId: string, fileName: string) {
selectedLibraryModel.value[modelId] = fileName
importStatus.value[modelId] = 'using_library'
activeLibraryDropdown.value = null
}
function startImport(modelId: string) {
if (downloadTimers.value[modelId]) {
clearInterval(downloadTimers.value[modelId])
}
importStatus.value[modelId] = 'downloading'
downloadProgress.value[modelId] = 0
const startTime = Date.now()
const duration = 5000
downloadTimers.value[modelId] = setInterval(() => {
const elapsed = Date.now() - startTime
const progress = Math.min((elapsed / duration) * 100, 100)
downloadProgress.value[modelId] = progress
if (progress >= 100) {
clearInterval(downloadTimers.value[modelId])
delete downloadTimers.value[modelId]
importStatus.value[modelId] = 'downloaded'
}
}, 50)
}
function handleCheckClick(modelId: string) {
if (importStatus.value[modelId] === 'downloaded' || importStatus.value[modelId] === 'using_library') {
// Update object with spread to guarantee reactivity trigger
removedModels.value = { ...removedModels.value, [modelId]: true }
}
}
function cancelImport(modelId: string) {
if (downloadTimers.value[modelId]) {
clearInterval(downloadTimers.value[modelId])
delete downloadTimers.value[modelId]
}
importStatus.value[modelId] = 'idle'
downloadProgress.value[modelId] = 0
modelInputs.value[modelId] = ''
selectedLibraryModel.value[modelId] = ''
}
function resetAll() {
// Clear all status records
modelInputs.value = {}
revealingModels.value = {}
// Clear any running timers
for (const id in downloadTimers.value) {
clearInterval(downloadTimers.value[id])
}
downloadTimers.value = {}
for (const id in inputTimeouts.value) {
clearTimeout(inputTimeouts.value[id])
}
inputTimeouts.value = {}
importStatus.value = {}
downloadProgress.value = {}
selectedLibraryModel.value = {}
removedModels.value = {}
activeDropdown.value = null
activeLibraryDropdown.value = null
}
// Helpers
function getElementStyle(el: HTMLElement) {
return {
height: el.style.height,
overflow: el.style.overflow,
paddingTop: el.style.paddingTop,
paddingBottom: el.style.paddingBottom,
marginTop: el.style.marginTop,
marginBottom: el.style.marginBottom
}
}
// Transitions
const DURATION = 150
function enterTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { width } = getComputedStyle(el)
el.style.width = width
el.style.position = 'absolute'
el.style.visibility = 'hidden'
el.style.height = ''
const { height } = getComputedStyle(el)
el.style.position = ''
el.style.visibility = ''
el.style.height = '0px'
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
function leaveTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { height } = getComputedStyle(el)
el.style.height = height
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
</script>
<template>
<div
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
>
<!-- Nav Item -->
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
Workflow Overview
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
<i class="icon-[lucide--panel-right] size-4 text-white" />
</div>
</div>
</div>
<!-- Node Header -->
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
<span class="text-sm text-white">Error</span>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Inputs</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Nodes</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
</div>
</div>
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
Search for nodes or inputs
</p>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
<!-- Content: Missing Models -->
<div class="flex-1 overflow-y-auto min-w-0 no-scrollbar">
<template v-for="(models, category) in INITIAL_MISSING_MODELS" :key="category">
<div
v-if="activeCategories[category]"
class="px-4 mb-4"
>
<!-- Category Header -->
<div
class="flex h-8 items-center justify-center w-full group"
:class="category === 'VAE' ? 'cursor-default' : 'cursor-pointer'"
@click="category !== 'VAE' && (collapsedCategories[category] = !collapsedCategories[category])"
>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">
{{ category }} ({{ models.filter(m => !removedModels[m.id]).length }})
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0">
<i
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] transition-all"
:class="[
category !== 'VAE' ? 'group-hover:text-white' : '',
collapsedCategories[category] ? '-rotate-180' : ''
]"
/>
</div>
</div>
<!-- Model List -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!collapsedCategories[category]" class="pt-2">
<TransitionGroup :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-for="model in models" v-show="!removedModels[model.id]" :key="model.id" class="flex flex-col w-full mb-6 last:mb-4">
<!-- Model Header (Always visible) -->
<div class="flex h-8 items-center w-full gap-2 mb-1">
<i class="icon-[lucide--file-check] size-4 text-white shrink-0" />
<p class="flex-1 min-w-0 text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap">
{{ model.name }}
</p>
<!-- Check Button (Highlights blue when downloaded or using from library) -->
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 transition-colors"
:class="[
(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library')
? 'cursor-pointer hover:bg-[#1e2d3d] bg-[#1e2d3d]'
: 'opacity-20 cursor-default'
]"
@click="handleCheckClick(model.id)"
>
<i
class="icon-[lucide--check] size-4"
:class="(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library') ? 'text-[#3b82f6]' : 'text-white'"
/>
</div>
<!-- Locate Button -->
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
@click="emit('locate', model.name)"
>
<i class="icon-[lucide--locate] size-4 text-white" />
</div>
</div>
<!-- Input or Progress Area -->
<div class="relative mt-1">
<!-- CARD (Download or Library Substitute) -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div
v-if="importStatus[model.id] && importStatus[model.id] !== 'idle'"
class="relative bg-white/5 border border-[#55565e] rounded-lg overflow-hidden flex items-center p-2 gap-2"
>
<!-- Progress Filling (Only while downloading) -->
<div
v-if="importStatus[model.id] === 'downloading'"
class="absolute inset-y-0 left-0 bg-[#3b82f6]/10 transition-all duration-100 ease-linear pointer-events-none"
:style="{ width: downloadProgress[model.id] + '%' }"
/>
<!-- Left Icon -->
<div class="relative z-10 size-[32px] flex items-center justify-center shrink-0">
<i class="icon-[lucide--file-check] size-5 text-[#8a8a8a]" />
</div>
<!-- Center Text -->
<div class="relative z-10 flex-1 min-w-0 flex flex-col justify-center">
<span class="text-[12px] font-medium text-white truncate leading-tight">
{{ importStatus[model.id] === 'using_library' ? selectedLibraryModel[model.id] : model.name }}
</span>
<span class="text-[12px] text-[#8a8a8a] leading-tight mt-0.5">
<template v-if="importStatus[model.id] === 'downloading'">Importing ...</template>
<template v-else-if="importStatus[model.id] === 'downloaded'">Imported</template>
<template v-else-if="importStatus[model.id] === 'using_library'">Using from Library</template>
</span>
</div>
<!-- Cancel (X) Button (Always visible in this card) -->
<div
class="relative z-10 size-6 flex items-center justify-center text-[#55565e] hover:text-white cursor-pointer transition-colors shrink-0"
@click="cancelImport(model.id)"
>
<i class="icon-[lucide--circle-x] size-4" />
</div>
</div>
</Transition>
<!-- IDLE / INPUT AREA -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!importStatus[model.id] || importStatus[model.id] === 'idle'" class="flex flex-col gap-2">
<!-- URL Input Area -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!selectedLibraryModel[model.id]" class="flex flex-col gap-2">
<div class="h-8 bg-[#262729] rounded-lg flex items-center px-3 border border-transparent focus-within:border-[#494a50] transition-colors whitespace-nowrap overflow-hidden">
<input
v-model="modelInputs[model.id]"
type="text"
placeholder="Paste Model URL (Civitai or Hugging Face)"
class="bg-transparent border-none outline-none text-xs text-white w-full placeholder:text-[#8a8a8a]"
/>
</div>
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="revealingModels[model.id]" class="flex flex-col gap-2">
<div class="px-0.5 pt-0.5 whitespace-nowrap overflow-hidden text-ellipsis">
<span class="text-xs font-bold text-white">something_model.safetensors</span>
</div>
<div class="flex items-center gap-1.5 px-0.5">
<span class="text-[11px] text-[#8a8a8a]">What type of model is this?</span>
<i class="icon-[lucide--help-circle] size-3 text-[#55565e]" />
<span class="text-[11px] text-[#55565e]">Not sure? Just leave this as is</span>
</div>
<div class="relative">
<div class="h-9 bg-[#262729] rounded-lg flex items-center px-3 border border-transparent cursor-pointer hover:bg-[#303133]" @click="toggleDropdown(model.id)">
<span class="flex-1 text-xs text-[#8a8a8a]">{{ selectedModelTypes[model.id] || 'Select model type' }}</span>
<i class="icon-[lucide--chevron-down] size-4 text-[#8a8a8a]" />
</div>
<div v-if="activeDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
<div v-for="type in MODEL_TYPES" :key="type" class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer" @click="selectType(model.id, type)">
{{ type }}
</div>
</div>
</div>
<div class="pt-0.5">
<Button variant="primary" class="w-full h-9 justify-center gap-2 text-sm font-semibold" @click="startImport(model.id)">
<i class="icon-[lucide--download] size-4" /> Import
</Button>
</div>
</div>
</Transition>
<!-- OR / Library Dropdown -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!modelInputs[model.id]" class="flex flex-col gap-2">
<div class="flex items-center justify-center py-0.5 font-bold text-[10px] text-[#8a8a8a]">OR</div>
<div class="relative">
<div
class="h-8 bg-[#262729] rounded-lg flex items-center px-3 cursor-pointer group/lib hover:border-[#494a50] border border-transparent"
@click="toggleLibraryDropdown(model.id)"
>
<span class="flex-1 text-xs text-white truncate">Use from Library</span>
<i class="icon-[lucide--chevron-down] size-3.5 text-[#8a8a8a] group-hover/lib:text-white" />
</div>
<!-- Library Dropdown Menu -->
<div v-if="activeLibraryDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
<div
v-for="libModel in LIBRARY_MODELS"
:key="libModel"
class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer flex items-center gap-2"
@click="selectFromLibrary(model.id, libModel)"
>
<i class="icon-[lucide--file-code] size-3.5 text-[#8a8a8a]" />
{{ libModel }}
</div>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</div>
</Transition>
</div>
</div>
</TransitionGroup>
</div>
</Transition>
<!-- Bottom Divider -->
<div class="-mx-4 mt-6 h-px bg-[#55565e]" />
</div>
</template>
<!-- Reset Button (Convenience for Storybook testing) -->
<div v-if="Object.keys(removedModels).length > 0" class="flex justify-center py-8">
<Button variant="muted-textonly" class="text-xs gap-2 hover:text-white" @click="resetAll">
<i class="icon-[lucide--rotate-ccw] size-3.5" />
Reset Storybook Flow
</Button>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
</div>
</template>
<style scoped>
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View File

@@ -0,0 +1,472 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
// Props / Emits
const emit = defineEmits<{
'locate': [name: string],
}>()
// Mock Data
interface MissingModel {
id: string
name: string
type: string
}
const INITIAL_MISSING_MODELS: Record<string, MissingModel[]> = {
'Lora': [
{ id: 'm1', name: 'Flat_color_anime.safetensors', type: 'Lora' },
{ id: 'm2', name: 'Bokeh_blur_xl.safetensors', type: 'Lora' },
{ id: 'm3', name: 'Skin_texture_realism.safetensors', type: 'Lora' }
],
'VAE': [
{ id: 'v1', name: 'vae-ft-mse-840000-ema-pruned.safetensors', type: 'VAE' },
{ id: 'v2', name: 'clear-vae-v1.safetensors', type: 'VAE' }
]
}
const LIBRARY_MODELS = [
'v1-5-pruned-emaonly.safetensors',
'sd_xl_base_1.0.safetensors',
'dreamshaper_8.safetensors',
'realisticVisionV51_v51VAE.safetensors'
]
// State
const collapsedCategories = ref<Record<string, boolean>>({
'VAE': true
})
// Stores the URL input for each model
const modelInputs = ref<Record<string, string>>({})
// Tracks which models have finished their "revealing" delay
const revealingModels = ref<Record<string, boolean>>({})
const inputTimeouts = ref<Record<string, ReturnType<typeof setTimeout>>>({})
// Model Status: 'idle' | 'downloading' | 'downloaded' | 'using_library'
const importStatus = ref<Record<string, 'idle' | 'downloading' | 'downloaded' | 'using_library'>>({})
const downloadProgress = ref<Record<string, number>>({})
const downloadTimers = ref<Record<string, ReturnType<typeof setInterval>>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
// Track hidden models (removed after clicking check button)
const removedModels = ref<Record<string, boolean>>({})
// Watch for input changes to trigger the 1s delay
watch(modelInputs, (newVal) => {
for (const id in newVal) {
const value = newVal[id]
if (value && !revealingModels.value[id] && !inputTimeouts.value[id]) {
inputTimeouts.value[id] = setTimeout(() => {
revealingModels.value[id] = true
delete inputTimeouts.value[id]
}, 1000)
} else if (!value) {
revealingModels.value[id] = false
if (inputTimeouts.value[id]) {
clearTimeout(inputTimeouts.value[id])
delete inputTimeouts.value[id]
}
}
}
}, { deep: true })
// Compute which categories have at least one visible model
const activeCategories = computed(() => {
const result: Record<string, boolean> = {}
for (const cat in INITIAL_MISSING_MODELS) {
result[cat] = INITIAL_MISSING_MODELS[cat].some(m => !removedModels.value[m.id])
}
return result
})
// Tracks which model's library dropdown is currently open
const activeLibraryDropdown = ref<string | null>(null)
// Actions
function toggleLibraryDropdown(modelId: string) {
if (activeLibraryDropdown.value === modelId) {
activeLibraryDropdown.value = null
} else {
activeLibraryDropdown.value = modelId
}
}
function selectFromLibrary(modelId: string, fileName: string) {
selectedLibraryModel.value[modelId] = fileName
importStatus.value[modelId] = 'using_library'
activeLibraryDropdown.value = null
}
function startImport(modelId: string) {
if (downloadTimers.value[modelId]) {
clearInterval(downloadTimers.value[modelId])
}
importStatus.value[modelId] = 'downloading'
downloadProgress.value[modelId] = 0
const startTime = Date.now()
const duration = 5000
downloadTimers.value[modelId] = setInterval(() => {
const elapsed = Date.now() - startTime
const progress = Math.min((elapsed / duration) * 100, 100)
downloadProgress.value[modelId] = progress
if (progress >= 100) {
clearInterval(downloadTimers.value[modelId])
delete downloadTimers.value[modelId]
importStatus.value[modelId] = 'downloaded'
}
}, 50)
}
function handleCheckClick(modelId: string) {
if (importStatus.value[modelId] === 'downloaded' || importStatus.value[modelId] === 'using_library') {
// Update object with spread to guarantee reactivity trigger
removedModels.value = { ...removedModels.value, [modelId]: true }
}
}
function cancelImport(modelId: string) {
if (downloadTimers.value[modelId]) {
clearInterval(downloadTimers.value[modelId])
delete downloadTimers.value[modelId]
}
importStatus.value[modelId] = 'idle'
downloadProgress.value[modelId] = 0
modelInputs.value[modelId] = ''
selectedLibraryModel.value[modelId] = ''
}
function resetAll() {
// Clear all status records
modelInputs.value = {}
revealingModels.value = {}
// Clear any running timers
for (const id in downloadTimers.value) {
clearInterval(downloadTimers.value[id])
}
downloadTimers.value = {}
for (const id in inputTimeouts.value) {
clearTimeout(inputTimeouts.value[id])
}
inputTimeouts.value = {}
importStatus.value = {}
downloadProgress.value = {}
selectedLibraryModel.value = {}
removedModels.value = {}
activeLibraryDropdown.value = null
}
// Helpers
function getElementStyle(el: HTMLElement) {
return {
height: el.style.height,
overflow: el.style.overflow,
paddingTop: el.style.paddingTop,
paddingBottom: el.style.paddingBottom,
marginTop: el.style.marginTop,
marginBottom: el.style.marginBottom
}
}
// Transitions
const DURATION = 150
function enterTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { width } = getComputedStyle(el)
el.style.width = width
el.style.position = 'absolute'
el.style.visibility = 'hidden'
el.style.height = ''
const { height } = getComputedStyle(el)
el.style.position = ''
el.style.visibility = ''
el.style.height = '0px'
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
function leaveTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { height } = getComputedStyle(el)
el.style.height = height
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
</script>
<template>
<div
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
>
<!-- Nav Item -->
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
Workflow Overview
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
<i class="icon-[lucide--panel-right] size-4 text-white" />
</div>
</div>
</div>
<!-- Node Header -->
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
<span class="text-sm text-white">Error</span>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Inputs</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Nodes</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
</div>
</div>
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
Search for nodes or inputs
</p>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
<!-- Content: Missing Models -->
<div class="flex-1 overflow-y-auto min-w-0 no-scrollbar">
<template v-for="(models, category) in INITIAL_MISSING_MODELS" :key="category">
<div
v-if="activeCategories[category]"
class="px-4 mb-4"
>
<!-- Category Header -->
<div
class="flex h-8 items-center justify-center w-full group"
:class="category === 'VAE' ? 'cursor-default' : 'cursor-pointer'"
@click="category !== 'VAE' && (collapsedCategories[category] = !collapsedCategories[category])"
>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">
{{ category }} ({{ models.filter(m => !removedModels[m.id]).length }})
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0">
<i
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] transition-all"
:class="[
category !== 'VAE' ? 'group-hover:text-white' : '',
collapsedCategories[category] ? '-rotate-180' : ''
]"
/>
</div>
</div>
<!-- Model List -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!collapsedCategories[category]" class="pt-2">
<TransitionGroup :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-for="model in models" v-show="!removedModels[model.id]" :key="model.id" class="flex flex-col w-full mb-6 last:mb-4">
<!-- Model Header (Always visible) -->
<div class="flex h-8 items-center w-full gap-2 mb-1">
<i class="icon-[lucide--file-check] size-4 text-white shrink-0" />
<p class="flex-1 min-w-0 text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap">
{{ model.name }}
</p>
<!-- Check Button (Highlights blue when downloaded or using from library) -->
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 transition-colors"
:class="[
(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library')
? 'cursor-pointer hover:bg-[#1e2d3d] bg-[#1e2d3d]'
: 'opacity-20 cursor-default'
]"
@click="handleCheckClick(model.id)"
>
<i
class="icon-[lucide--check] size-4"
:class="(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library') ? 'text-[#3b82f6]' : 'text-white'"
/>
</div>
<!-- Locate Button -->
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
@click="emit('locate', model.name)"
>
<i class="icon-[lucide--locate] size-4 text-white" />
</div>
</div>
<!-- Input or Progress Area -->
<div class="relative mt-1">
<!-- CARD (Download or Library Substitute) -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div
v-if="importStatus[model.id] && importStatus[model.id] !== 'idle'"
class="relative bg-white/5 border border-[#55565e] rounded-lg overflow-hidden flex items-center p-2 gap-2"
>
<!-- Progress Filling (Only while downloading) -->
<div
v-if="importStatus[model.id] === 'downloading'"
class="absolute inset-y-0 left-0 bg-[#3b82f6]/10 transition-all duration-100 ease-linear pointer-events-none"
:style="{ width: downloadProgress[model.id] + '%' }"
/>
<!-- Left Icon -->
<div class="relative z-10 size-[32px] flex items-center justify-center shrink-0">
<i class="icon-[lucide--file-check] size-5 text-[#8a8a8a]" />
</div>
<!-- Center Text -->
<div class="relative z-10 flex-1 min-w-0 flex flex-col justify-center">
<span class="text-[12px] font-medium text-white truncate leading-tight">
{{ importStatus[model.id] === 'using_library' ? selectedLibraryModel[model.id] : model.name }}
</span>
<span class="text-[12px] text-[#8a8a8a] leading-tight mt-0.5">
<template v-if="importStatus[model.id] === 'downloading'">Importing ...</template>
<template v-else-if="importStatus[model.id] === 'downloaded'">Imported</template>
<template v-else-if="importStatus[model.id] === 'using_library'">Using from Library</template>
</span>
</div>
<!-- Cancel (X) Button (Always visible in this card) -->
<div
class="relative z-10 size-6 flex items-center justify-center text-[#55565e] hover:text-white cursor-pointer transition-colors shrink-0"
@click="cancelImport(model.id)"
>
<i class="icon-[lucide--circle-x] size-4" />
</div>
</div>
</Transition>
<!-- IDLE / INPUT AREA -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!importStatus[model.id] || importStatus[model.id] === 'idle'" class="flex flex-col gap-2">
<!-- URL Input Area -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!selectedLibraryModel[model.id]" class="flex flex-col gap-2">
<div class="h-8 bg-[#262729] rounded-lg flex items-center px-3 border border-transparent focus-within:border-[#494a50] transition-colors whitespace-nowrap overflow-hidden">
<input
v-model="modelInputs[model.id]"
type="text"
placeholder="Paste Model URL (Civitai or Hugging Face)"
class="bg-transparent border-none outline-none text-xs text-white w-full placeholder:text-[#8a8a8a]"
/>
</div>
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="revealingModels[model.id]" class="flex flex-col gap-2">
<div class="px-0.5 pt-0.5 whitespace-nowrap overflow-hidden text-ellipsis">
<span class="text-xs font-bold text-white">something_model.safetensors</span>
</div>
<div class="pt-0.5">
<Button variant="primary" class="w-full h-9 justify-center gap-2 text-sm font-semibold" @click="startImport(model.id)">
<i class="icon-[lucide--download] size-4" /> Import
</Button>
</div>
</div>
</Transition>
<!-- OR / Library Dropdown -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!modelInputs[model.id]" class="flex flex-col gap-2">
<div class="flex items-center justify-center py-0.5 font-bold text-[10px] text-[#8a8a8a]">OR</div>
<div class="relative">
<div
class="h-8 bg-[#262729] rounded-lg flex items-center px-3 cursor-pointer group/lib hover:border-[#494a50] border border-transparent"
@click="toggleLibraryDropdown(model.id)"
>
<span class="flex-1 text-xs text-white truncate">Use from Library</span>
<i class="icon-[lucide--chevron-down] size-3.5 text-[#8a8a8a] group-hover/lib:text-white" />
</div>
<!-- Library Dropdown Menu -->
<div v-if="activeLibraryDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
<div
v-for="libModel in LIBRARY_MODELS"
:key="libModel"
class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer flex items-center gap-2"
@click="selectFromLibrary(model.id, libModel)"
>
<i class="icon-[lucide--file-code] size-3.5 text-[#8a8a8a]" />
{{ libModel }}
</div>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</div>
</Transition>
</div>
</div>
</TransitionGroup>
</div>
</Transition>
<!-- Bottom Divider -->
<div class="-mx-4 mt-6 h-px bg-[#55565e]" />
</div>
</template>
<!-- Reset Button (Convenience for Storybook testing) -->
<div v-if="Object.keys(removedModels).length > 0" class="flex justify-center py-8">
<Button variant="muted-textonly" class="text-xs gap-2 hover:text-white" @click="resetAll">
<i class="icon-[lucide--rotate-ccw] size-3.5" />
Reset Storybook Flow
</Button>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
</div>
</template>
<style scoped>
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { MissingNodePack } from './MockManagerDialog.vue'
// Props / Emits
const emit = defineEmits<{
'locate': [pack: MissingNodePack]
}>()
// Mock Data
const MOCK_MISSING_PACKS: MissingNodePack[] = [
{
id: 'pack-1',
displayName: 'MeshGraphormerDepthMapPreprocessor_for_SEGS //Inspire',
packId: 'comfyui-inspire-pack',
description: 'Inspire Pack provides various creative and utility nodes for ComfyUI workflows.'
},
{
id: 'pack-2',
displayName: 'TilePreprocessor_Provider_for_SEGS',
packId: 'comfyui-controlnet-aux',
description: 'Auxiliary preprocessors for ControlNet including tile, depth, and pose processors.'
},
{
id: 'pack-3',
displayName: 'WD14Tagger | pysssss',
packId: 'comfyui-wdv14-tagger',
description: 'Automatic image tagging using WD14 model from pysssss.'
},
{
id: 'pack-4',
displayName: 'CR Simple Image Compare',
packId: 'comfyui-crystools',
description: 'Crystal Tools suite including image comparison and utility nodes.'
},
{
id: 'pack-5',
displayName: 'FaceDetailer | impact',
packId: 'comfyui-impact-pack',
description: 'Impact Pack provides face detailing, masking, and segmentation utilities.'
}
]
// State
const isSectionCollapsed = ref(false)
// Helpers
function getElementStyle(el: HTMLElement) {
return {
height: el.style.height,
overflow: el.style.overflow,
paddingTop: el.style.paddingTop,
paddingBottom: el.style.paddingBottom,
marginTop: el.style.marginTop,
marginBottom: el.style.marginBottom
}
}
// Transitions
const DURATION = 150
function enterTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { width } = getComputedStyle(el)
el.style.width = width
el.style.position = 'absolute'
el.style.visibility = 'hidden'
el.style.height = ''
const { height } = getComputedStyle(el)
el.style.position = ''
el.style.visibility = ''
el.style.height = '0px'
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
function leaveTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { height } = getComputedStyle(el)
el.style.height = height
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
</script>
<template>
<div
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
>
<!-- Nav Item: "Workflow Overview" + panel-right button -->
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
Workflow Overview
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
<i class="icon-[lucide--panel-right] size-4 text-white" />
</div>
</div>
</div>
<!-- Node Header: tab bar + search -->
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
<!-- Tab bar -->
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
<!-- "Error" tab (active) -->
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
<span class="text-sm text-white">Error</span>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
</div>
<!-- Other tabs -->
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Inputs</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Nodes</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
</div>
</div>
<!-- Search bar -->
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
Search for nodes or inputs
</p>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
<!-- Content: Nodes (Cloud Version) -->
<div class="flex-1 overflow-y-auto min-w-0">
<div class="px-4">
<!-- Section Header: Unsupported Node Packs -->
<div class="flex h-8 items-center justify-center w-full">
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">Unsupported Node Packs</p>
<div
class="group flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer"
@click="isSectionCollapsed = !isSectionCollapsed"
>
<i
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] group-hover:text-white transition-all"
:class="isSectionCollapsed ? '-rotate-180' : ''"
/>
</div>
</div>
<!-- Cloud Warning Text -->
<div v-if="!isSectionCollapsed" class="mt-3 mb-5">
<p class="m-0 text-sm text-[#8a8a8a] leading-relaxed">
This workflow requires custom nodes not yet available on Comfy Cloud.
</p>
</div>
<div class="-mx-4 border-b border-[#55565e]">
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!isSectionCollapsed" class="px-4 pb-2">
<div v-for="pack in MOCK_MISSING_PACKS" :key="pack.id" class="flex flex-col w-full group/card mb-1">
<!-- Widget Header -->
<div class="flex h-8 items-center w-full">
<p class="flex-1 min-w-0 text-sm text-white overflow-hidden text-ellipsis whitespace-nowrap">
{{ pack.displayName }}
</p>
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
@click="emit('locate', pack)"
>
<i class="icon-[lucide--locate] size-4 text-white" />
</div>
</div>
<!-- No Install button in Cloud version -->
</div>
</div>
</Transition>
</div>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
</div>
</template>

View File

@@ -0,0 +1,288 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
// Story-only type (Separated from actual production types)
export interface MissingNodePack {
id: string
displayName: string
packId: string
description: string
}
// Props / Emits
const props = withDefaults(
defineProps<{
selectedPack?: MissingNodePack | null
}>(),
{ selectedPack: null }
)
const emit = defineEmits<{ close: [] }>()
</script>
<template>
<!--
BaseModalLayout structure reproduction:
- Outer: rounded-2xl overflow-hidden
- Grid: 14rem(left) 1fr(content) 18rem(right)
- Left/Right panel bg: modal-panel-background = charcoal-600 = #262729
- Main bg: base-background = charcoal-800 = #171718
- Header height: h-18 (4.5rem / 72px)
- Border: charcoal-200 = #494a50
- NavItem selected: charcoal-300 = #3c3d42
- NavItem hovered: charcoal-400 = #313235
-->
<div
class="w-full h-full rounded-2xl overflow-hidden shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
style="display:grid; grid-template-columns: 14rem 1fr 18rem;"
>
<!-- Left Panel: bg = modal-panel-background = #262729 -->
<nav class="h-full overflow-hidden flex flex-col" style="background:#262729;">
<!-- Header: h-18 = 72px -->
<header class="flex w-full shrink-0 gap-2 pl-6 pr-3 items-center" style="height:4.5rem;">
<i class="icon-[comfy--extensions-blocks] text-white" />
<h2 class="text-white text-base font-semibold m-0">Nodes Manager</h2>
</header>
<!-- NavItems: px-3 gap-1 flex-col -->
<div class="flex flex-col gap-1 px-3 pb-3 overflow-y-auto">
<!-- All Extensions -->
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
<i class="icon-[lucide--list] size-4 text-white/70 shrink-0" />
<span class="min-w-0 truncate">All Extensions</span>
</div>
<!-- Not Installed -->
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
<i class="icon-[lucide--globe] size-4 text-white/70 shrink-0" />
<span class="min-w-0 truncate">Not Installed</span>
</div>
<!-- Installed Group -->
<div class="flex flex-col gap-1 mt-2">
<p class="px-4 py-1 text-[10px] text-white/40 uppercase tracking-wider font-medium m-0">Installed</p>
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
<i class="icon-[lucide--download] size-4 text-white/70 shrink-0" />
<span class="min-w-0 truncate">All Installed</span>
</div>
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
<i class="icon-[lucide--refresh-cw] size-4 text-white/70 shrink-0" />
<span class="min-w-0 truncate">Updates Available</span>
</div>
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
<i class="icon-[lucide--triangle-alert] size-4 text-white/70 shrink-0" />
<span class="min-w-0 truncate">Conflicting</span>
</div>
</div>
<!-- In Workflow Group -->
<div class="flex flex-col gap-1 mt-2">
<p class="px-4 py-1 text-[10px] text-white/40 uppercase tracking-wider font-medium m-0">In Workflow</p>
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
<i class="icon-[lucide--share-2] size-4 text-white/70 shrink-0" />
<span class="min-w-0 truncate">In Workflow</span>
</div>
<!-- Missing Nodes: active selection = charcoal-300 = #3c3d42 -->
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors bg-[#3c3d42]">
<i class="icon-[lucide--triangle-alert] size-4 text-[#fd9903] shrink-0" />
<span class="min-w-0 truncate">Missing Nodes</span>
</div>
</div>
</div>
</nav>
<!-- Main Content: bg = base-background = #171718 -->
<div class="flex flex-col overflow-hidden" style="background:#171718;">
<!-- Header row 1: Node Pack dropdown | Search | Install All -->
<header class="w-full px-6 flex items-center gap-3 shrink-0" style="height:4.5rem;">
<!-- Node Pack Dropdown -->
<div class="flex items-center gap-2 h-10 px-3 rounded-lg shrink-0 cursor-pointer text-sm text-white border border-[#494a50] hover:border-[#55565e]" style="background:#262729;">
<span>Node Pack</span>
<i class="icon-[lucide--chevron-down] size-4 text-[#8a8a8a]" />
</div>
<!-- Search bar (flex-1) -->
<div class="flex items-center h-10 rounded-lg px-4 gap-2 flex-1" style="background:#262729;">
<i class="pi pi-search text-xs text-[#8a8a8a] shrink-0" />
<span class="text-sm text-[#8a8a8a]">Search</span>
</div>
<!-- Install All Button (blue, right side) -->
<Button variant="primary" size="sm" class="shrink-0 gap-2 px-4 text-sm h-10 font-semibold rounded-xl">
<i class="icon-[lucide--download] size-4" />
Install All
</Button>
</header>
<!-- Header row 2: Downloads Sort Dropdown (right aligned) -->
<div class="flex justify-end px-6 py-3 shrink-0">
<div class="flex items-center h-10 gap-2 px-4 rounded-xl cursor-pointer text-sm text-white border border-[#494a50] hover:border-[#55565e]" style="background:#262729;">
<i class="icon-[lucide--arrow-up-down] size-4 text-[#8a8a8a]" />
<span>Downloads</span>
<i class="icon-[lucide--chevron-down] size-4 text-[#8a8a8a]" />
</div>
</div>
<!-- Pack Grid Content -->
<div class="flex-1 min-h-0 overflow-y-auto px-6 py-4">
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));">
<div
v-for="(_, i) in 9"
:key="i"
:class="[
'rounded-xl border p-4 flex flex-col gap-3 cursor-pointer transition-colors',
i === 0 && selectedPack
? 'border-[#0b8ce9]/70 ring-1 ring-[#0b8ce9]/40'
: 'border-[#494a50] hover:border-[#55565e]'
]"
:style="i === 0 && selectedPack ? 'background:#172d3a;' : 'background:#262729;'"
>
<!-- Card Image Area -->
<div class="w-full h-20 rounded-lg flex items-center justify-center" style="background:#313235;">
<i class="icon-[lucide--package] size-6 text-[#8a8a8a]" />
</div>
<!-- Card Text Content -->
<div>
<p class="m-0 text-xs font-semibold text-white truncate">
{{ i === 0 && selectedPack ? selectedPack.packId : 'node-pack-' + (i + 1) }}
</p>
<p class="m-0 text-[11px] text-[#8a8a8a] truncate mt-0.5">by publisher</p>
</div>
</div>
</div>
</div>
</div>
<!-- Right Info Panel -->
<aside class="h-full flex flex-col overflow-hidden" style="background:#1c1d1f; border-left: 1px solid #494a50;">
<!-- Header: h-18 - Title + Panel icons + X close -->
<header class="flex h-[4.5rem] shrink-0 items-center px-5 gap-3 border-b border-[#494a50]">
<h2 class="flex-1 select-none text-base font-bold text-white m-0">Node Pack Info</h2>
<!-- Panel Collapse Icon -->
<button
class="flex items-center justify-center text-[#8a8a8a] hover:text-white p-1"
style="background:none;border:none;outline:none;cursor:pointer;"
>
<i class="icon-[lucide--panel-right] size-4" />
</button>
<!-- Close X Icon -->
<button
class="flex items-center justify-center text-[#8a8a8a] hover:text-white p-1"
style="background:none;border:none;outline:none;cursor:pointer;"
@click="emit('close')"
>
<i class="icon-[lucide--x] size-4" />
</button>
</header>
<!-- Panel Content Area -->
<div class="flex-1 min-h-0 overflow-y-auto">
<div v-if="props.selectedPack" class="flex flex-col divide-y divide-[#2e2f31]">
<!-- ACTIONS SECTION -->
<div class="flex flex-col gap-3 px-5 py-4">
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Actions</span>
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
</div>
<Button variant="primary" class="w-full justify-center gap-2 h-10 font-semibold rounded-xl">
<i class="icon-[lucide--download] size-4" />
Install
</Button>
</div>
<!-- BASIC INFO SECTION -->
<div class="flex flex-col gap-4 px-5 py-4">
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Basic Info</span>
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
</div>
<!-- Name -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">Name</span>
<span class="text-sm text-[#8a8a8a]">{{ props.selectedPack.packId }}</span>
</div>
<!-- Created By -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">Created By</span>
<span class="text-sm text-[#8a8a8a]">publisher</span>
</div>
<!-- Downloads -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">Downloads</span>
<span class="text-sm text-[#8a8a8a]">539,373</span>
</div>
<!-- Last Updated -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">Last Updated</span>
<span class="text-sm text-[#8a8a8a]">Jan 21, 2026</span>
</div>
<!-- Status -->
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-white">Status</span>
<span
class="inline-flex items-center gap-1.5 w-fit px-2.5 py-1 rounded-full text-xs text-white border border-[#494a50]"
style="background:#262729;"
>
<span class="size-2 rounded-full bg-[#8a8a8a] shrink-0" />
Unknown
</span>
</div>
<!-- Version -->
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-white">Version</span>
<span
class="inline-flex items-center gap-1 w-fit px-2.5 py-1 rounded-full text-xs text-white border border-[#494a50]"
style="background:#262729;"
>
1.8.0
<i class="icon-[lucide--chevron-right] size-3 text-[#8a8a8a]" />
</span>
</div>
</div>
<!-- DESCRIPTION SECTION -->
<div class="flex flex-col gap-4 px-5 py-4">
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Description</span>
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
</div>
<!-- Description -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">Description</span>
<p class="m-0 text-sm text-[#8a8a8a] leading-relaxed">{{ props.selectedPack.description }}</p>
</div>
<!-- Repository -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">Repository</span>
<div class="flex items-start gap-2">
<i class="icon-[lucide--github] size-4 text-[#8a8a8a] shrink-0 mt-0.5" />
<span class="text-sm text-[#8a8a8a] break-all flex-1">https://github.com/aria1th/{{ props.selectedPack.packId }}</span>
<i class="icon-[lucide--external-link] size-4 text-[#8a8a8a] shrink-0 mt-0.5" />
</div>
</div>
<!-- License -->
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-white">License</span>
<span class="text-sm text-[#8a8a8a]">MIT</span>
</div>
</div>
<!-- NODES SECTION -->
<div class="flex flex-col gap-3 px-5 py-4">
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Nodes</span>
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
</div>
</div>
</div>
<!-- No Selection State -->
<div v-else class="flex flex-col items-center justify-center h-full gap-3 px-6 opacity-40">
<i class="icon-[lucide--package] size-8 text-white" />
<p class="m-0 text-sm text-white text-center">Select a pack to view details</p>
</div>
</div>
</aside>
</div>
</template>

View File

@@ -0,0 +1,405 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
// Props / Emits
const emit = defineEmits<{
'locate': [name: string],
}>()
// Mock Data
interface MissingModel {
id: string
name: string
type: string
}
const INITIAL_MISSING_MODELS: Record<string, MissingModel[]> = {
'Lora': [
{ id: 'm1', name: 'Flat_color_anime.safetensors', type: 'Lora' },
{ id: 'm2', name: 'Bokeh_blur_xl.safetensors', type: 'Lora' },
{ id: 'm3', name: 'Skin_texture_realism.safetensors', type: 'Lora' }
],
'VAE': [
{ id: 'v1', name: 'vae-ft-mse-840000-ema-pruned.safetensors', type: 'VAE' },
{ id: 'v2', name: 'clear-vae-v1.safetensors', type: 'VAE' }
]
}
const LIBRARY_MODELS = [
'v1-5-pruned-emaonly.safetensors',
'sd_xl_base_1.0.safetensors',
'dreamshaper_8.safetensors',
'realisticVisionV51_v51VAE.safetensors'
]
// State
const collapsedCategories = ref<Record<string, boolean>>({
'VAE': true
})
// Model Status: 'idle' | 'downloading' | 'downloaded' | 'using_library'
const importStatus = ref<Record<string, 'idle' | 'downloading' | 'downloaded' | 'using_library'>>({})
const downloadProgress = ref<Record<string, number>>({})
const downloadTimers = ref<Record<string, ReturnType<typeof setInterval>>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
// Track hidden models (removed after clicking check button)
const removedModels = ref<Record<string, boolean>>({})
// Compute which categories have at least one visible model
const activeCategories = computed(() => {
const result: Record<string, boolean> = {}
for (const cat in INITIAL_MISSING_MODELS) {
result[cat] = INITIAL_MISSING_MODELS[cat].some(m => !removedModels.value[m.id])
}
return result
})
// Tracks which model's library dropdown is currently open
const activeLibraryDropdown = ref<string | null>(null)
// Actions
function toggleLibraryDropdown(modelId: string) {
if (activeLibraryDropdown.value === modelId) {
activeLibraryDropdown.value = null
} else {
activeLibraryDropdown.value = modelId
}
}
function selectFromLibrary(modelId: string, fileName: string) {
selectedLibraryModel.value[modelId] = fileName
importStatus.value[modelId] = 'using_library'
activeLibraryDropdown.value = null
}
function startUpload(modelId: string) {
if (downloadTimers.value[modelId]) {
clearInterval(downloadTimers.value[modelId])
}
importStatus.value[modelId] = 'downloading'
downloadProgress.value[modelId] = 0
const startTime = Date.now()
const duration = 3000 // Speed up for OSS simulation
downloadTimers.value[modelId] = setInterval(() => {
const elapsed = Date.now() - startTime
const progress = Math.min((elapsed / duration) * 100, 100)
downloadProgress.value[modelId] = progress
if (progress >= 100) {
clearInterval(downloadTimers.value[modelId])
delete downloadTimers.value[modelId]
importStatus.value[modelId] = 'downloaded'
}
}, 50)
}
function handleCheckClick(modelId: string) {
if (importStatus.value[modelId] === 'downloaded' || importStatus.value[modelId] === 'using_library') {
removedModels.value = { ...removedModels.value, [modelId]: true }
}
}
function cancelImport(modelId: string) {
if (downloadTimers.value[modelId]) {
clearInterval(downloadTimers.value[modelId])
delete downloadTimers.value[modelId]
}
importStatus.value[modelId] = 'idle'
downloadProgress.value[modelId] = 0
selectedLibraryModel.value[modelId] = ''
}
function resetAll() {
for (const id in downloadTimers.value) {
clearInterval(downloadTimers.value[id])
}
downloadTimers.value = {}
importStatus.value = {}
downloadProgress.value = {}
selectedLibraryModel.value = {}
removedModels.value = {}
activeLibraryDropdown.value = null
}
// Helpers
function getElementStyle(el: HTMLElement) {
return {
height: el.style.height,
overflow: el.style.overflow,
paddingTop: el.style.paddingTop,
paddingBottom: el.style.paddingBottom,
marginTop: el.style.marginTop,
marginBottom: el.style.marginBottom
}
}
// Transitions
const DURATION = 150
function enterTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { width } = getComputedStyle(el)
el.style.width = width
el.style.position = 'absolute'
el.style.visibility = 'hidden'
el.style.height = ''
const { height } = getComputedStyle(el)
el.style.position = ''
el.style.visibility = ''
el.style.height = '0px'
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
function leaveTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { height } = getComputedStyle(el)
el.style.height = height
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
</script>
<template>
<div
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
>
<!-- Nav Item -->
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
Workflow Overview
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
<i class="icon-[lucide--panel-right] size-4 text-white" />
</div>
</div>
</div>
<!-- Node Header -->
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
<span class="text-sm text-white">Error</span>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Inputs</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Nodes</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
</div>
</div>
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
Search for nodes or inputs
</p>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
<!-- Content: Missing Models -->
<div class="flex-1 overflow-y-auto min-w-0 no-scrollbar">
<template v-for="(models, category) in INITIAL_MISSING_MODELS" :key="category">
<div
v-if="activeCategories[category]"
class="px-4 mb-4"
>
<!-- Category Header -->
<div
class="flex h-8 items-center justify-center w-full group"
:class="category === 'VAE' ? 'cursor-default' : 'cursor-pointer'"
@click="category !== 'VAE' && (collapsedCategories[category] = !collapsedCategories[category])"
>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">
{{ category }} ({{ models.filter(m => !removedModels[m.id]).length }})
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0">
<i
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] transition-all"
:class="[
category !== 'VAE' ? 'group-hover:text-white' : '',
collapsedCategories[category] ? '-rotate-180' : ''
]"
/>
</div>
</div>
<!-- Model List -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!collapsedCategories[category]" class="pt-2">
<TransitionGroup :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-for="model in models" v-show="!removedModels[model.id]" :key="model.id" class="flex flex-col w-full mb-6 last:mb-4">
<!-- Model Header (Always visible) -->
<div class="flex h-8 items-center w-full gap-2 mb-1">
<i class="icon-[lucide--file-check] size-4 text-white shrink-0" />
<p class="flex-1 min-w-0 text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap">
{{ model.name }}
</p>
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 transition-colors"
:class="[
(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library')
? 'cursor-pointer hover:bg-[#1e2d3d] bg-[#1e2d3d]'
: 'opacity-20 cursor-default'
]"
@click="handleCheckClick(model.id)"
>
<i
class="icon-[lucide--check] size-4"
:class="(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library') ? 'text-[#3b82f6]' : 'text-white'"
/>
</div>
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
@click="emit('locate', model.name)"
>
<i class="icon-[lucide--locate] size-4 text-white" />
</div>
</div>
<!-- Input or Progress Area -->
<div class="relative mt-1">
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div
v-if="importStatus[model.id] && importStatus[model.id] !== 'idle'"
class="relative bg-white/5 border border-[#55565e] rounded-lg overflow-hidden flex items-center p-2 gap-2"
>
<div
v-if="importStatus[model.id] === 'downloading'"
class="absolute inset-y-0 left-0 bg-[#3b82f6]/10 transition-all duration-100 ease-linear pointer-events-none"
:style="{ width: downloadProgress[model.id] + '%' }"
/>
<div class="relative z-10 size-[32px] flex items-center justify-center shrink-0">
<i class="icon-[lucide--file-check] size-5 text-[#8a8a8a]" />
</div>
<div class="relative z-10 flex-1 min-w-0 flex flex-col justify-center">
<span class="text-[12px] font-medium text-white truncate leading-tight">
{{ importStatus[model.id] === 'using_library' ? selectedLibraryModel[model.id] : model.name }}
</span>
<span class="text-[12px] text-[#8a8a8a] leading-tight mt-0.5">
<template v-if="importStatus[model.id] === 'downloading'">Uploading ...</template>
<template v-else-if="importStatus[model.id] === 'downloaded'">Uploaded</template>
<template v-else-if="importStatus[model.id] === 'using_library'">Using from Library</template>
</span>
</div>
<div
class="relative z-10 size-6 flex items-center justify-center text-[#55565e] hover:text-white cursor-pointer transition-colors shrink-0"
@click="cancelImport(model.id)"
>
<i class="icon-[lucide--circle-x] size-4" />
</div>
</div>
</Transition>
<!-- IDLE / UPLOAD AREA -->
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!importStatus[model.id] || importStatus[model.id] === 'idle'" class="flex flex-col gap-2">
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!selectedLibraryModel[model.id]" class="flex flex-col gap-2">
<!-- Direct Upload Section -->
<div
class="h-8 rounded-lg flex items-center justify-center border border-dashed border-[#55565e] hover:border-white transition-colors cursor-pointer group"
@click="startUpload(model.id)"
>
<span class="text-xs text-[#8a8a8a] group-hover:text-white">Upload .safetensors or .ckpt</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-center py-0.5 font-bold text-[10px] text-[#8a8a8a]">OR</div>
<div class="relative">
<div
class="h-8 bg-[#262729] rounded-lg flex items-center px-3 cursor-pointer group/lib hover:border-[#494a50] border border-transparent"
@click="toggleLibraryDropdown(model.id)"
>
<span class="flex-1 text-xs text-white truncate">Use from Library</span>
<i class="icon-[lucide--chevron-down] size-3.5 text-[#8a8a8a] group-hover/lib:text-white" />
</div>
<div v-if="activeLibraryDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
<div
v-for="libModel in LIBRARY_MODELS"
:key="libModel"
class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer flex items-center gap-2"
@click="selectFromLibrary(model.id, libModel)"
>
<i class="icon-[lucide--file-code] size-3.5 text-[#8a8a8a]" />
{{ libModel }}
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</div>
</div>
</TransitionGroup>
</div>
</Transition>
<div class="-mx-4 mt-6 h-px bg-[#55565e]" />
</div>
</template>
<div v-if="Object.keys(removedModels).length > 0" class="flex justify-center py-8">
<Button variant="muted-textonly" class="text-xs gap-2 hover:text-white" @click="resetAll">
<i class="icon-[lucide--rotate-ccw] size-3.5" />
Reset Storybook Flow
</Button>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
</div>
</template>
<style scoped>
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View File

@@ -0,0 +1,312 @@
<script setup lang="ts">
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { MissingNodePack } from './MockManagerDialog.vue'
// Props / Emits
const emit = defineEmits<{
'open-manager': [pack: MissingNodePack],
'locate': [pack: MissingNodePack],
'log': [msg: string]
}>()
// Mock Data
const MOCK_MISSING_PACKS: MissingNodePack[] = [
{
id: 'pack-1',
displayName: 'MeshGraphormerDepthMapPreprocessor_for_SEGS //Inspire',
packId: 'comfyui-inspire-pack',
description: 'Inspire Pack provides various creative and utility nodes for ComfyUI workflows.'
},
{
id: 'pack-2',
displayName: 'TilePreprocessor_Provider_for_SEGS',
packId: 'comfyui-controlnet-aux',
description: 'Auxiliary preprocessors for ControlNet including tile, depth, and pose processors.'
},
{
id: 'pack-3',
displayName: 'WD14Tagger | pysssss',
packId: 'comfyui-wdv14-tagger',
description: 'Automatic image tagging using WD14 model from pysssss.'
},
{
id: 'pack-4',
displayName: 'CR Simple Image Compare',
packId: 'comfyui-crystools',
description: 'Crystal Tools suite including image comparison and utility nodes.'
},
{
id: 'pack-5',
displayName: 'FaceDetailer | impact',
packId: 'comfyui-impact-pack',
description: 'Impact Pack provides face detailing, masking, and segmentation utilities.'
}
]
// State
const isSectionCollapsed = ref(false)
const installStates = ref<Record<string, 'idle' | 'installing' | 'error'>>({})
const hasSuccessfulInstall = ref(false)
// Helpers
function getInstallState(packId: string) {
return installStates.value[packId] ?? 'idle'
}
function getElementStyle(el: HTMLElement) {
return {
height: el.style.height,
overflow: el.style.overflow,
paddingTop: el.style.paddingTop,
paddingBottom: el.style.paddingBottom,
marginTop: el.style.marginTop,
marginBottom: el.style.marginBottom
}
}
// Actions
function onInstall(pack: MissingNodePack) {
if (getInstallState(pack.id) !== 'idle') return
installStates.value[pack.id] = 'installing'
emit('log', `⤵ Installing: "${pack.packId}"`)
const isErrorPack = pack.id === 'pack-2'
const delay = isErrorPack ? 2000 : 3000
setTimeout(() => {
if (isErrorPack) {
installStates.value[pack.id] = 'error'
emit('log', `⚠️ Install failed: "${pack.packId}"`)
} else {
installStates.value[pack.id] = 'idle'
hasSuccessfulInstall.value = true
emit('log', `✓ Installed: "${pack.packId}"`)
}
}, delay)
}
function onInstallAll() {
const idlePacks = MOCK_MISSING_PACKS.filter(p => getInstallState(p.id) === 'idle')
if (!idlePacks.length) {
emit('log', 'No packs to install')
return
}
emit('log', `Install All → Starting sequential install of ${idlePacks.length} pack(s)`)
idlePacks.forEach((pack, i) => {
setTimeout(() => onInstall(pack), i * 1000)
})
}
function resetAll() {
installStates.value = {}
hasSuccessfulInstall.value = false
emit('log', '🔄 Reboot Server')
}
// Transitions
const DURATION = 150
function enterTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { width } = getComputedStyle(el)
el.style.width = width
el.style.position = 'absolute'
el.style.visibility = 'hidden'
el.style.height = ''
const { height } = getComputedStyle(el)
el.style.position = ''
el.style.visibility = ''
el.style.height = '0px'
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
function leaveTransition(element: Element, done: () => void) {
const el = element as HTMLElement
const init = getElementStyle(el)
const { height } = getComputedStyle(el)
el.style.height = height
el.style.overflow = 'hidden'
const anim = el.animate(
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: DURATION, easing: 'ease-in-out' }
)
el.style.height = init.height
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
}
</script>
<template>
<div
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
>
<!-- Nav Item: "Workflow Overview" + panel-right button -->
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
Workflow Overview
</p>
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
<i class="icon-[lucide--panel-right] size-4 text-white" />
</div>
</div>
</div>
<!-- Node Header: tab bar + search -->
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
<!-- Tab bar -->
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
<!-- "Error" tab (active) -->
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
<span class="text-sm text-white">Error</span>
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
</div>
<!-- Other tabs -->
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Inputs</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm text-[#8a8a8a]">Nodes</span>
</div>
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
</div>
</div>
<!-- Search bar -->
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
Search for nodes or inputs
</p>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
<!-- Content: Nodes (Missing Node Packs) -->
<div class="flex-1 overflow-y-auto min-w-0">
<div class="px-4">
<!-- Section Header -->
<div class="flex h-8 items-center justify-center w-full">
<div class="flex items-center justify-center size-6 shrink-0">
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
</div>
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap">Missing Node Packs</p>
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]"
@click="onInstallAll"
>
<span class="text-sm text-white">Install All</span>
</div>
<div
class="group flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer"
@click="isSectionCollapsed = !isSectionCollapsed"
>
<i
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] group-hover:text-white transition-all"
:class="isSectionCollapsed ? '-rotate-180' : ''"
/>
</div>
</div>
<div class="h-2" />
<div class="-mx-4 border-b border-[#55565e]">
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<div v-if="!isSectionCollapsed" class="px-4 pb-2">
<div v-for="pack in MOCK_MISSING_PACKS" :key="pack.id" class="flex flex-col w-full group/card mb-1">
<!-- Widget Header -->
<div class="flex h-8 items-center w-full">
<p class="flex-1 min-w-0 text-sm text-white overflow-hidden text-ellipsis whitespace-nowrap">
{{ pack.displayName }}
</p>
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
@click="emit('open-manager', pack)"
>
<i class="icon-[lucide--info] size-4 text-white" />
</div>
<div
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
@click="emit('locate', pack)"
>
<i class="icon-[lucide--locate] size-4 text-white" />
</div>
</div>
<!-- Install button -->
<div class="flex items-start w-full pt-1 pb-2">
<div
class="flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 transition-colors select-none"
:class="[
getInstallState(pack.id) === 'idle'
? 'bg-[#262729] cursor-pointer hover:bg-[#303133]'
: getInstallState(pack.id) === 'error'
? 'bg-[#3a2020] cursor-pointer hover:bg-[#4a2a2a]'
: 'bg-[#262729] opacity-60 cursor-default'
]"
@click="onInstall(pack)"
>
<svg
v-if="getInstallState(pack.id) === 'installing'"
class="animate-spin size-4 text-white shrink-0"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<i
v-else-if="getInstallState(pack.id) === 'error'"
class="icon-[lucide--triangle-alert] size-4 text-[#f59e0b] shrink-0"
/>
<i v-else class="icon-[lucide--download] size-4 text-white shrink-0" />
<span class="text-sm text-white ml-1.5 shrink-0">
{{ getInstallState(pack.id) === 'installing' ? 'Installing...' : 'Install node pack' }}
</span>
</div>
</div>
</div>
</div>
</Transition>
<Transition
enter-active-class="transition-opacity duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="hasSuccessfulInstall && !isSectionCollapsed" class="px-4 pb-4 pt-1">
<Button
variant="primary"
class="w-full h-9 justify-center gap-2 text-sm font-semibold"
@click="resetAll"
>
<i class="icon-[lucide--refresh-cw] size-4" />
Apply Changes
</Button>
</div>
</Transition>
</div>
</div>
</div>
<div class="h-px bg-[#55565e] shrink-0 w-full" />
</div>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { ref } from 'vue'
import MockManagerDialog from './MockManagerDialog.vue'
import MockOSSMissingNodePack from './MockOSSMissingNodePack.vue'
import type { MissingNodePack } from './MockManagerDialog.vue'
// State
const isManagerOpen = ref(false)
const selectedPack = ref<MissingNodePack | null>(null)
const statusLog = ref<string>('')
// Actions
function log(msg: string) {
statusLog.value = msg
}
function openManager(pack: MissingNodePack) {
selectedPack.value = pack
isManagerOpen.value = true
log(`ⓘ Opening Manager: "${pack.displayName.split('//')[0].trim()}"`)
}
function closeManager() {
isManagerOpen.value = false
selectedPack.value = null
log('Manager closed')
}
function onLocate(pack: MissingNodePack) {
log(`◎ Locating on canvas: "${pack.displayName.split('//')[0].trim()}"`)
}
</script>
<template>
<!-- ComfyUI layout simulation: canvas + right side panel + manager overlay -->
<div class="relative w-full h-full flex overflow-hidden bg-[#0d0e10]">
<!-- Canvas area -->
<div class="flex-1 min-w-0 relative flex flex-col items-center justify-center gap-4 overflow-hidden">
<!-- Grid background -->
<div
class="absolute inset-0 opacity-15"
style="background-image: repeating-linear-gradient(#444 0 1px, transparent 1px 100%), repeating-linear-gradient(90deg, #444 0 1px, transparent 1px 100%); background-size: 32px 32px;"
/>
<div class="relative z-10 flex flex-col items-center gap-4">
<div class="text-[#8a8a8a]/30 text-sm select-none">ComfyUI Canvas</div>
<div class="flex gap-5 flex-wrap justify-center px-8">
<div v-for="i in 4" :key="i" class="w-[160px] h-[80px] rounded-lg border border-[#3a3b3d] bg-[#1a1b1d]/80 flex flex-col p-3 gap-2">
<div class="h-3 w-24 rounded bg-[#2a2b2d]" />
<div class="h-2 w-16 rounded bg-[#2a2b2d]" />
<div class="h-2 w-20 rounded bg-[#2a2b2d]" />
</div>
</div>
<div class="flex items-center justify-center min-h-[36px]">
<div
v-if="statusLog"
class="px-4 py-1.5 rounded-lg text-xs text-center bg-blue-950/70 border border-blue-500/40 text-blue-300"
>{{ statusLog }}</div>
<div v-else class="px-4 py-1.5 text-xs text-[#8a8a8a]/30 border border-dashed border-[#2a2b2d] rounded-lg">
Click the buttons in the right-side error tab
</div>
</div>
</div>
</div>
<!-- Right: MockOSSMissingNodePack (320px) -->
<MockOSSMissingNodePack
@open-manager="openManager"
@locate="onLocate"
@log="log"
/>
<!-- Manager dialog overlay (full screen including right panel) -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="isManagerOpen"
class="absolute inset-0 z-20 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="closeManager"
>
<div class="relative h-[80vh] w-[90vw] max-w-[1400px]">
<MockManagerDialog :selected-pack="selectedPack" @close="closeManager" />
</div>
</div>
</Transition>
</div>
</template>

View File

@@ -0,0 +1,21 @@
export interface ErrorItem {
message: string
details?: string
isRuntimeError?: boolean
}
export interface ErrorCardData {
id: string
title: string
nodeId?: string
nodeTitle?: string
graphNodeId?: string
isSubgraphNode?: boolean
errors: ErrorItem[]
}
export interface ErrorGroup {
title: string
cards: ErrorCardData[]
priority: number
}

View File

@@ -0,0 +1,405 @@
import { computed, reactive, watch } from 'vue'
import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { app } from '@/scripts/app'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { st } from '@/i18n'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import {
isNodeExecutionId,
parseNodeExecutionId
} from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
const SINGLE_GROUP_KEY = '__single__'
const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt'])
interface GroupEntry {
priority: number
cards: Map<string, ErrorCardData>
}
interface ErrorSearchItem {
groupIndex: number
cardIndex: number
searchableNodeId: string
searchableNodeTitle: string
searchableMessage: string
searchableDetails: string
}
/**
* Resolve display info for a node by its execution ID.
* For group node internals, resolves the parent group node's title instead.
*/
function resolveNodeInfo(nodeId: string) {
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
const parts = parseNodeExecutionId(nodeId)
const parentId = parts && parts.length > 1 ? String(parts[0]) : null
const parentNode = parentId
? app.rootGraph.getNodeById(Number(parentId))
: null
const isParentGroupNode = parentNode ? isGroupNode(parentNode) : false
return {
title: isParentGroupNode
? parentNode?.title || ''
: resolveNodeDisplayName(graphNode, {
emptyLabel: '',
untitledLabel: '',
st
}),
graphNodeId: graphNode ? String(graphNode.id) : undefined,
isParentGroupNode
}
}
function getOrCreateGroup(
groupsMap: Map<string, GroupEntry>,
title: string,
priority = 1
): Map<string, ErrorCardData> {
let entry = groupsMap.get(title)
if (!entry) {
entry = { priority, cards: new Map() }
groupsMap.set(title, entry)
}
return entry.cards
}
function createErrorCard(
nodeId: string,
classType: string,
idPrefix: string
): ErrorCardData {
const nodeInfo = resolveNodeInfo(nodeId)
return {
id: `${idPrefix}-${nodeId}`,
title: classType,
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: isNodeExecutionId(nodeId) && !nodeInfo.isParentGroupNode,
errors: []
}
}
/**
* In single-node mode, regroup cards by error message instead of class_type.
* This lets the user see "what kinds of errors this node has" at a glance.
*/
function regroupByErrorMessage(
groupsMap: Map<string, GroupEntry>
): Map<string, GroupEntry> {
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
Array.from(g.cards.values())
)
const cardErrorPairs = allCards.flatMap((card) =>
card.errors.map((error) => ({ card, error }))
)
const messageMap = new Map<string, GroupEntry>()
for (const { card, error } of cardErrorPairs) {
addCardErrorToGroup(messageMap, card, error)
}
return messageMap
}
function addCardErrorToGroup(
messageMap: Map<string, GroupEntry>,
card: ErrorCardData,
error: ErrorItem
) {
const group = getOrCreateGroup(messageMap, error.message, 1)
if (!group.has(card.id)) {
group.set(card.id, { ...card, errors: [] })
}
group.get(card.id)?.errors.push(error)
}
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
title,
cards: Array.from(groupData.cards.values()),
priority: groupData.priority
}))
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority
return a.title.localeCompare(b.title)
})
}
function searchErrorGroups(groups: ErrorGroup[], query: string) {
if (!query) return groups
const searchableList: ErrorSearchItem[] = []
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi]
for (let ci = 0; ci < group.cards.length; ci++) {
const card = group.cards[ci]
searchableList.push({
groupIndex: gi,
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
})
}
}
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
keys: [
{ name: 'searchableNodeId', weight: 0.3 },
{ name: 'searchableNodeTitle', weight: 0.3 },
{ name: 'searchableMessage', weight: 0.3 },
{ name: 'searchableDetails', weight: 0.1 }
],
threshold: 0.3
}
const fuse = new Fuse(searchableList, fuseOptions)
const results = fuse.search(query)
const matchedCardKeys = new Set(
results.map((r) => `${r.item.groupIndex}:${r.item.cardIndex}`)
)
return groups
.map((group, gi) => ({
...group,
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
}))
.filter((group) => group.cards.length > 0)
}
export function useErrorGroups(
searchQuery: Ref<string>,
t: (key: string) => string
) {
const executionStore = useExecutionStore()
const canvasStore = useCanvasStore()
const rightSidePanelStore = useRightSidePanelStore()
const collapseState = reactive<Record<string, boolean>>({})
const selectedNodeInfo = computed(() => {
const items = canvasStore.selectedItems
const nodeIds = new Set<string>()
const containerIds = new Set<string>()
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))
}
}
return {
nodeIds: nodeIds.size > 0 ? nodeIds : null,
containerIds
}
})
const isSingleNodeSelected = computed(
() =>
selectedNodeInfo.value.nodeIds?.size === 1 &&
selectedNodeInfo.value.containerIds.size === 0
)
const errorNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
for (const execId of executionStore.allErrorExecutionIds) {
const node = getNodeByExecutionId(app.rootGraph, execId)
if (node) map.set(execId, node)
}
return map
})
function isErrorInSelection(executionNodeId: string): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
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
}
return false
}
function addNodeErrorToGroup(
groupsMap: Map<string, GroupEntry>,
nodeId: string,
classType: string,
idPrefix: string,
errors: ErrorItem[],
filterBySelection = false
) {
if (filterBySelection && !isErrorInSelection(nodeId)) return
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
const cards = getOrCreateGroup(groupsMap, groupKey, 1)
if (!cards.has(nodeId)) {
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
}
cards.get(nodeId)?.errors.push(...errors)
}
function processPromptError(groupsMap: Map<string, GroupEntry>) {
if (selectedNodeInfo.value.nodeIds || !executionStore.lastPromptError)
return
const error = executionStore.lastPromptError
const groupTitle = error.message
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
cards.set(PROMPT_CARD_ID, {
id: PROMPT_CARD_ID,
title: groupTitle,
errors: [
{
message: isKnown
? t(`rightSidePanel.promptErrors.${error.type}.desc`)
: error.message
}
]
})
}
function processNodeErrors(
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
executionStore.lastNodeErrors
)) {
addNodeErrorToGroup(
groupsMap,
nodeId,
nodeError.class_type,
'node',
nodeError.errors.map((e) => ({
message: e.message,
details: e.details ?? undefined
})),
filterBySelection
)
}
}
function processExecutionError(
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionStore.lastExecutionError) return
const e = executionStore.lastExecutionError
addNodeErrorToGroup(
groupsMap,
String(e.node_id),
e.node_type,
'exec',
[
{
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true
}
],
filterBySelection
)
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap)
processNodeErrors(groupsMap)
processExecutionError(groupsMap)
return toSortedGroups(groupsMap)
})
const tabErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap)
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)
return isSingleNodeSelected.value
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
})
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = searchQuery.value.trim()
return searchErrorGroups(tabErrorGroups.value, query)
})
const groupedErrorMessages = computed<string[]>(() => {
const messages = new Set<string>()
for (const group of allErrorGroups.value) {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
}
}
return Array.from(messages)
})
/**
* When an external trigger (e.g. "See Error" button in SectionWidgets)
* sets focusedErrorNodeId, expand only the group containing the target
* node and collapse all others so the user sees the relevant errors
* immediately.
*/
function expandFocusedErrorGroup(graphNodeId: string | null) {
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
collapseState[group.title] = !hasMatch
}
rightSidePanelStore.focusedErrorNodeId = null
}
watch(() => rightSidePanelStore.focusedErrorNodeId, expandFocusedErrorGroup, {
immediate: true
})
return {
allErrorGroups,
tabErrorGroups,
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache,
groupedErrorMessages
}
}

View File

@@ -5,13 +5,18 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type {
LGraphGroup,
LGraphNode,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
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 { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -57,6 +62,9 @@ watchEffect(() => (widgets.value = widgetsProp))
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const nodeDefStore = useNodeDefStore()
const { t } = useI18n()
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
@@ -100,6 +108,26 @@ const targetNode = computed<LGraphNode | null>(() => {
return allSameNode ? widgets.value[0].node : null
})
const hasDirectError = computed(() => {
if (!targetNode.value) return false
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
})
const hasContainerInternalError = computed(() => {
if (!targetNode.value) return false
const isContainer =
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
if (!isContainer) return false
return executionStore.hasInternalErrorForNode(targetNode.value.id)
})
const nodeHasError = computed(() => {
if (!targetNode.value) return false
if (canvasStore.selectedItems.length === 1) return false
return hasDirectError.value || hasContainerInternalError.value
})
const parentGroup = computed<LGraphGroup | null>(() => {
if (!targetNode.value || !getNodeParentGroup) return null
return getNodeParentGroup(targetNode.value)
@@ -118,15 +146,38 @@ function handleLocateNode() {
}
}
function handleWidgetValueUpdate(
widget: IBaseWidget,
newValue: string | number | boolean | object
) {
widget.value = newValue
widget.callback?.(newValue)
function navigateToErrorTab() {
if (!targetNode.value) return
if (!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) return
rightSidePanelStore.focusedErrorNodeId = String(targetNode.value.id)
rightSidePanelStore.openPanel('errors')
}
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
widget.value = value
widget.callback?.(value)
canvasStore.canvas?.setDirty(true, true)
}
function handleResetAllWidgets() {
for (const { widget, node: widgetNode } of widgetsProp) {
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
const defaultValue = getWidgetDefaultValue(spec)
if (defaultValue !== undefined) {
writeWidgetValue(widget, defaultValue)
}
}
}
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
if (newValue === undefined) return
writeWidgetValue(widget, newValue)
}
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
writeWidgetValue(widget, newValue)
}
defineExpose({
widgetsContainer,
rootElement
@@ -142,9 +193,20 @@ defineExpose({
:tooltip
>
<template #label>
<div class="flex items-center gap-2 flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
<span class="flex-1 flex items-center gap-2 min-w-0">
<span class="truncate">
<i
v-if="nodeHasError"
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
/>
<span
:class="
cn(
'truncate',
nodeHasError && 'text-destructive-background-hover'
)
"
>
<slot name="label">
{{ displayLabel }}
</slot>
@@ -157,6 +219,26 @@ defineExpose({
{{ parentGroup.title }}
</span>
</span>
<Button
v-if="nodeHasError"
variant="secondary"
size="sm"
class="shrink-0 rounded-lg text-sm"
@click.stop="navigateToErrorTab"
>
{{ t('rightSidePanel.seeError') }}
</Button>
<Button
v-if="!isEmpty"
variant="textonly"
size="icon-sm"
class="subbutton shrink-0 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
:title="t('rightSidePanel.resetAllParameters')"
:aria-label="t('rightSidePanel.resetAllParameters')"
@click.stop="handleResetAllWidgets"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
</Button>
<Button
v-if="canShowLocateButton"
variant="textonly"
@@ -189,6 +271,7 @@ defineExpose({
:parents="parents"
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
@update:widget-value="handleWidgetValueUpdate(widget, $event)"
@reset-to-default="handleWidgetReset(widget, $event)"
/>
</TransitionGroup>
</div>

View File

@@ -0,0 +1,209 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import type { Slots } from 'vue'
import { h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import WidgetActions from './WidgetActions.vue'
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
mockGetInputSpecForWidget: vi.fn()
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
getInputSpecForWidget: mockGetInputSpecForWidget
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: { setDirty: vi.fn() }
})
}))
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
useFavoritedWidgetsStore: () => ({
isFavorited: vi.fn().mockReturnValue(false),
toggleFavorite: vi.fn()
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
prompt: vi.fn()
})
}))
vi.mock('@/components/button/MoreButton.vue', () => ({
default: (_: unknown, { slots }: { slots: Slots }) =>
h('div', slots.default?.({ close: () => {} }))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
rename: 'Rename',
enterNewName: 'Enter new name'
},
rightSidePanel: {
hideInput: 'Hide input',
showInput: 'Show input',
addFavorite: 'Favorite',
removeFavorite: 'Unfavorite',
resetToDefault: 'Reset to default'
}
}
}
})
describe('WidgetActions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
mockGetInputSpecForWidget.mockReturnValue({
type: 'INT',
default: 42
})
})
function createMockWidget(
value: number = 100,
callback?: () => void
): IBaseWidget {
return {
name: 'test_widget',
type: 'number',
value,
label: 'Test Widget',
options: {},
y: 0,
callback
} as IBaseWidget
}
function createMockNode(): LGraphNode {
return {
id: 1,
type: 'TestNode'
} as LGraphNode
}
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
return mount(WidgetActions, {
props: {
widget,
node,
label: 'Test Widget'
},
global: {
plugins: [i18n]
}
})
}
it('shows reset button when widget has default value', () => {
const widget = createMockWidget()
const node = createMockNode()
const wrapper = mountWidgetActions(widget, node)
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
expect(resetButton).toBeDefined()
})
it('emits resetToDefault with default value when reset button clicked', async () => {
const widget = createMockWidget(100)
const node = createMockNode()
const wrapper = mountWidgetActions(widget, node)
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
await resetButton?.trigger('click')
expect(wrapper.emitted('resetToDefault')).toHaveLength(1)
expect(wrapper.emitted('resetToDefault')![0]).toEqual([42])
})
it('disables reset button when value equals default', () => {
const widget = createMockWidget(42)
const node = createMockNode()
const wrapper = mountWidgetActions(widget, node)
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
expect(resetButton?.attributes('disabled')).toBeDefined()
})
it('does not show reset button when no default value exists', () => {
mockGetInputSpecForWidget.mockReturnValue({
type: 'CUSTOM'
})
const widget = createMockWidget(100)
const node = createMockNode()
const wrapper = mountWidgetActions(widget, node)
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
expect(resetButton).toBeUndefined()
})
it('uses fallback default for INT type without explicit default', async () => {
mockGetInputSpecForWidget.mockReturnValue({
type: 'INT'
})
const widget = createMockWidget(100)
const node = createMockNode()
const wrapper = mountWidgetActions(widget, node)
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
await resetButton?.trigger('click')
expect(wrapper.emitted('resetToDefault')![0]).toEqual([0])
})
it('uses first option as default for combo without explicit default', async () => {
mockGetInputSpecForWidget.mockReturnValue({
type: 'COMBO',
options: ['option1', 'option2', 'option3']
})
const widget = createMockWidget(100)
const node = createMockNode()
const wrapper = mountWidgetActions(widget, node)
const resetButton = wrapper
.findAll('button')
.find((b) => b.text().includes('Reset'))
await resetButton?.trigger('click')
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
})
})

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { isEqual } from 'es-toolkit'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -14,7 +15,10 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDialogService } from '@/services/dialogService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
const {
widget,
@@ -28,10 +32,15 @@ const {
isShownOnParents?: boolean
}>()
const emit = defineEmits<{
resetToDefault: [value: WidgetValue]
}>()
const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const dialogService = useDialogService()
const { t } = useI18n()
@@ -43,6 +52,19 @@ const isFavorited = computed(() =>
favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name)
)
const inputSpec = computed(() =>
nodeDefStore.getInputSpecForWidget(node, widget.name)
)
const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
const hasDefault = computed(() => defaultValue.value !== undefined)
const isCurrentValueDefault = computed(() => {
if (!hasDefault.value) return true
return isEqual(widget.value, defaultValue.value)
})
async function handleRename() {
const newLabel = await dialogService.prompt({
title: t('g.rename'),
@@ -97,6 +119,11 @@ function handleToggleFavorite() {
favoritedWidgetsStore.toggleFavorite(favoriteNode.value, widget.name)
}
function handleResetToDefault() {
if (!hasDefault.value) return
emit('resetToDefault', defaultValue.value)
}
const buttonClasses = cn([
'border-none bg-transparent',
'w-full flex items-center gap-2 rounded px-3 py-2 text-sm',
@@ -162,6 +189,21 @@ const buttonClasses = cn([
<span>{{ t('rightSidePanel.addFavorite') }}</span>
</template>
</button>
<button
v-if="hasDefault"
:class="cn(buttonClasses, isCurrentValueDefault && 'opacity-50')"
:disabled="isCurrentValueDefault"
@click="
() => {
handleResetToDefault()
close()
}
"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
<span>{{ t('rightSidePanel.resetToDefault') }}</span>
</button>
</template>
</MoreButton>
</template>

View File

@@ -20,6 +20,7 @@ import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { renameWidget } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
import WidgetActions from './WidgetActions.vue'
@@ -42,7 +43,8 @@ const {
}>()
const emit = defineEmits<{
'update:widgetValue': [value: string | number | boolean | object]
'update:widgetValue': [value: WidgetValue]
resetToDefault: [value: WidgetValue]
}>()
const { t } = useI18n()
@@ -83,11 +85,8 @@ const favoriteNode = computed(() =>
)
const widgetValue = computed({
get: () => {
widget.vueTrack?.()
return widget.value
},
set: (newValue: string | number | boolean | object) => {
get: () => widget.value,
set: (newValue: WidgetValue) => {
emit('update:widgetValue', newValue)
}
})
@@ -157,6 +156,7 @@ const displayLabel = customRef((track, trigger) => {
:node="node"
:parents="parents"
:is-shown-on-parents="isShownOnParents"
@reset-to-default="emit('resetToDefault', $event)"
/>
</div>
</div>

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