Compare commits

...

44 Commits

Author SHA1 Message Date
Hunter Senft-Grupp
5bbd5103cd fix: default frontend preview variant to cpu
Frontend previews don't need GPU resources. Default to cpu variant
and only use gpu when the preview-gpu label is explicitly added.
2026-03-10 14:53:54 -04:00
Hunter Senft-Grupp
a3d47fed8c test: change title to Preview v2 for update timing 2026-03-10 13:58:14 -04:00
Hunter Senft-Grupp
eda40b02c4 test: change page title for preview env smoke test 2026-03-10 13:36:25 -04:00
Hunter
b368a865cf feat: dispatch frontend PR preview environments to cloud (#9715)
## Summary

Add support for deploying full ephemeral preview environments from
frontend PRs. This is the frontend-side half — it sends `pr_number` and
`variant` (cpu/gpu) in the dispatch payload, and adds a cleanup dispatch
on PR close/unlabel.

### Changes

- **`cloud-dispatch-build.yaml`** — Add `pr_number` and `variant` to the
`frontend-asset-build` dispatch payload. Variant is derived from which
preview label triggered the event (`preview-cpu` → cpu, else gpu).

- **`cloud-dispatch-cleanup.yaml`** (new) — Fire-and-forget dispatch of
`frontend-preview-cleanup` to the cloud repo when a frontend PR is
closed or has its preview label removed. Enables synchronized teardown.

### Companion PR

Cloud-side: Comfy-Org/cloud (creates the `deploy-frontend-preview` job,
extends the reconciler)

### How it works

1. Label a frontend PR with `preview`, `preview-cpu`, or `preview-gpu`
2. Assets build and upload to GCS (existing flow)
3. Cloud deploys a full ephemeral env at `fe-pr-{N}.testenvs.comfy.org`
using all `:main` service tags
4. Subsequent pushes update the frontend SHA via AppSet upsert
5. On close/unlabel, cleanup dispatch triggers immediate teardown

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9715-feat-dispatch-frontend-PR-preview-environments-to-cloud-31f6d73d3650819da1b5ca5ce419e06e)
by [Unito](https://www.unito.io)
2026-03-10 13:16:37 -04:00
AustinMroz
1d7a5b9e0b Mobile input tweaks (#9686)
- Buttons are marked as `touch-manipulation` so double-tapping on them
doesn't initiate a zoom.
- Move scrubable inputs to usePointerSwipe
- Strangely, swipe direction was inverted on mobile. This solves the
issue and simplifies code
  - Moves event handlers into the scrubbable input component
- Make the slightly bigger buttons only apply when on mobile.
- Updates the workflows dropdown to have a check by the activeWorkflow
and truncate workflow names
- Displays dropzones (for the image preview) on mobile, but disables the
prompt to drag and drop an image if none is selected.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9686-Mobile-input-tweaks-31f6d73d3650811d9025d0cd1ac58534)
by [Unito](https://www.unito.io)
2026-03-09 23:08:42 -07:00
AustinMroz
fbcd36d355 Revert flake snapshot update (#9699)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9699-Revert-flake-snapshot-update-31f6d73d3650818db8a1f07aae70182e)
by [Unito](https://www.unito.io)
2026-03-09 22:18:42 -07:00
Robin Huang
e594164b71 feat: show App/Node Graph type indicator on template cards (#9695)
Show a type label (App or Node Graph) below the description on each
template card in the workflow templates modal. Templates with
`.app.json` suffix display an app icon with "App", all others show a
workflow icon with "Node Graph". Card size changed from compact to tall
to fit the extra row.


https://github.com/user-attachments/assets/dc14d7f5-2994-4764-aa96-c5fc5b634e7e

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9695-feat-show-App-Node-Graph-type-indicator-on-template-cards-31f6d73d3650813f8310c850f6107cf6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-09 22:17:45 -07:00
Jin Yi
245f840e7c fix: prevent HoneyToast from collapsing to minimum width in collapsed state (#9701)
## Summary

Fix HoneyToast footer content (text + buttons) being squished when
collapsed due to `sm:w-min` on the outer container.

## Changes

- **What**: Replace `sm:w-min sm:min-w-0` with state-aware sizing
(`sm:w-fit` when collapsed, `sm:w-[max(400px,40vw)]` when expanded) on
the HoneyToast container. Add `gap-4` between footer text and buttons in
ManagerProgressToast, `min-w-0` on text area, and `shrink-0` on button
area to prevent overlap.

## Review Focus

- HoneyToast is used in 3 places: ManagerProgressToast,
ModelImportProgressDialog, AssetExportProgressDialog. The change moves
width control from the inner content div to the outer container, which
should have no negative impact on the other two consumers.

## As-is
<img width="481" height="146" alt="스크린샷 2026-03-10 오전 10 40 52"
src="https://github.com/user-attachments/assets/b5e12e20-23ea-4f11-9778-ad4e6c10a425"
/>

## To-be
<img width="506" height="62" alt="스크린샷 2026-03-10 오후 1 46 18"
src="https://github.com/user-attachments/assets/f2b7963d-eedb-4885-bc57-f8b377962e92"
/>
<img width="630" height="83" alt="스크린샷 2026-03-10 오후 1 46 10"
src="https://github.com/user-attachments/assets/e9c35f1c-3441-4fb2-8fa4-f66b7d53b3e5"
/>
<img width="683" height="103" alt="스크린샷 2026-03-10 오후 1 46 02"
src="https://github.com/user-attachments/assets/afb94a16-cfba-4da9-8676-35f4d3133b57"
/>
2026-03-09 22:17:22 -07:00
Jin Yi
240b54419b fix: load API format workflows with missing node types (#9694)
## Summary

`loadApiJson` early-returns when missing node types are detected,
preventing the entire API-format workflow from loading onto the canvas.

## Changes

- **What**: Remove early `return` in `loadApiJson` so missing nodes are
skipped while the rest of the workflow loads normally, consistent with
how `loadGraphData` handles missing nodes in standard workflow format.

## Review Focus

The existing code already handles missing nodes gracefully:
- `LiteGraph.createNode()` returns `null` for unregistered types
- `if (!node) continue` skips missing nodes during graph construction
- `if (!fromNode) continue` skips connections to missing nodes
- `if (!node) return` skips input processing for missing nodes

The early `return` was unnecessarily preventing the entire load. The
warning modal is still shown via `showMissingNodesError`.

## Test workflow & screen recording
[04wan2.2smoothmix图生视频
(3).json](https://github.com/user-attachments/files/25858354/04wan2.2smoothmix.3.json)

[screen-capture.webm](https://github.com/user-attachments/assets/9c396f80-fff1-4d17-882c-35ada86542c1)
2026-03-09 22:13:41 -07:00
Robin Huang
d9020b7fbe feat: show user avatar for personal workspace (#9687)
Use the login provider's profile picture (Google, GitHub, etc.) as the
topbar avatar when in the personal workspace, instead of the generated
gradient letter avatar.

<img width="371" height="563" alt="Screenshot 2026-03-09 at 6 45 11 PM"
src="https://github.com/user-attachments/assets/29dc0d87-0bdb-497c-ab1d-f8d4d6784217"
/>


- Fixes #

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9687-feat-show-user-avatar-for-personal-workspace-31f6d73d36508191ab34fe7880e5e57e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:12:38 -07:00
Dante
c4272ef1da refactor: reorganize Select stories and add size/state variants (#9639)
<img width="373" height="535" alt="스크린샷 2026-03-09 오후 2 48 10"
src="https://github.com/user-attachments/assets/7fea3fd4-0d90-4022-ad78-c53e3d5be887"
/>


## Summary
- Reorganize Select-related stories under `Components/Select/` hierarchy
(SingleSelect, MultiSelect, Select)
- Add `size` prop (`lg`/`md`) to SingleSelect, MultiSelect,
SelectTrigger for Figma Large (40px) / Medium (32px) variants
- Add `invalid` prop (red border) to SingleSelect and SelectTrigger
- Add `loading` prop (spinner) to SingleSelect
- Add `hover:bg-secondary-background-hover` to all select triggers
- Align disabled opacity to 30% per Figma spec
- Add new stories: Disabled, Invalid, Loading, MediumSize, AllStates

## Test plan
- [ ] Verify Storybook renders all stories under `Components/Select/`
- [ ] Check hover state visually on all select triggers
- [ ] Verify Medium size (32px) renders correctly
- [ ] Verify Invalid state shows red border
- [ ] Verify Loading state shows spinner
- [ ] Verify Disabled state has 30% opacity and no hover effect

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9639-refactor-reorganize-Select-stories-and-add-size-state-variants-31e6d73d36508142b835f04ab6bdaefe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:00:58 +09:00
Dante
2ef354447d feat: add Storybook stories for Slider components (#9634)
## Summary
- Add Storybook stories for `Slider` component matching Figma design
system variants 1:1
- Stories at `Components/Slider`: **Default** and **Disabled**
- Add hover background
(`hover:bg-component-node-widget-background-hovered`) to
`WidgetInputNumberSlider` only

## Figma reference
[Comfy Design System —
Slider](https://www.figma.com/design/vALUV83vIdBzEsTJAhQgXq/Comfy-Design-System?node-id=2-9718&m=dev)

## Scope decisions
- **Hover**: Added to `WidgetInputNumberSlider.vue` only — other widgets
need separate verification before applying
- **Invalid**: Not implemented in `Slider.vue` — excluded until
component supports it

## Files changed
- `src/components/ui/slider/Slider.stories.ts` — new
-
`src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberSlider.vue`
— add hover background

## Test plan
- [ ] `pnpm storybook` — verify Default and Disabled render under
`Components/Slider`
- [ ] Hover background visible on slider widget in app (e.g. KSampler
`cfg` slider)
- [ ] Other widget inputs (text, textarea, select) unchanged
- [ ] `pnpm typecheck` — passes
- [ ] `pnpm lint` — passes

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:49:47 +09:00
Hunter
55789ef0fb Redirect authenticated users from signup page to cloud (#9691)
## Summary

When a logged-in user navigates to `/cloud/signup`, they are now
redirected to `cloud-user-check` (which handles survey or main page
routing).

This mirrors the existing `beforeEnter` guard on the `cloud-login`
route. The `switchAccount` query param bypass is preserved for
consistency.

## Changes

- Added `beforeEnter` guard to the `cloud-signup` route in
`onboardingCloudRoutes.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9691-Redirect-authenticated-users-from-signup-page-to-cloud-31f6d73d365081e08cb5c3360a862a37)
by [Unito](https://www.unito.io)
2026-03-09 22:38:49 -04:00
Jin Yi
7add2c03e9 feat: unify search components by replacing SearchBox/SearchBoxV2 with SearchInput (#9644)
## Summary

Replace legacy `SearchBox` (PrimeVue) and `SearchBoxV2` with the unified
`SearchInput` (reka-ui) component across all consumers.

## Changes

- **What**: Remove `SearchBox.vue`, `SearchBoxV2.vue`, their tests and
stories. Migrate all 14 consumers to `SearchInput`. Move layout classes
to `ComboboxRoot` for proper flex sizing. Extract filter button/chips in
`NodeLibrarySidebarTab`. Standardize modal search width to `flex-1
max-w-lg`.
- **Dependencies**: None new — `SearchInput` already existed using
reka-ui

## Review Focus

- `NodeLibrarySidebarTab.vue`: filter button and `SearchFilterChip`
rendering moved outside the search component
- `SearchInput.vue`: `className` now applied to `ComboboxRoot` instead
of `ComboboxAnchor` for correct flex layout
- Modal dialogs (`WorkflowTemplateSelectorDialog`, `AssetBrowserModal`,
`SampleModelSelector`) unified to `flex-1 max-w-lg`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9644-feat-unify-search-components-by-replacing-SearchBox-SearchBoxV2-with-SearchInput-31e6d73d365081ebac55cb265f33b631)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-10 11:30:25 +09:00
Jin Yi
c81bc8400c fix: virtual scroll pagination not working in media asset list view (#9646)
## Summary

Fix virtual scroll pagination not triggering in media asset panel list
view.

## Changes

**What**: `VirtualGrid` in `AssetsSidebarListView` was missing
`maxColumns=1` and had an incorrect default item height (200px vs actual
~48px). Without `maxColumns`, `cols` was calculated as
`floor(containerWidth / 200)` (e.g. 2), causing the row count to be
halved and `isNearEnd` to never fire correctly. Added `:max-columns="1"`
and `:default-item-height="48"` to fix pagination. Added regression
tests to `VirtualGrid.test.ts`.

## Review Focus

The root cause: `VirtualGrid.cols` computed as `floor(width/200)`
instead of `1` for single-column list layout, breaking spacer heights
and `approach-end` detection.

Test covers both the fix (approach-end fires with maxColumns=1) and the
bug reproduction (does not fire without it).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9646-fix-virtual-scroll-pagination-not-working-in-media-asset-list-view-31e6d73d3650813d973ad19638ad6933)
by [Unito](https://www.unito.io)
2026-03-10 11:29:42 +09:00
AustinMroz
af5a72021b Use preview downscaling in fewer places (#9678)
Thumbnail downscaling is currently being used in more places than it
should be.
- Nodes which display images will display incorrect resolution
indicators
<img width="255" height="372" alt="image"
src="https://github.com/user-attachments/assets/674790b6-04c8-4db0-84c2-2fa2dbaf123d"
/> <img width="255" height="372" alt="image"
src="https://github.com/user-attachments/assets/1dbe751b-7462-4408-9236-9446b005f5fc"
/>

This is particularly confusing with output nodes, which claim the output
is not of the intended resolution
- The "Download Image" and "Open Image" context menu actions will
incorrectly download the downscaled thumbnail.
- The assets panel will incorrectly display the thumbnail resolution as
the resolution of the output
- The lightbox (zoom) of an image will incorrectly display a downscaled
thumbnail.

This PR is a quick workaround to staunch the major problems
- Nodes always display full previews.
- Resolution downscaling is applied on the assert card, not on the
assetItem itself
- Due to implementation, this means that asset cards will still
incorrectly show the resolution of the thumbnail instead of the size of
the full image.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 16:03:32 -07:00
Hunter
4e5bb3e540 fix: dispatch cloud build on synchronize for preview-labeled PRs (#9636)
## Summary

Cloud build dispatch was only triggering on the `labeled` event, not on
subsequent pushes to PRs that already had a preview label.

## Changes

- **What**: Add `synchronize` to `pull_request` event types and update
the `if` condition to support all three preview labels (`preview`,
`preview-cpu`, `preview-gpu`). For `labeled` events, check the added
label name; for `synchronize` events, check existing PR labels.

## Review Focus

The `if` condition now branches on `github.event.action` to use the
correct label-checking mechanism for each event type.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9636-fix-dispatch-cloud-build-on-synchronize-for-preview-labeled-PRs-31e6d73d3650814e9069e37d6199ffc9)
by [Unito](https://www.unito.io)
2026-03-09 15:55:53 -07:00
AustinMroz
2ccfb822b4 Restore hiding of linked inputs in app mode (#9671)
As a temporary fix for widgets being incorrectly hidden, #9669 allowed
all disabled widgets to be displayed.

This PR provides a more robust implementation to derive whether the
widget, as would be displayed from the root graph, is disabled.

Potential regression:
- Drag drop handlers are applied on node, not widgets. A subgraph
containing a "Load Image" node, does not allow dragging and dropping an
image onto the subgraphNode in order to load it. Because app mode
widgets would display from the original owning node prior to this PR,
these drag/drop handlers would apply. Placing "Load Image" nodes. I
believe this change makes behavior more consistent, but it warrants
consideration.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9671-Restore-hiding-of-linked-inputs-in-app-mode-31e6d73d365081688e37fbb931f3af68)
by [Unito](https://www.unito.io)
2026-03-09 13:18:05 -07:00
jaeone94
370003da94 fix: add isGraphReady guard to prevent premature graph access error logs (#9672)
## Summary
Adds `isGraphReady` getter to `ComfyApp` and uses it in
`executionErrorStore` guards to prevent false 'ComfyApp graph accessed
before initialization' error logs during early store evaluation.

## Changes
- **What**: Added `isGraphReady` boolean getter to `ComfyApp` that
safely checks graph initialization without triggering the `rootGraph`
getter's error log. Updated 5 guard sites in `executionErrorStore` to
use `app.isGraphReady` instead of `app.rootGraph`.
- **Why**: The `rootGraph` getter logs an error when accessed before
initialization. Computed properties and watch callbacks in
`executionErrorStore` are evaluated early (before graph init), causing
false error noise in the console.

## Review Focus
- `isGraphReady` is intentionally minimal — just
`!!this.rootGraphInternal` — to avoid duplicating the error-logging
behavior of `rootGraph`
- The `watch(lastNodeErrors, ...)` callback now checks `isGraphReady` at
the top and early-returns, consistent with the computed property pattern

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9672-fix-add-isGraphReady-guard-to-prevent-premature-graph-access-error-logs-31e6d73d365081be8e1fc77114ce9382)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 13:15:04 -07:00
Alexander Brown
3b5af4960f fix: show load widget inputs in media dropdown (#9670)
Main targeted, built on
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9551

## Summary

Fix Load Image/Load Video input dropdown tabs not showing available
input assets in Vue node select dropdown.

## Changes

- **What**: Keep combo widget `options` object identity while exposing
dynamic `values` for cloud/remote combos.
- **What**: Remove temporary debug logging and restore clearer dropdown
filter branching.
- **What**: Remove stale `searcher`/`updateKey` prop plumbing in
dropdown menu/actions and update related tests.

## Review Focus

Verify `Load Image` / `Load Video` Inputs tab behavior and confirm
cloud/remote combo option values still update correctly.

Relates to #9551

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9670-fix-show-load-widget-inputs-in-media-dropdown-31e6d73d36508148b845e18268a60c2a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-09 12:49:47 -07:00
Christian Byrne
46895ee1a9 docs: add release process guide (#9548)
Adds a concise guide to `docs/release-process.md` explaining how the
release workflows interact, with focus on the version semantics that
differ between minor and patch bumps.

Key sections:
- How minor bumps freeze the previous minor into `core/` and `cloud/`
branches
- How patch bumps on `main` vs `core/X.Y` differ (published vs draft
releases)
- Why unreleased commits are dual-homed when a minor bump happens
- Summary table, backporting, publishing, and bi-weekly automation

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9548-docs-add-release-process-guide-31d6d73d365081f2bdaace48a7cb81ae)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 12:38:40 -07:00
AustinMroz
7f0472fde4 Always use interior nodeId for app mode (#9669)
App mode stores the state of selected widgets as a tuple of `[NodeId,
WidgetName]`. With recent subgraph changes, for a given node,
`widget.name` will no longer uniquely resolve to a single widget.

- From both Vue and Litegraph, selecting an input for display in App
mode will now resolve the NodeId of the node which owns the widget
instead of the selected node.
- When displaying selections in litegraph, if the NodeId does not exist
in the current graph, instead of resolving the actual node the rootGraph
is searched for any subgraphNode which contains a view matching the
`[NodeId, WidgetName]` pair.
- When displaying widgets in App mode, the widget is always set as being
a view of the real widget (This means that they will not display a
purple promotion border.

Known Issue:
- These same subgraph changes made it so that a widget can be linked
without being disabled. This PR makes it so widgets which have been
linked instead display normally under the assumption that they are
incorrectly marked as disabled. As disabled widgets can not be selected
as inputs, this should handle normal usage fine, but a better solution
is being investigated

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9669-Always-use-interior-nodeId-for-app-mode-31e6d73d365081f8a918d0e43cb659ee)
by [Unito](https://www.unito.io)
2026-03-09 11:36:33 -07:00
Alexander Brown
24ac6388d7 style: Update share icon to be a send icon instead (#9667)
## Summary

It's a plane!

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9667-style-Update-share-icon-to-be-a-send-icon-instead-31e6d73d365081919013ea1521d26f2c)
by [Unito](https://www.unito.io)
2026-03-09 17:00:53 +00:00
Alexander Brown
6b6049e48e fix: Add a tooltip to account for assets with really long names. (#9665)
## Summary

Simple tooltip, default show delay.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9665-fix-Add-a-tooltip-to-account-for-assets-with-really-long-names-31e6d73d3650811f9057d1ec41e761b6)
by [Unito](https://www.unito.io)
2026-03-09 09:52:28 -07:00
AustinMroz
592f992d1d Even further app fixes (#9617)
- Allow dragging zoom pane with middle click
- Prevent selection of canvasOnly widgets
- These widgets would not display in app mode, so allow selection would
only cause confusion.
- Support displaying the error dialogue in app mode
- Add a somewhat involved mobile app mode error indication system
<img width="300" alt="image"
src="https://github.com/user-attachments/assets/d8793bbd-fff5-4b2a-a316-6ff154bae2c4"
/> <img width="300" alt="image"
src="https://github.com/user-attachments/assets/cb88b0f6-f7e5-409e-ae43-f1348f946b19"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9617-Even-further-app-fixes-31d6d73d365081c891dfdfe3477cfd61)
by [Unito](https://www.unito.io)
2026-03-09 09:35:31 -07:00
jaeone94
76fd80aa98 fix: hide empty actionbar container and relocate error border to floating actionbar (#9657)
## Summary
When the actionbar is floating and has no docked buttons, the container
is now hidden (zero-width, transparent border) to avoid showing an empty
rounded box. Additionally, the error/destructive border is now applied
to the floating actionbar panel itself (via `ComfyActionbar`) instead of
the container, so it appears in the correct location when floating.

## Changes
- **TopMenuSection**: Added `hasDockedButtons` and
`isActionbarContainerEmpty` computed properties to detect when the
docked container has no visible buttons; `actionbarContainerClass`
computed hides the container by collapsing it when empty and floating,
while preserving the legacy drop zone via `:has(.border-dashed)` CSS
selector
- **TopMenuSection**: Error border
(`border-destructive-background-hover`) is now only applied to the
docked container when the actionbar is **not** floating
- **ComfyActionbar**: Accepts new `hasAnyError` prop and applies the
error border to the floating panel's `panelClass` when floating

## Review Focus
- The `has-[.border-dashed]` CSS selector restores the container visuals
when a legacy drag-target element is present inside it — verify this
works as expected
- Error border placement: docked mode shows border on container,
floating mode shows border on the fixed panel

## Screenshots


https://github.com/user-attachments/assets/75caabac-e391-4bfd-b4dc-62d564e55d37
2026-03-09 21:24:00 +09:00
Hunter
63c36d3f2f feat: display original asset names instead of hashes in assets panel (#9626)
## Problem
Output assets in the assets panel show content hashes (e.g.,
`a1b2c3d4.png`) instead of display names (e.g., `ComfyUI_00001_.png`).

## Root Cause
Cloud inference replaces `filename` with the content hash in the output
transform pipeline. The hashed filename gets stored in the jobs table's
`preview_output` JSONB. The frontend uses this hash as the display name.

## Solution
- Add `display_name` field to `AssetItem` schema and `ResultItemImpl`
- Backend (cloud PR) joins job→assets table to resolve the original name
and injects `display_name` into job responses
- Frontend prefers `display_name` over `name` **only for display text
and download filenames**
- `asset.name` remains unchanged (the hash) for URLs, drag-to-canvas,
export filters, and output key dedup

## Backwards Compatible
- OSS: `display_name` is undefined, falls back to `asset.name` (which is
already the real filename in OSS)
- Cloud pre-deploy: `display_name` absent from API, falls back
gracefully
- Old jobs with no assets: `display_name` not injected, no change

## Cloud PR
https://github.com/Comfy-Org/cloud/pull/2747



https://github.com/user-attachments/assets/8a4c9cac-4ade-4ea2-9a70-9af240a56602
2026-03-09 01:06:28 -04:00
pythongosssss
892a9cf2c5 fix: prevent showing outputs in app mode when no output nodes configured (#9625)
## Summary

After a user runs the workflow once in graph mode, switching to app mode
with no app built, incorrectly showed the app mode outputs view instead
of the intro screen

## Changes

- **What**: don't try and select outputs if no outputs & filter out all
outputs when nothing chosen
2026-03-08 17:36:15 -07:00
Comfy Org PR Bot
308c22efc6 1.42.2 (#9629)
Patch version increment to 1.42.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9629-1-42-2-31e6d73d365081faa106d97ae431e2e6)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-03-08 17:24:52 -07:00
Hunter
5728d240da fix: restore backend outputs_count for asset sidebar multi-output badge (#9627)
## Summary

Fix regression from PR #9535 where the multi-output count badge stopped
appearing in the asset sidebar.

## Root Cause

PR #9535 changed `outputCount` in `mapHistoryToAssets` from
`job.outputs_count` (backend-provided total) to
`task.previewableOutputs.length`. However, `TaskItemImpl` constructed
from a job listing only has the single `preview_output`, so
`previewableOutputs.length` is always **1** — the multi-output badge
never appears.

## Fix

Use the backend-provided `outputs_count` (via `task.outputsCount`) with
fallback to `task.previewableOutputs.length` when unavailable. This
restores the correct count while preserving the fallback for jobs that
don't have `outputs_count` from the server.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9627-fix-restore-backend-outputs_count-for-asset-sidebar-multi-output-badge-31d6d73d36508160b93fd03af4a01aa3)
by [Unito](https://www.unito.io)
2026-03-08 13:17:22 -07:00
Kelly Yang
acf2f4280c fix(maskeditor): make brush size slider logarithmic (#8097) (#9534)
## Summary
fix #8097.

This PR shifts the Mask Editor Brush Size slider from a linear scale to
a logarithmic (exponential) scale. Previously, the linear 1-250 range
heavily clumped the usable, small "fine-detail" brush sizes (e.g., 1px
to 20px) into the very first 10% of the slider, making it extremely
difficult to select precise sizes with the mouse.

This update borrows UX paradigms from other standard image editors like
Photoshop and GIMP, which map their scale entry widgets on an
exponential curve.

## GIMP Source
By inspecting the official **GIMP** source code under
`libgimpwidgets/gimpscaleentry.c`, we can see this exact mathematical
relationship being utilized when the logarithmic property is marked TRUE
on a brush radius adjustment widget:

```
// Mapping visual slider to internal value
value = gtk_adjustment_get_lower(...) + exp(t);
// Mapping internal value to visual slider
t = log (value - gtk_adjustment_get_lower(...) + 0.1);
```


https://github.com/user-attachments/assets/6d59ff12-f623-42cc-a52b-84147e9bb90b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9534-fix-maskeditor-make-brush-size-slider-logarithmic-8097-31c6d73d365081118508e8363e0c5312)
by [Unito](https://www.unito.io)
2026-03-08 09:11:19 -07:00
Comfy Org PR Bot
7ad6994d01 1.42.1 (#9546)
Patch version increment to 1.42.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9546-1-42-1-31d6d73d365081a781fdebfef024a7cd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-07 18:28:06 -08:00
Christian Byrne
2829f78579 fix: use previewable output count for asset sidebar badge (#9535)
## Summary

Fix asset sidebar badge showing inflated output count by using
previewable outputs length instead of raw server count.

## Changes

- **What**: Changed `outputCount` in `mapHistoryToAssets` from
`job.outputs_count` (includes all output types: text, JSON, custom data)
to `task.previewableOutputs.length` (only image, video, audio, 3D). The
badge now matches what users actually see in the expanded view.

## Review Focus

One-line change. The `task.previewableOutputs` array is already computed
on the line immediately below and used for `allOutputs`, so this
introduces no new computation.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9535-fix-use-previewable-output-count-for-asset-sidebar-badge-31c6d73d365081c49161caec64cf3921)
by [Unito](https://www.unito.io)
2026-03-07 18:03:21 -08:00
pythongosssss
c4156d7059 feat/fix: App mode further updates (#9545)
## Summary

Additional updates

## Changes

- **What**: 
- Share widget rename functionality with properties panel implementation
- Add hammer icon to builder mode tabs
- Change (!) to (i) on app builder info sections

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9545-feat-fix-App-mode-further-updates-31c6d73d36508104aaa9c5f1e6205a0b)
by [Unito](https://www.unito.io)
2026-03-07 16:03:55 -08:00
Christian Byrne
725a0a2b89 fix: remove timeouts from error toasts so they persist until dismissed (#9543)
## Summary

Remove `life` (timeout) property from all error-severity toast calls so
they persist until manually dismissed, preventing users from missing
important error messages.

## Changes

- **What**: Removed `life` property from 86 error toast calls across 46
files. Error toasts now use PrimeVue's default behavior (no
auto-dismiss). Non-error toasts (success, warn, info) are unchanged.
- Also fixed a pre-existing lint issue in `TaskListPanel.vue` (`import {
t } from '@/i18n'` → `useI18n()`)

## Review Focus

- One conditional toast in `useMediaAssetActions.ts` intentionally keeps
`life` because its severity alternates between `warn` and `error`

Fixes
https://www.notion.so/comfy-org/Implement-Remove-timeouts-for-all-error-toasts-31b6d73d365081cead54fddc77ae7c3d

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9543-fix-remove-timeouts-from-error-toasts-so-they-persist-until-dismissed-31c6d73d365081fa8d30f6366e9bfe38)
by [Unito](https://www.unito.io)
2026-03-07 15:08:13 -08:00
Alexander Brown
8a5bcde168 fix: prevent non-widget inputs on nested subgraphs from appearing as button widgets (#9542)
## Summary

Fix non-widget inputs on nested subgraphs appearing twice — once as
slots and once as unresolved button widgets.

## Changes

- **What**: Add `getTargetWidget()` guard in the `isSubgraphNode()`
branch of `resolveSubgraphInputTarget`, matching the existing check for
non-subgraph nodes. Non-widget inputs (e.g. AUDIO, IMAGE) now return
`undefined` instead of a bogus promotion entry.

## Review Focus

`resolveSubgraphInputTarget` had an asymmetry: the non-subgraph branch
checked `getTargetWidget()` before returning, but the `isSubgraphNode()`
branch returned unconditionally for every input. For nested subgraphs
where non-widget slots are linked through to inner SubgraphNode inputs,
this created `PromotedWidgetView` entries that failed `resolveDeepest()`
(falling back to `type: 'button'`), while the inputs also rendered as
normal slot circles since `input.widget` was never set by
`_resolveInputWidget` (which correctly skipped them).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9542-fix-prevent-non-widget-inputs-on-nested-subgraphs-from-appearing-as-button-widgets-31c6d73d3650816387c3f97f0385e762)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 22:58:59 +00:00
AustinMroz
83ffaf30c8 Yet further app fixes (#9523)
- Prevent selection of note nodes
- Prevents selection  of nodes with errors
- A bit broader than the reported "can select missing nodes". A node
with an error is a node that can not execute and thus can not be used in
an app.
- Updates the typeform survey
- Add a collapsible list of all api nodes(/prices) contained in an app.
  - Needs to be prettied up for mobile still.
<img width="322" height="751" alt="image"
src="https://github.com/user-attachments/assets/ebfeeada-9b80-488e-88d6-feaa8bd53629"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9523-Yet-further-app-fixes-31c6d73d365081de9150fbf2d3ec54dd)
by [Unito](https://www.unito.io)
2026-03-07 14:22:15 -08:00
Christian Byrne
2875f897dc fix: remove workspace switching confirmation dialog (#9250)
## Summary

Remove the workspace switching confirmation dialog since switching
workspaces no longer discards unsaved changes.

## Changes

- **What**: Remove `hasUnsavedChanges` check, `dialogService.confirm`
call, and unused imports (`useI18n`, `useWorkflowStore`,
`useDialogService`) from `useWorkspaceSwitch`. Rename
`switchWithConfirmation` to `switchWorkspace`. Update callers
(`WorkspaceSwitcherPopover.vue`, `InviteAcceptedToast.vue`). Remove
`workspace.unsavedChanges` i18n entries from all 12 locale files.
Simplify tests to cover core switching behavior only.

## Review Focus

The confirmation dialog was showing inaccurate information (warning
about discarding unsaved changes when that no longer happens). This is a
pure removal with no new behavior.

<!-- Pipeline-Ticket: COM-15441 -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9250-fix-remove-workspace-switching-confirmation-dialog-3136d73d365081d3b959da22e8f151d1)
by [Unito](https://www.unito.io)
2026-03-07 14:19:05 -08:00
pythongosssss
ec129de63d fix: Prevent corruption of workflow data due to checkState during graph loading (#9531)
## Summary

During workflow loading, the workflow data & active workflow object can
be out of sync, meaning any checkState calls will overwrite data into
the wrong workflow.

Recreation steps:
* Open 2-3 workflows
* Enter builder mode > select step
* Select some different inputs on each
* Quickly tap the shift key (this triggers checkState) while switching
tabs
* After a while, you'll see the wrong inputs on the workflows

Alternatively, register an extension that guarantees to call checkState
during the bad phase, run this in browser devtools and switch tabs:
```
window.app.registerExtension({
  name: 'bad',
  async afterConfigureGraph() {
    window.app.extensionManager.workflow.activeWorkflow.changeTracker.checkState()
  }
})
```

## Changes

- **What**: 
- Add loading graph flag
- Prevent checkState calls while loading
- Prevent app mode data sync while loading

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9531-fix-Prevent-corruption-of-workflow-data-due-to-checkState-during-graph-loading-31c6d73d365081e2ab91d9145bf1d025)
by [Unito](https://www.unito.io)
2026-03-07 12:44:12 -08:00
pythongosssss
1687ca93b3 feat: Integrated tab UI updates (#8516)
## Summary
Next iteration of the integrated tab/top menu

## Changes
- **What**:  
- make integrated default, rename old to legacy
- move feedback to integrated
- fix user icon shapes
- remove comfy cloud text in top bar, move to canvas stats
- add chevron to C logo menu
- move help back to sidebar
   - remove now unused help top positioning code

## Screenshots (if applicable)
<img width="428" height="148" alt="image"
src="https://github.com/user-attachments/assets/725025b7-4982-4f61-be11-8aabb0a1faff"
/>
<img width="264" height="187" alt="image"
src="https://github.com/user-attachments/assets/91fa5e92-df08-4467-9bc5-50a614d9b8aa"
/>
<img width="1169" height="220" alt="image"
src="https://github.com/user-attachments/assets/68c81bea-0cff-48df-8303-a6231a1d2fc4"
/>
<img width="242" height="207" alt="image"
src="https://github.com/user-attachments/assets/5a10f40e-83ae-44c3-9434-3dbe87ba30e2"
/>
<img width="302" height="222" alt="image"
src="https://github.com/user-attachments/assets/27fcc638-5fff-4302-9a1f-066227aafd86"
/>

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-07 11:20:01 -08:00
pythongosssss
5bb742ac3a feat/fix: App mode QA fixes (#9530)
## Summary

Additional updates for app mode

## Changes

- **What**:
- Reposition toolbar button in app mode so it doesnt jump between modes
- Move node name to under title on input/output selection
- Change delete asset button text
- Change exit builder button icon

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9530-feat-fix-App-mode-QA-fixes-31c6d73d365081738b5cc5beaf2cbd41)
by [Unito](https://www.unito.io)
2026-03-07 18:54:13 +00:00
Kelly Yang
ca2d61f393 use getLoad3dAsync (#9520)
## Summary

Applying `useLoad3dService().getLoad3dAsync` to fix the broken 3D viewer
scene.

Fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/9495

## Screenshots 

before

<img width="3456" height="2168" alt="665d1b83bf8b23a9ddde1411a34c8df9"
src="https://github.com/user-attachments/assets/6cb190f4-ef13-4fd3-a0c5-2360f056da55"
/>


after

<img width="5120" height="2638" alt="image"
src="https://github.com/user-attachments/assets/154b1a98-bd71-41e2-839d-f0f1f7e5e72e"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9520-use-getLoad3dAsync-31c6d73d365081cf8c24cd89b545ccb4)
by [Unito](https://www.unito.io)
2026-03-07 09:22:35 -08:00
Benjamin Lu
750a2d23e0 chore: standardize on Node 24 (#9521)
## Summary

Standardize the repo's Node contract on 24 while centralizing workflow
resolution through `.nvmrc` so local setup, CI, and package metadata
stay aligned from one version file.

## Changes

- **What**: Add `package.json` `engines.node = 24.x`, switch every
`actions/setup-node` workflow in the repo to `node-version-file:
'.nvmrc'`, and update contributor and Playwright docs to point to
`.nvmrc` as the Node source of truth.

## Review Focus

The workflow behavior should be unchanged apart from sourcing the Node
version from `.nvmrc` instead of repeating literals like `20`, `22`,
`24.x`, or `lts/*`. GitHub's formatter also moved the new `engines`
block to the package metadata section near the end of `package.json`.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-07 09:06:10 +00:00
Comfy Org PR Bot
6d90bf3537 1.42.0 (#9522)
Minor version increment to 1.42.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9522-1-42-0-31c6d73d3650816faa33df8b4dde26fd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-06 23:11:18 -08:00
267 changed files with 5536 additions and 2691 deletions

View File

@@ -23,7 +23,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Update electron types

View File

@@ -28,7 +28,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies

View File

@@ -27,7 +27,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies

View File

@@ -26,7 +26,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies

View File

@@ -27,7 +27,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
@@ -82,7 +82,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies

View File

@@ -14,7 +14,7 @@ on:
- 'cloud/*'
- 'main'
pull_request:
types: [labeled]
types: [labeled, synchronize]
workflow_dispatch:
permissions: {}
@@ -26,11 +26,18 @@ concurrency:
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo.
# For pull_request events, only dispatch when the 'preview' label is added.
# For pull_request events, only dispatch for preview labels.
# - labeled: fires when a label is added; check the added label name.
# - synchronize: fires on push; check existing labels on the PR.
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
github.event.label.name == 'preview')
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
runs-on: ubuntu-latest
steps:
- name: Build client payload
@@ -39,18 +46,35 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
ACTION: ${{ github.event.action }}
LABEL_NAME: ${{ github.event.label.name }}
PR_LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
run: |
if [ "${EVENT_NAME}" = "pull_request" ]; then
REF="${PR_HEAD_SHA}"
BRANCH="${PR_HEAD_REF}"
# Derive variant from labels (default to cpu for frontend-only previews)
VARIANT="cpu"
if [ "${ACTION}" = "labeled" ]; then
[ "${LABEL_NAME}" = "preview-gpu" ] && VARIANT="gpu"
else
# synchronize — check existing labels on the PR
echo "${PR_LABELS}" | grep -q '"preview-gpu"' && VARIANT="gpu"
fi
else
REF="${GITHUB_SHA}"
BRANCH="${GITHUB_REF_NAME}"
PR_NUMBER=""
VARIANT=""
fi
payload="$(jq -nc \
--arg ref "${REF}" \
--arg branch "${BRANCH}" \
'{ref: $ref, branch: $branch}')"
--arg pr_number "${PR_NUMBER}" \
--arg variant "${VARIANT}" \
'{ref: $ref, branch: $branch, pr_number: $pr_number, variant: $variant}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
- name: Dispatch to cloud repo

View File

@@ -0,0 +1,39 @@
---
# Dispatches a frontend-preview-cleanup event to the cloud repo when a
# frontend PR with a preview label is closed or has its preview label
# removed. The cloud repo handles the actual environment teardown.
#
# This is fire-and-forget — it does NOT wait for the cloud workflow to
# complete. Status is visible in the cloud repo's Actions tab.
name: Cloud Frontend Preview Cleanup Dispatch
on:
pull_request:
types: [closed, unlabeled]
permissions: {}
jobs:
dispatch:
# Only dispatch when:
# - PR closed AND had a preview label
# - Preview label specifically removed
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))) ||
(github.event.action == 'unlabeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)))
runs-on: ubuntu-latest
steps:
- name: Dispatch to cloud repo
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
repository: Comfy-Org/cloud
event-type: frontend-preview-cleanup
client-payload: >-
{"pr_number": "${{ github.event.pull_request.number }}"}

View File

@@ -36,7 +36,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies for analysis tools

View File

@@ -25,7 +25,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
node-version-file: '.nvmrc'
- name: Download PR metadata
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12

View File

@@ -28,7 +28,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
node-version-file: '.nvmrc'
- name: Read desktop-ui version
id: get_version

View File

@@ -91,7 +91,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
node-version-file: '.nvmrc'
cache: 'pnpm'
registry-url: https://registry.npmjs.org

View File

@@ -82,7 +82,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: 'frontend/.nvmrc'
- name: Install dependencies
working-directory: frontend

View File

@@ -26,7 +26,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
- name: Check version bump type
id: check_version

View File

@@ -26,7 +26,7 @@ jobs:
version: 10
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Get current version

View File

@@ -82,7 +82,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
registry-url: https://registry.npmjs.org

View File

@@ -22,7 +22,7 @@ jobs:
version: 10
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Get current version

View File

@@ -149,7 +149,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: '.nvmrc'
- name: Bump version
id: bump-version

View File

@@ -58,7 +58,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Bump desktop-ui version

View File

@@ -35,7 +35,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies for analysis tools

View File

@@ -17,7 +17,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
### Prerequisites & Technology Stack
- **Required Software**:
- Node.js (v24) and pnpm
- Node.js (see `.nvmrc`, currently v24) and pnpm
- Git for version control
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)

View File

@@ -4,7 +4,7 @@
<template v-if="filter.tasks.length === 0">
<!-- Empty filter -->
<Divider />
<p class="text-neutral-400 w-full text-center">
<p class="w-full text-center text-neutral-400">
{{ $t('maintenance.allOk') }}
</p>
</template>
@@ -25,7 +25,7 @@
<!-- Display: Cards -->
<template v-else>
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
<div class="pad-y my-4 flex flex-wrap justify-evenly gap-8">
<TaskCard
v-for="task in filter.tasks"
:key="task.id"
@@ -45,7 +45,8 @@ import { useConfirm, useToast } from 'primevue'
import ConfirmPopup from 'primevue/confirmpopup'
import Divider from 'primevue/divider'
import { t } from '@/i18n'
import { useI18n } from 'vue-i18n'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type {
MaintenanceFilter,
@@ -55,6 +56,7 @@ import type {
import TaskCard from './TaskCard.vue'
import TaskListItem from './TaskListItem.vue'
const { t } = useI18n()
const toast = useToast()
const confirm = useConfirm()
const taskStore = useMaintenanceTaskStore()
@@ -80,8 +82,7 @@ const executeTask = async (task: MaintenanceTask) => {
toast.add({
severity: 'error',
summary: t('maintenance.error.toastTitle'),
detail: message ?? t('maintenance.error.defaultDescription'),
life: 10_000
detail: message ?? t('maintenance.error.defaultDescription')
})
}

View File

@@ -189,8 +189,7 @@ const completeValidation = async () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('maintenance.error.cannotContinue'),
life: 5_000
detail: t('maintenance.error.cannotContinue')
})
}
}

View File

@@ -1,8 +1,8 @@
<template>
<BaseViewTemplate dark hide-language-selector>
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
<div class="flex h-full flex-col items-center justify-center p-8 2xl:p-16">
<div
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
class="flex w-full max-w-[600px] flex-col gap-6 rounded-lg bg-neutral-800 p-6 shadow-lg"
>
<h2 class="text-3xl font-semibold text-neutral-100">
{{ $t('install.helpImprove') }}
@@ -15,7 +15,7 @@
<a
href="https://comfy.org/privacy"
target="_blank"
class="text-blue-400 hover:text-blue-300 underline"
class="text-blue-400 underline hover:text-blue-300"
>
{{ $t('install.privacyPolicy') }} </a
>.
@@ -33,7 +33,7 @@
}}
</span>
</div>
<div class="flex pt-6 justify-end">
<div class="flex justify-end pt-6">
<Button
:label="$t('g.ok')"
icon="pi pi-check"
@@ -72,8 +72,7 @@ const updateConsent = async () => {
toast.add({
severity: 'error',
summary: t('install.settings.errorUpdatingConsent'),
detail: t('install.settings.errorUpdatingConsentDetail'),
life: 3000
detail: t('install.settings.errorUpdatingConsentDetail')
})
} finally {
isUpdating.value = false

View File

@@ -27,7 +27,8 @@ cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
### Node.js & Playwright Prerequisites
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
Ensure you have the Node.js version from `.nvmrc` installed (currently v24).
Then, set up the Chromium test driver:
```bash
pnpm exec playwright install chromium --with-deps

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 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: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

62
docs/release-process.md Normal file
View File

@@ -0,0 +1,62 @@
# Release Process
## Bump Types
All releases use `release-version-bump.yaml`. Effects differ by bump type:
| Bump | Target | Creates branches? | GitHub release |
| ---------- | ---------- | ------------------------------------- | ---------------------------- |
| Minor | `main` | `core/` + `cloud/` for previous minor | Published, "latest" |
| Patch | `main` | No | Published, "latest" |
| Patch | `core/X.Y` | No | **Draft** (uncheck "latest") |
| Prerelease | any | No | Draft + prerelease |
**Minor bump** (e.g. 1.41→1.42): freezes the previous minor into `core/1.41`
and `cloud/1.41`, branched from the commit _before_ the bump. Nightly patch
bumps on `main` are convenience snapshots — no branches created.
**Patch on `core/X.Y`**: publishes a hotfix draft release. Must not be marked
"latest" so `main` stays current.
### Dual-homed commits
When a minor bump happens, unreleased commits appear in both places:
```
v1.40.1 ── A ── B ── C ── [bump to 1.41.0]
└── core/1.40
```
A, B, C become v1.41.0 on `main` AND sit on `core/1.40` (where they could
later ship as v1.40.2). Same commits, no divergence — the branch just prevents
1.41+ features from mixing in so ComfyUI can stay on 1.40.x.
## Backporting
1. Add `needs-backport` + version label to the merged PR
2. `pr-backport.yaml` cherry-picks and creates a backport PR
3. Conflicts produce a comment with details and an agent prompt
## Publishing
Merged PRs with the `Release` label trigger `release-draft-create.yaml`,
publishing to GitHub Releases (`dist.zip`), PyPI (`comfyui-frontend-package`),
and npm (`@comfyorg/comfyui-frontend-types`).
## Bi-weekly ComfyUI Integration
`release-biweekly-comfyui.yaml` runs every other Monday — if the next `core/`
branch has unreleased commits, it triggers a patch bump and drafts a PR to
`Comfy-Org/ComfyUI` updating `requirements.txt`.
## Workflows
| Workflow | Purpose |
| ------------------------------- | ------------------------------------------------ |
| `release-version-bump.yaml` | Bump version, create Release PR |
| `release-draft-create.yaml` | Build + publish to GitHub/PyPI/npm |
| `release-branch-create.yaml` | Create `core/` + `cloud/` branches (minor/major) |
| `release-biweekly-comfyui.yaml` | Auto-patch + ComfyUI requirements PR |
| `pr-backport.yaml` | Cherry-pick fixes to stable branches |
| `cloud-backport-tag.yaml` | Tag cloud branch merges |

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>ComfyUI</title>
<title>ComfyUI (Preview v2)</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.13",
"version": "1.42.2",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -195,6 +195,9 @@
"zip-dir": "^2.0.0",
"zod-to-json-schema": "catalog:"
},
"engines": {
"node": "24.x"
},
"pnpm": {
"overrides": {
"vite": "catalog:"

View File

@@ -166,13 +166,22 @@ describe('TopMenuSection', () => {
})
describe('authentication state', () => {
function createLegacyTabBarWrapper() {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.UI.TabBarLayout' ? 'Legacy' : undefined
)
return createWrapper({ pinia })
}
describe('when user is logged in', () => {
beforeEach(() => {
mockData.isLoggedIn = true
})
it('should display CurrentUserButton and not display LoginButton', () => {
const wrapper = createWrapper()
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})
@@ -186,7 +195,7 @@ describe('TopMenuSection', () => {
describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => {
mockData.isDesktop = true
const wrapper = createWrapper()
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
})
@@ -194,7 +203,7 @@ describe('TopMenuSection', () => {
describe('on web platform', () => {
it('should not display CurrentUserButton and not display LoginButton', () => {
const wrapper = createWrapper()
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})

View File

@@ -34,17 +34,7 @@
</Button>
</div>
<div
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
)
"
>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
@@ -55,6 +45,7 @@
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
:has-any-error="hasAnyError"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
@@ -70,7 +61,7 @@
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--share-2] size-4" />
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
@@ -123,7 +114,7 @@
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -145,6 +136,7 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -168,6 +160,7 @@ const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const executionErrorStore = useExecutionErrorStore()
const actionBarButtonStore = useActionBarButtonStore()
const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
@@ -182,8 +175,45 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
/**
* Whether the actionbar container has any visible docked buttons
* (excluding ComfyActionbar, which uses position:fixed when floating
* and does not contribute to the container's visual layout).
*/
const hasDockedButtons = computed(() => {
if (actionBarButtonStore.buttons.length > 0) return true
if (hasLegacyContent.value) return true
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
if (isDesktop && !isIntegratedTabBar.value) return true
if (isCloud && flags.workflowSharingEnabled) return true
if (!isRightSidePanelOpen.value) return true
return false
})
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
const actionbarContainerClass = computed(() => {
const base =
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
if (isActionbarContainerEmpty.value) {
return cn(
base,
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
}
const borderClass =
!isActionbarFloating.value && hasAnyError.value
? 'border-destructive-background-hover'
: 'border-interface-stroke'
return cn(base, 'px-2', borderClass)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
@@ -233,6 +263,25 @@ const rightSidePanelTooltipConfig = computed(() =>
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)
function checkLegacyContent() {
const el = legacyCommandsContainerRef.value
if (!el) {
hasLegacyContent.value = false
return
}
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
hasLegacyContent.value =
el.querySelector(':scope > * > *:not(:empty)') !== null
}
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
childList: true,
subtree: true,
characterData: true
})
onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'

View File

@@ -119,9 +119,14 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
const {
topMenuContainer,
queueOverlayExpanded = false,
hasAnyError = false
} = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
hasAnyError?: boolean
}>()
const emit = defineEmits<{
@@ -435,7 +440,12 @@ const panelClass = computed(() =>
isDragging.value && 'pointer-events-none select-none',
isDocked.value
? 'static border-none bg-transparent p-0'
: 'fixed shadow-interface'
: [
'fixed shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
]
)
)
</script>

View File

@@ -46,71 +46,74 @@ function showApps() {
</script>
<template>
<div class="pointer-events-auto flex flex-col gap-2">
<WorkflowActionsDropdown source="app_mode_toolbar" />
<Button
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:disabled="!hasNodes"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilder"
>
<i class="icon-[lucide--hammer] size-4" />
</Button>
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.right="{
value: t('actionbar.shareTooltip'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:aria-label="t('actionbar.shareTooltip')"
class="size-10 rounded-lg"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--send] size-4" />
</Button>
<div
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
>
<div class="pointer-events-auto flex flex-row items-start gap-2">
<div class="pointer-events-auto flex flex-col gap-2">
<Button
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('sideToolbar.mediaAssets.title'),
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
}"
variant="textonly"
variant="secondary"
size="unset"
:aria-label="t('sideToolbar.mediaAssets.title')"
:class="
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
"
@click="openAssets"
:disabled="!hasNodes"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilder"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
<i class="icon-[lucide--hammer] size-4" />
</Button>
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.apps'),
value: t('actionbar.shareTooltip'),
...tooltipOptions
}"
variant="textonly"
variant="secondary"
size="unset"
:aria-label="t('linearMode.appModeToolbar.apps')"
:class="cn('size-10', isAppsActive && 'bg-secondary-background-hover')"
@click="showApps"
:aria-label="t('actionbar.shareTooltip')"
class="size-10 rounded-lg"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--panels-top-left] size-4" />
<i class="icon-[lucide--send] size-4" />
</Button>
<div
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
>
<Button
v-tooltip.right="{
value: t('sideToolbar.mediaAssets.title'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('sideToolbar.mediaAssets.title')"
:class="
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
"
@click="openAssets"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
<Button
v-tooltip.right="{
value: t('linearMode.appModeToolbar.apps'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('linearMode.appModeToolbar.apps')"
:class="
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
"
@click="showApps"
>
<i class="icon-[lucide--panels-top-left] size-4" />
</Button>
</div>
</div>
<WorkflowActionsDropdown source="app_mode_toolbar" />
</div>
</template>

View File

@@ -8,9 +8,9 @@ import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import {
LGraphEventMode,
@@ -25,10 +25,10 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { promptRenameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -53,18 +53,15 @@ workflowStore.activeWorkflow?.changeTracker?.reset()
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const node = resolveNode(nodeId)
if (!node) return null
const widget = node.widgets?.find((w) => w.name === widgetName)
return { nodeId, widgetName, node, widget }
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return node ? { nodeId, widgetName, node, widget } : null
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = resolveNode(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) {
return {
nodeId,
@@ -73,15 +70,12 @@ const inputsWithState = computed(() =>
}
}
const input = node.inputs.find((i) => i.widget?.name === widget.name)
const rename = input && (() => renameWidget(widget, input))
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
rename
rename: () => promptRenameWidget(widget, node, t)
}
})
)
@@ -92,20 +86,6 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
])
)
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
const newLabel = await useDialogService().prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
widget.label = newLabel || undefined
input.label = newLabel || undefined
widget.callback?.(widget.value)
useCanvasStore().canvas?.setDirty(true)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
@@ -126,7 +106,7 @@ function getHovered(
function getBounding(nodeId: NodeId, widgetName?: string) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const node = app.rootGraph.getNodeById(nodeId)
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node) return
const titleOffset =
@@ -139,7 +119,6 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) return
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
@@ -162,7 +141,11 @@ function handleDown(e: MouseEvent) {
}
function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? []
if (node?.mode !== LGraphEventMode.ALWAYS)
if (
node?.mode !== LGraphEventMode.ALWAYS ||
!nodeTypeValidForApp(node.type) ||
node.has_errors
)
return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
@@ -174,12 +157,16 @@ function handleClick(e: MouseEvent) {
else appModeStore.selectedOutputs.splice(index, 1)
return
}
if (!isSelectInputsMode.value) return
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
else appModeStore.selectedInputs.splice(index, 1)
}
@@ -198,7 +185,9 @@ const renderedOutputs = computed(() => {
return canvas
.graph!.nodes.filter(
(n) =>
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
n.constructor.nodeData?.output_node &&
n.mode === LGraphEventMode.ALWAYS &&
!n.has_errors
)
.map(nodeToDisplayTuple)
})
@@ -260,7 +249,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
<i class="icon-[lucide--info] bg-muted-foreground" />
</div>
</template>
<template #empty>
@@ -315,7 +304,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
<i class="icon-[lucide--info] bg-muted-foreground" />
</div>
</template>
<template #empty>

View File

@@ -72,7 +72,7 @@ const menuItems = computed(() => [
},
{
label: t('builderMenu.exitAppBuilder'),
icon: 'icon-[lucide--square-pen]',
icon: 'icon-[lucide--x]',
action: onExitBuilder
}
])

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
@@ -7,6 +7,13 @@ import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
const titleTooltip = ref<string | null>(null)
const subTitleTooltip = ref<string | null>(null)
function isTruncated(e: MouseEvent): boolean {
const el = e.currentTarget as HTMLElement
return el.scrollWidth > el.clientWidth
}
const { rename, remove } = defineProps<{
title: string
subTitle?: string
@@ -32,15 +39,28 @@ const entries = computed(() => {
})
</script>
<template>
<div class="my-2 flex items-center-safe gap-2 rounded-lg p-2">
<div
class="drag-handle mr-auto inline max-w-max min-w-0 flex-[4_1_0%] truncate"
v-text="title"
/>
<div
class="drag-handle inline max-w-max min-w-0 flex-[2_1_0%] truncate text-end text-muted-foreground"
v-text="subTitle"
/>
<div
class="my-2 flex items-center-safe gap-2 rounded-lg p-2"
data-testid="builder-io-item"
>
<div class="drag-handle mr-auto flex min-w-0 flex-col gap-1">
<div
v-tooltip.left="{ value: titleTooltip, showDelay: 300 }"
class="drag-handle truncate text-sm"
data-testid="builder-io-item-title"
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
v-text="title"
/>
<div
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"
class="drag-handle truncate text-xs text-muted-foreground"
data-testid="builder-io-item-subtitle"
@mouseenter="
subTitleTooltip = isTruncated($event) ? (subTitle ?? null) : null
"
v-text="subTitle"
/>
</div>
<Popover :entries>
<template #button>
<Button variant="muted-textonly">

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
DialogTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
defineProps<{ title?: string; to?: string | HTMLElement }>()
const { t } = useI18n()
</script>
<template>
<DialogRoot v-slot="{ close }">
<DialogTrigger as-child>
<slot name="button" />
</DialogTrigger>
<DialogPortal :to>
<DialogOverlay
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-black/70"
/>
<DialogContent
v-bind="$attrs"
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
>
<div
v-if="title"
class="flex w-full items-center justify-between border-b border-border-subtle px-4"
>
<DialogTitle class="text-sm">{{ title }}</DialogTitle>
<DialogClose as-child>
<Button
:aria-label="t('g.close')"
size="icon"
variant="muted-textonly"
>
<i class="icon-[lucide--x]" />
</Button>
</DialogClose>
</div>
<slot :close />
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -54,11 +54,12 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5" :class="item.icon" />
{{ item.label }}
<i class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<div
v-if="item.new"
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
v-else-if="item.new"
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
v-text="t('contextMenu.new')"
/>
</DropdownMenuItem>

View File

@@ -27,7 +27,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const itemClass = computed(() =>
cn(
'm-1 flex cursor-pointer gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
'm-1 flex cursor-pointer items-center-safe gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
itemProp
)
)

View File

@@ -33,19 +33,20 @@
spellcheck="false"
@blur="handleBlur"
@keyup.enter="handleBlur"
@dragstart.prevent
@keydown.up.prevent="updateValueBy(step)"
@keydown.down.prevent="updateValueBy(-step)"
@keydown.page-up.prevent="updateValueBy(10 * step)"
@keydown.page-down.prevent="updateValueBy(-10 * step)"
/>
<div
ref="swipeElement"
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize',
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
textEdit && 'pointer-events-none hidden'
)
"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="resetDrag"
/>
</div>
<slot />
@@ -65,7 +66,7 @@
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -73,8 +74,8 @@ import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
min,
max,
min = -Number.MAX_VALUE,
max = Number.MAX_VALUE,
step = 1,
disabled = false,
hideButtons = false,
@@ -96,6 +97,7 @@ const modelValue = defineModel<number>({ default: 0 })
const container = useTemplateRef<HTMLDivElement>('container')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const swipeElement = useTemplateRef('swipeElement')
const textEdit = ref(false)
onClickOutside(container, () => {
@@ -103,21 +105,11 @@ onClickOutside(container, () => {
})
function clamp(value: number): number {
const lo = min ?? -Infinity
const hi = max ?? Infinity
return Math.min(hi, Math.max(lo, value))
return Math.min(max, Math.max(min, 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)
const canDecrement = computed(() => modelValue.value > min && !disabled)
const canIncrement = computed(() => modelValue.value < max && !disabled)
function handleBlur(e: Event) {
const target = e.target as HTMLInputElement
@@ -135,41 +127,27 @@ function handleBlur(e: Event) {
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)
}
let dragDelta = 0
function handlePointerUp() {
if (!dragging.value) return
if (isSwiping.value) return
if (!hasDragged.value) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
resetDrag()
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
function resetDrag() {
dragging.value = false
dragDelta.value = 0
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
onSwipeEnd: () => (dragDelta = 0)
})
whenever(distanceX, () => {
if (disabled) return
const delta = ((distanceX.value - dragDelta) / 10) | 0
dragDelta += delta * 10
modelValue.value = clamp(modelValue.value - delta * step)
})
function updateValueBy(delta: number) {
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
}
</script>

View File

@@ -1,95 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
component: Omit<ComponentExposed<C>, 'focus'>
}
const meta: GenericMeta<typeof SearchBox> = {
title: 'Components/Input/SearchBox',
component: SearchBox,
tags: ['autodocs'],
argTypes: {
modelValue: {
control: 'text'
},
placeholder: {
control: 'text'
},
showBorder: {
control: 'boolean',
description: 'Toggle border prop'
},
size: {
control: 'select',
options: ['md', 'lg'],
description: 'Size variant of the search box'
},
'onUpdate:modelValue': { action: 'update:modelValue' },
onSearch: { action: 'search' }
},
args: {
modelValue: '',
placeholder: 'Search...',
showBorder: false,
size: 'md'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { SearchBox },
setup() {
const searchText = ref('')
return { searchText, args }
},
template: `
<div style="max-width: 320px;">
<SearchBox v-bind="args" v-model="searchText" />
</div>
`
})
}
export const WithBorder: Story = {
...Default,
args: {
showBorder: true
}
}
export const NoBorder: Story = {
...Default,
args: {
showBorder: false
}
}
export const MediumSize: Story = {
...Default,
args: {
size: 'md',
showBorder: false
}
}
export const LargeSize: Story = {
...Default,
args: {
size: 'lg',
showBorder: false
}
}
export const LargeSizeWithBorder: Story = {
...Default,
args: {
size: 'lg',
showBorder: true
}
}

View File

@@ -1,193 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templateWidgets: {
sort: {
searchPlaceholder: 'Search...'
}
}
}
}
})
describe('SearchBox', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllMocks()
})
const createWrapper = (props = {}) => {
return mount(SearchBox, {
props: {
modelValue: '',
...props
},
global: {
plugins: [i18n]
}
})
}
describe('debounced search functionality', () => {
it('should debounce search input by 300ms', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
// Type search query
await input.setValue('test')
// Model should not update immediately
expect(wrapper.emitted('search')).toBeFalsy()
// Advance timers by 299ms (just before debounce delay)
await vi.advanceTimersByTimeAsync(299)
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
// Advance timers by 1ms more (reaching 300ms)
await vi.advanceTimersByTimeAsync(1)
await nextTick()
// Model should now be updated
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test'])
})
it('should reset debounce timer on each keystroke', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
// Type first character
await input.setValue('t')
vi.advanceTimersByTime(200)
await nextTick()
// Type second character (should reset timer)
await input.setValue('te')
vi.advanceTimersByTime(200)
await nextTick()
// Type third character (should reset timer again)
await input.setValue('tes')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
// Should not have emitted yet (only 200ms passed since last keystroke)
expect(wrapper.emitted('search')).toBeFalsy()
// Advance final 100ms to reach 300ms
await vi.advanceTimersByTimeAsync(100)
await nextTick()
// Should now emit with final value
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')?.[0]).toEqual(['tes', []])
})
it('should only emit final value after rapid typing', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
// Simulate rapid typing
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
for (const term of searchTerms) {
await input.setValue(term)
await vi.advanceTimersByTimeAsync(50) // Less than debounce delay
}
await nextTick()
// Should not have emitted yet
expect(wrapper.emitted('search')).toBeFalsy()
// Complete the debounce delay
await vi.advanceTimersByTimeAsync(350)
await nextTick()
// Should emit only once with final value
expect(wrapper.emitted('search')).toHaveLength(1)
expect(wrapper.emitted('search')?.[0]).toEqual(['search', []])
})
describe('bidirectional model sync', () => {
it('should sync external model changes to internal state', async () => {
const wrapper = createWrapper({ modelValue: 'initial' })
const input = wrapper.find('input')
expect(input.element.value).toBe('initial')
// Update model externally
await wrapper.setProps({ modelValue: 'external update' })
await nextTick()
// Internal state should sync
expect(input.element.value).toBe('external update')
})
})
describe('placeholder', () => {
it('should use custom placeholder when provided', () => {
const wrapper = createWrapper({ placeholder: 'Custom search...' })
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Custom search...')
expect(input.attributes('aria-label')).toBe('Custom search...')
})
it('should use default placeholder when not provided', () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Search...')
expect(input.attributes('aria-label')).toBe('Search...')
})
})
describe('autofocus', () => {
it('should focus input when autofocus is true', async () => {
const wrapper = createWrapper({ autofocus: true })
await nextTick()
const input = wrapper.find('input')
const inputElement = input.element as HTMLInputElement
// Note: In JSDOM, focus() doesn't actually set document.activeElement
// We can only verify that the focus method exists and doesn't throw
expect(inputElement.focus).toBeDefined()
})
it('should not autofocus when autofocus is false', () => {
const wrapper = createWrapper({ autofocus: false })
const input = wrapper.find('input')
expect(document.activeElement).not.toBe(input.element)
})
})
describe('click to focus', () => {
it('should focus input when wrapper is clicked', async () => {
const wrapper = createWrapper()
const wrapperDiv = wrapper.find('[class*="flex"]')
await wrapperDiv.trigger('click')
await nextTick()
// Input should receive focus
const input = wrapper.find('input').element as HTMLInputElement
expect(input.focus).toBeDefined()
})
})
})
})

View File

@@ -1,139 +0,0 @@
<template>
<div
:class="
cn(
'relative flex w-full cursor-text items-center gap-2 bg-comfy-input text-comfy-input-foreground',
customClass,
wrapperStyle
)
"
>
<InputText
ref="inputRef"
v-model="modelValue"
:placeholder
:autofocus
unstyled
:class="
cn(
'absolute inset-0 size-full border-none bg-transparent text-sm outline-none',
isLarge ? 'pl-11' : 'pl-8'
)
"
:aria-label="placeholder"
/>
<Button
v-if="filterIcon"
size="icon"
variant="textonly"
class="filter-button absolute inset-y-0 right-0 m-0 p-0"
@click="$emit('showFilter', $event)"
>
<i :class="filterIcon" />
</Button>
<InputIcon v-if="!modelValue" :class="icon" />
<Button
v-if="modelValue"
:class="cn('clear-button absolute', isLarge ? 'left-2' : 'left-0')"
variant="textonly"
size="icon"
@click="modelValue = ''"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
<div v-if="filters?.length" class="search-filters flex flex-wrap gap-2 pt-2">
<SearchFilterChip
v-for="filter in filters"
:key="filter.id"
:text="filter.text"
:badge="filter.badge"
:badge-class="filter.badgeClass"
@remove="$emit('removeFilter', filter)"
/>
</div>
</template>
<script setup lang="ts" generic="TFilter extends SearchFilter">
import { cn } from '@comfyorg/tailwind-utils'
import { watchDebounced } from '@vueuse/core'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { SearchFilter } from './SearchFilterChip.vue'
import SearchFilterChip from './SearchFilterChip.vue'
const {
placeholder = 'Search...',
icon = 'pi pi-search',
debounceTime = 300,
filterIcon,
filters = [],
autofocus = false,
showBorder = false,
size = 'md',
class: customClass
} = defineProps<{
placeholder?: string
icon?: string
debounceTime?: number
filterIcon?: string
filters?: TFilter[]
autofocus?: boolean
showBorder?: boolean
size?: 'md' | 'lg'
class?: string
}>()
const isLarge = computed(() => size === 'lg')
const emit = defineEmits<{
(e: 'search', value: string, filters: TFilter[]): void
(e: 'showFilter', event: Event): void
(e: 'removeFilter', filter: TFilter): void
}>()
const modelValue = defineModel<string>({ required: true })
const inputRef = ref()
defineExpose({
focus: () => {
inputRef.value?.$el?.focus()
}
})
watchDebounced(
modelValue,
(value: string) => {
emit('search', value, filters)
},
{ debounce: debounceTime }
)
const wrapperStyle = computed(() => {
if (showBorder) {
return cn(
'box-border rounded-sm border border-solid border-border-default p-2',
isLarge.value ? 'h-10' : 'h-8'
)
}
// Size-specific classes matching button sizes for consistency
const sizeClasses = {
md: 'h-8 px-2 py-1.5', // Matches button sm size
lg: 'h-10 px-4 py-2' // Matches button md size
}[size]
return cn('rounded-lg', sizeClasses)
})
</script>
<style scoped>
:deep(.p-inputtext) {
--p-form-field-padding-x: 0.625rem;
}
</style>

View File

@@ -1,90 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SearchBoxV2 from './SearchBoxV2.vue'
vi.mock('@vueuse/core', () => ({
watchDebounced: vi.fn(() => vi.fn())
}))
describe('SearchBoxV2', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
clear: 'Clear',
searchPlaceholder: 'Search...'
}
}
}
})
function mountComponent(props = {}) {
return mount(SearchBoxV2, {
global: {
plugins: [i18n],
stubs: {
ComboboxRoot: {
template: '<div><slot /></div>'
},
ComboboxAnchor: {
template: '<div><slot /></div>'
},
ComboboxInput: {
template:
'<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['placeholder', 'modelValue', 'autoFocus']
}
}
},
props: {
modelValue: '',
...props
}
})
}
it('uses i18n placeholder when no placeholder prop provided', () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Search...')
})
it('uses custom placeholder when provided', () => {
const wrapper = mountComponent({
placeholder: 'Custom placeholder'
})
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Custom placeholder')
})
it('shows search icon when search term is empty', () => {
const wrapper = mountComponent({ modelValue: '' })
expect(wrapper.find('i.icon-\\[lucide--search\\]').exists()).toBe(true)
})
it('shows clear button when search term is not empty', () => {
const wrapper = mountComponent({ modelValue: 'test' })
expect(wrapper.find('button').exists()).toBe(true)
})
it('clears search term when clear button is clicked', async () => {
const wrapper = mountComponent({ modelValue: 'test' })
const clearButton = wrapper.find('button')
await clearButton.trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
})
it('applies large size classes when size is lg', () => {
const wrapper = mountComponent({ size: 'lg' })
expect(wrapper.html()).toContain('size-5')
})
it('applies medium size classes when size is md', () => {
const wrapper = mountComponent({ size: 'md' })
expect(wrapper.html()).toContain('size-4')
})
})

View File

@@ -1,117 +0,0 @@
<template>
<div class="flex flex-auto flex-col gap-2">
<ComboboxRoot :ignore-filter="true" :open="false">
<ComboboxAnchor
:class="
cn(
'relative flex w-full cursor-text items-center',
'rounded-lg bg-comfy-input text-comfy-input-foreground',
showBorder &&
'box-border border border-solid border-border-default',
sizeClasses,
className
)
"
>
<i
v-if="!searchTerm"
:class="cn('pointer-events-none absolute left-4', icon, iconClass)"
/>
<Button
v-else
class="absolute left-2"
variant="textonly"
size="icon"
:aria-label="$t('g.clear')"
@click="clearSearch"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<ComboboxInput
ref="inputRef"
v-model="searchTerm"
:class="
cn(
'size-full border-none bg-transparent text-sm outline-none',
inputPadding
)
"
:placeholder="placeholderText"
:auto-focus="autofocus"
/>
</ComboboxAnchor>
</ComboboxRoot>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import { watchDebounced } from '@vueuse/core'
import { ComboboxAnchor, ComboboxInput, ComboboxRoot } from 'reka-ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
const {
placeholder,
icon = 'icon-[lucide--search]',
debounceTime = 300,
autofocus = false,
showBorder = false,
size = 'md',
class: className
} = defineProps<{
placeholder?: string
icon?: string
debounceTime?: number
autofocus?: boolean
showBorder?: boolean
size?: 'md' | 'lg'
class?: string
}>()
const emit = defineEmits<{
search: [value: string]
}>()
const searchTerm = defineModel<string>({ required: true })
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
defineExpose({
focus: () => {
inputRef.value?.$el?.focus()
}
})
const isLarge = computed(() => size === 'lg')
const placeholderText = computed(
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
)
const sizeClasses = computed(() => {
if (showBorder) {
return isLarge.value ? 'h-10 p-2' : 'h-8 p-2'
}
return isLarge.value ? 'h-12 px-4 py-2' : 'h-10 px-4 py-2'
})
const iconClass = computed(() => (isLarge.value ? 'size-5' : 'size-4'))
const inputPadding = computed(() => (isLarge.value ? 'pl-8' : 'pl-6'))
function clearSearch() {
searchTerm.value = ''
}
watchDebounced(
searchTerm,
(value: string) => {
emit('search', value)
},
{ debounce: debounceTime }
)
</script>

View File

@@ -1,6 +1,6 @@
<template>
<Avatar
class="bg-interface-panel-selected-surface"
class="aspect-square bg-interface-panel-selected-surface"
:image="photoUrl ?? undefined"
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
:pt:icon:class="{ 'size-4': !hasAvatar }"

View File

@@ -155,6 +155,93 @@ describe('VirtualGrid', () => {
wrapper.unmount()
})
it('emits approach-end for single-column list when scrolled near bottom', async () => {
const items = createItems(50)
mockedWidth.value = 400
mockedHeight.value = 600
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)'
},
defaultItemHeight: 48,
defaultItemWidth: 200,
maxColumns: 1,
bufferRows: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
expect(wrapper.emitted('approach-end')).toBeUndefined()
// Scroll near the end: 50 items * 48px = 2400px total
// viewRows = ceil(600/48) = 13, buffer = 1
// Need toCol >= items.length - cols*bufferRows = 50 - 1 = 49
// toCol = (offsetRows + bufferRows + viewRows) * cols
// offsetRows = floor(scrollY / 48)
// Need (offsetRows + 1 + 13) * 1 >= 49 → offsetRows >= 35
// scrollY = 35 * 48 = 1680
mockedScrollY.value = 1680
await nextTick()
expect(wrapper.emitted('approach-end')).toBeDefined()
wrapper.unmount()
})
it('does not emit approach-end without maxColumns in single-column layout', async () => {
// Demonstrates the bug: without maxColumns=1, cols is calculated
// from width/itemWidth (400/200 = 2), causing incorrect row math
const items = createItems(50)
mockedWidth.value = 400
mockedHeight.value = 600
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)'
},
defaultItemHeight: 48,
defaultItemWidth: 200,
// No maxColumns — cols will be floor(400/200) = 2
bufferRows: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
// Same scroll position as the passing test
mockedScrollY.value = 1680
await nextTick()
// With cols=2, toCol = (35+1+13)*2 = 98, which exceeds items.length (50)
// remainingCol = 50-98 = -48, hasMoreToRender = false → isNearEnd = false
// The approach-end never fires at the correct scroll position
expect(wrapper.emitted('approach-end')).toBeUndefined()
wrapper.unmount()
})
it('forces cols to maxColumns when maxColumns is finite', async () => {
mockedWidth.value = 100
mockedHeight.value = 200

View File

@@ -14,10 +14,10 @@
</template>
<template #header>
<SearchBox
<SearchInput
v-model="searchQuery"
size="lg"
class="max-w-[384px]"
class="max-w-96 flex-1"
autofocus
/>
</template>
@@ -178,7 +178,7 @@
v-show="isTemplateVisibleOnDistribution(template)"
:key="template.name"
ref="cardRefs"
size="compact"
size="tall"
variant="ghost"
rounded="lg"
:data-testid="`template-workflow-${template.name}`"
@@ -318,6 +318,20 @@
</Button>
</div>
</div>
<div class="flex">
<span
class="text-neutral flex items-center gap-1.5 text-xs font-bold"
>
<template v-if="isAppTemplate(template)">
<i class="icon-[lucide--panels-top-left]" />
{{ $t('builderToolbar.app', 'App') }}
</template>
<template v-else>
<i class="icon-[lucide--workflow]" />
{{ $t('builderToolbar.nodeGraph', 'Node Graph') }}
</template>
</span>
</div>
</div>
</CardBottom>
</template>
@@ -389,7 +403,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
@@ -483,6 +497,8 @@ const {
const getEffectiveSourceModule = (template: TemplateInfo) =>
template.sourceModule || 'default'
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
const getBaseThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')

View File

@@ -138,8 +138,7 @@ onMounted(async () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToFetchLogs'),
life: 5000
detail: t('toastMessages.failedToFetchLogs')
})
}
})

View File

@@ -275,8 +275,7 @@ async function handleBuy() {
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage })
})
} finally {
loading.value = false

View File

@@ -1,6 +1,6 @@
<template>
<div class="keybinding-panel flex flex-col gap-2">
<SearchBox
<SearchInput
v-model="filters['global'].value"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
/>
@@ -155,7 +155,7 @@ import { useToast } from 'primevue/usetoast'
import { computed, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'

View File

@@ -98,8 +98,7 @@ async function onConfirmCancel() {
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
detail: error instanceof Error ? error.message : t('g.unknownError')
})
} finally {
isLoading.value = false

View File

@@ -50,7 +50,9 @@
{{ t('g.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="seeErrors">
{{ t('errorOverlay.seeErrors') }}
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}
</Button>
</div>
</div>
@@ -69,6 +71,8 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
@@ -94,6 +98,7 @@ function dismiss() {
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()

View File

@@ -579,8 +579,7 @@ const onUpdateComfyUI = async (): Promise<void> => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error.value || t('helpCenter.updateComfyUIFailed'),
life: 5000
detail: error.value || t('helpCenter.updateComfyUIFailed')
})
return
}
@@ -597,8 +596,7 @@ const onUpdateComfyUI = async (): Promise<void> => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: err instanceof Error ? err.message : t('g.unknownError'),
life: 5000
detail: err instanceof Error ? err.message : t('g.unknownError')
})
}
}

View File

@@ -5,11 +5,8 @@
v-if="isHelpCenterVisible"
class="help-center-popup"
:class="{
'sidebar-left':
triggerLocation === 'sidebar' && sidebarLocation === 'left',
'sidebar-right':
triggerLocation === 'sidebar' && sidebarLocation === 'right',
'topbar-right': triggerLocation === 'topbar',
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
>
@@ -63,7 +60,6 @@ const { isSmall = false } = defineProps<{
const {
isHelpCenterVisible,
triggerLocation,
sidebarLocation,
closeHelpCenter,
handleWhatsNewDismissed
@@ -101,25 +97,6 @@ const {
right: 1rem;
}
.help-center-popup.topbar-right {
top: 2rem;
right: 1rem;
bottom: auto;
animation: slideInDown 0.2s ease-out;
}
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInUp {
from {
opacity: 0;

View File

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

View File

@@ -1,73 +1,33 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { MultiSelectProps } from 'primevue/multiselect'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
import type { SelectOption } from './types'
// Combine our component props with PrimeVue MultiSelect props
// Since we use v-bind="$attrs", all PrimeVue props are available
interface ExtendedProps extends Partial<MultiSelectProps> {
// Our custom props
label?: string
showSearchBox?: boolean
showSelectedCount?: boolean
showClearButton?: boolean
searchPlaceholder?: string
listMaxHeight?: string
popoverMinWidth?: string
popoverMaxWidth?: string
// Override modelValue type to match our Option type
modelValue?: SelectOption[]
}
const meta: Meta<ExtendedProps> = {
title: 'Components/Input/MultiSelect',
const meta: Meta<typeof MultiSelect> = {
title: 'Components/Select/MultiSelect',
component: MultiSelect,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
() => ({
template: '<div class="pt-4"><story /></div>'
})
],
argTypes: {
label: {
control: 'text'
label: { control: 'text' },
size: {
control: { type: 'select' },
options: ['lg', 'md']
},
options: {
control: 'object'
},
showSearchBox: {
control: 'boolean',
description: 'Toggle searchBar visibility'
},
showSelectedCount: {
control: 'boolean',
description: 'Toggle selected count visibility'
},
showClearButton: {
control: 'boolean',
description: 'Toggle clear button visibility'
},
searchPlaceholder: {
control: 'text'
},
listMaxHeight: {
control: 'text',
description: 'Maximum height of the dropdown list'
},
popoverMinWidth: {
control: 'text',
description: 'Minimum width of the popover'
},
popoverMaxWidth: {
control: 'text',
description: 'Maximum width of the popover'
}
showSearchBox: { control: 'boolean' },
showSelectedCount: { control: 'boolean' },
showClearButton: { control: 'boolean' },
searchPlaceholder: { control: 'text' }
},
args: {
label: 'Select',
options: [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
],
label: 'Category',
size: 'lg',
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
@@ -78,352 +38,125 @@ const meta: Meta<ExtendedProps> = {
export default meta
type Story = StoryObj<typeof meta>
const sampleOptions: SelectOption[] = [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
]
export const Default: Story = {
render: (args) => ({
components: { MultiSelect },
setup() {
const selected = ref([])
const options = args.options || [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
]
return { selected, options, args }
const selected = ref<SelectOption[]>([])
return { selected, sampleOptions, args }
},
template: `
<div>
<MultiSelect
v-model="selected"
:options="options"
:label="args.label"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<div class="mt-4 p-3 bg-base-background rounded">
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
</div>
</div>
`
template:
'<MultiSelect v-model="selected" :options="sampleOptions" :label="args.label" :size="args.size" :show-search-box="args.showSearchBox" :show-selected-count="args.showSelectedCount" :show-clear-button="args.showClearButton" />'
})
}
export const WithPreselectedValues: Story = {
render: (args) => ({
export const MediumSize: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const options = args.options || [
{ name: 'JavaScript', value: 'js' },
{ name: 'TypeScript', value: 'ts' },
{ name: 'Python', value: 'python' },
{ name: 'Go', value: 'go' },
{ name: 'Rust', value: 'rust' }
]
const selected = ref([options[0], options[1]])
return { selected, options, args }
const selected = ref<SelectOption[]>([sampleOptions[0]])
return { selected, sampleOptions }
},
template: `
<div>
<MultiSelect
v-model="selected"
:options="options"
:label="args.label"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<div class="mt-4 p-3 bg-base-background rounded">
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
</div>
</div>
`
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" size="md" />'
}),
args: {
label: 'Select Languages',
options: [
{ name: 'JavaScript', value: 'js' },
{ name: 'TypeScript', value: 'ts' },
{ name: 'Python', value: 'python' },
{ name: 'Go', value: 'go' },
{ name: 'Rust', value: 'rust' }
],
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
searchPlaceholder: 'Search...'
}
parameters: { controls: { disable: true } }
}
export const MultipleSelectors: Story = {
export const WithPreselectedValues: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([sampleOptions[0], sampleOptions[1]])
return { selected, sampleOptions }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" />'
}),
parameters: { controls: { disable: true } }
}
export const Disabled: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([sampleOptions[0]])
return { selected, sampleOptions }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" disabled />'
}),
parameters: { controls: { disable: true } }
}
export const WithSearchBox: Story = {
args: { showSearchBox: true },
render: (args) => ({
components: { MultiSelect },
setup() {
const frameworkOptions = ref([
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
])
const selected = ref<SelectOption[]>([])
return { selected, sampleOptions, args }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" :show-search-box="args.showSearchBox" />'
})
}
const projectOptions = ref([
{ name: 'Project A', value: 'proj-a' },
{ name: 'Project B', value: 'proj-b' },
{ name: 'Project C', value: 'proj-c' },
{ name: 'Project D', value: 'proj-d' }
])
export const AllHeaderFeatures: Story = {
args: {
showSearchBox: true,
showSelectedCount: true,
showClearButton: true
},
render: (args) => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([])
return { selected, sampleOptions, args }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" :show-search-box="args.showSearchBox" :show-selected-count="args.showSelectedCount" :show-clear-button="args.showClearButton" />'
})
}
const tagOptions = ref([
{ name: 'Frontend', value: 'frontend' },
{ name: 'Backend', value: 'backend' },
{ name: 'Database', value: 'database' },
{ name: 'DevOps', value: 'devops' },
{ name: 'Testing', value: 'testing' }
])
const selectedFrameworks = ref([])
const selectedProjects = ref([])
const selectedTags = ref([])
return {
frameworkOptions,
projectOptions,
tagOptions,
selectedFrameworks,
selectedProjects,
selectedTags,
args
}
export const AllStates: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const a = ref<SelectOption[]>([])
const b = ref<SelectOption[]>([sampleOptions[0]])
const c = ref<SelectOption[]>([sampleOptions[0]])
return { sampleOptions, a, b, c }
},
template: `
<div class="space-y-4">
<div class="flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
:options="frameworkOptions"
label="Select Frameworks"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<MultiSelect
v-model="selectedProjects"
:options="projectOptions"
label="Select Projects"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<MultiSelect
v-model="selectedTags"
:options="tagOptions"
label="Select Tags"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<div class="flex flex-col gap-6">
<div>
<p class="mb-2 text-xs text-muted-foreground">Large (Interface)</p>
<div class="flex flex-col gap-3">
<MultiSelect v-model="a" :options="sampleOptions" label="Default" />
<MultiSelect v-model="b" :options="sampleOptions" label="With Selection" />
<MultiSelect v-model="c" :options="sampleOptions" label="Disabled" disabled />
</div>
</div>
<div class="p-4 bg-base-background rounded">
<h4 class="font-medium mt-0">Current Selection:</h4>
<div class="flex flex-col text-sm">
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
<p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p>
<p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
<div>
<p class="mb-2 text-xs text-muted-foreground">Medium (Node)</p>
<div class="flex flex-col gap-3">
<MultiSelect v-model="a" :options="sampleOptions" label="Default" size="md" />
<MultiSelect v-model="b" :options="sampleOptions" label="With Selection" size="md" />
<MultiSelect v-model="c" :options="sampleOptions" label="Disabled" size="md" disabled />
</div>
</div>
</div>
`
}),
args: {
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
searchPlaceholder: 'Search...'
}
}
export const WithSearchBox: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true
}
}
export const WithSelectedCount: Story = {
...Default,
args: {
...Default.args,
showSelectedCount: true
}
}
export const WithClearButton: Story = {
...Default,
args: {
...Default.args,
showClearButton: true
}
}
export const AllHeaderFeatures: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true,
showSelectedCount: true,
showClearButton: true
}
}
export const CustomSearchPlaceholder: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true,
searchPlaceholder: 'Filter packages...'
}
}
export const CustomMaxHeight: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
name: `Option ${i + 1}`,
value: `option${i + 1}`
}))
return { selected1, selected2, selected3, manyOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
<MultiSelect
v-model="selected1"
:options="manyOptions"
label="Small Dropdown"
list-max-height="10rem"
show-selected-count
/>
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
<MultiSelect
v-model="selected2"
:options="manyOptions"
label="Default Dropdown"
list-max-height="28rem"
show-selected-count
/>
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
<MultiSelect
v-model="selected3"
:options="manyOptions"
label="Large Dropdown"
list-max-height="32rem"
show-selected-count
/>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMinWidth: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const options = [
{ name: 'A', value: 'a' },
{ name: 'B', value: 'b' },
{ name: 'Very Long Option Name Here', value: 'long' }
]
return { selected1, selected2, selected3, options }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<MultiSelect v-model="selected1" :options="options" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 18rem</h3>
<MultiSelect v-model="selected2" :options="options" label="Min 18rem" popover-min-width="18rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 28rem</h3>
<MultiSelect v-model="selected3" :options="options" label="Min 28rem" popover-min-width="28rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxWidth: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const longOptions = [
{ name: 'Short', value: 'short' },
{
name: 'This is a very long option name that would normally expand the dropdown',
value: 'long1'
},
{
name: 'Another extremely long option that demonstrates max-width constraint',
value: 'long2'
}
]
return { selected1, selected2, selected3, longOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<MultiSelect v-model="selected1" :options="longOptions" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Max Width 18rem</h3>
<MultiSelect v-model="selected2" :options="longOptions" label="Max 18rem" popover-max-width="18rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min 12rem Max 22rem</h3>
<MultiSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="12rem" popover-max-width="22rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
controls: { disable: true }
}
}

View File

@@ -16,20 +16,23 @@
:pt="{
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'relative inline-flex h-10 cursor-pointer select-none',
'relative inline-flex cursor-pointer select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
selectedCount > 0
? 'border-node-component-border'
: 'border-transparent',
'focus-within:border-node-component-border',
{ 'cursor-default opacity-60': props.disabled }
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
'focus-within:border-base-foreground',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
}),
labelContainer: {
class:
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 '
class: cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
label: {
class: 'p-0'
@@ -93,13 +96,12 @@
#header
>
<div class="flex flex-col px-2 pt-2 pb-0">
<SearchBox
<SearchInput
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:show-order="true"
:show-border="true"
:place-holder="searchPlaceholder"
:placeholder="searchPlaceholder"
size="sm"
/>
<div
v-if="showSelectedCount || showClearButton"
@@ -130,12 +132,12 @@
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span class="text-sm">
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-primary-background text-xs font-semibold text-base-foreground"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
>
{{ selectedCount }}
</span>
@@ -182,7 +184,7 @@ import MultiSelect from 'primevue/multiselect'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
@@ -198,6 +200,8 @@ defineOptions({
interface Props {
/** Input label shown on the trigger button */
label?: string
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
/** Show search box in the panel header */
showSearchBox?: boolean
/** Show selected count text in the panel header */
@@ -217,6 +221,7 @@ interface Props {
}
const {
label,
size = 'lg',
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,

View File

@@ -0,0 +1,219 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
import SingleSelect from './SingleSelect.vue'
import type { SelectOption } from './types'
const meta: Meta = {
title: 'Components/Select/SelectDropdown',
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
() => ({
template: '<div class="pt-4"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
const modelOptions: SelectOption[] = [
{ name: 'ACE-Step', value: 'ace-step' },
{ name: 'Anima', value: 'anima' },
{ name: 'BRIA', value: 'bria' },
{ name: 'ByteDance', value: 'bytedance' },
{ name: 'Capybara', value: 'capybara' },
{ name: 'Chatter Box', value: 'chatter-box' },
{ name: 'Chroma', value: 'chroma' },
{ name: 'ChronoEdit', value: 'chronoedit' },
{ name: 'DWPose', value: 'dwpose' },
{ name: 'Depth Anything v2', value: 'depth-anything-v2' },
{ name: 'ElevenLabs', value: 'elevenlabs' },
{ name: 'Flux', value: 'flux' },
{ name: 'HunyuanVideo', value: 'hunyuan-video' },
{ name: 'Stable Diffusion', value: 'stable-diffusion' },
{ name: 'SDXL', value: 'sdxl' }
]
const useCaseOptions: SelectOption[] = [
{ name: 'Text to Image', value: 'text-to-image' },
{ name: 'Image to Image', value: 'image-to-image' },
{ name: 'Inpainting', value: 'inpainting' },
{ name: 'Upscaling', value: 'upscaling' },
{ name: 'Video Generation', value: 'video-generation' },
{ name: 'Audio Generation', value: 'audio-generation' },
{ name: '3D Generation', value: '3d-generation' }
]
const sortOptions: SelectOption[] = [
{ name: 'Default', value: 'default' },
{ name: 'Recommended', value: 'recommended' },
{ name: 'Popular', value: 'popular' },
{ name: 'Newest', value: 'newest' },
{ name: 'VRAM Usage (Low to High)', value: 'vram-low-to-high' },
{ name: 'Model Size (Low to High)', value: 'model-size-low-to-high' },
{ name: 'Alphabetical (A-Z)', value: 'alphabetical' }
]
export const ModelFilter: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([
modelOptions[1],
modelOptions[2],
modelOptions[3]
])
return { selected, modelOptions }
},
template: `
<MultiSelect
v-model="selected"
:options="modelOptions"
:label="selected.length === 0 ? 'Models' : selected.length === 1 ? selected[0].name : selected.length + ' Models'"
show-search-box
show-selected-count
show-clear-button
class="w-[250px]"
>
<template #icon>
<i class="icon-[lucide--cpu]" />
</template>
</MultiSelect>
`
}),
parameters: { controls: { disable: true } }
}
export const UseCaseFilter: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([])
return { selected, useCaseOptions }
},
template: `
<MultiSelect
v-model="selected"
:options="useCaseOptions"
:label="selected.length === 0 ? 'Use Case' : selected.length === 1 ? selected[0].name : selected.length + ' Use Cases'"
show-search-box
show-selected-count
show-clear-button
>
<template #icon>
<i class="icon-[lucide--target]" />
</template>
</MultiSelect>
`
}),
parameters: { controls: { disable: true } }
}
export const SortDropdown: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | undefined>('default')
return { selected, sortOptions }
},
template: `
<SingleSelect
v-model="selected"
:options="sortOptions"
label="Sort by"
class="w-62.5"
>
<template #icon>
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
</template>
</SingleSelect>
`
}),
parameters: { controls: { disable: true } }
}
export const TemplateFilterBar: Story = {
render: () => ({
components: { MultiSelect, SingleSelect },
setup() {
const selectedModels = ref<SelectOption[]>([
modelOptions[1],
modelOptions[2],
modelOptions[3]
])
const selectedUseCases = ref<SelectOption[]>([])
const sortBy = ref<string | undefined>('default')
const modelLabel = () => {
if (selectedModels.value.length === 0) return 'Models'
if (selectedModels.value.length === 1)
return selectedModels.value[0].name
return selectedModels.value.length + ' Models'
}
const useCaseLabel = () => {
if (selectedUseCases.value.length === 0) return 'Use Case'
if (selectedUseCases.value.length === 1)
return selectedUseCases.value[0].name
return selectedUseCases.value.length + ' Use Cases'
}
return {
selectedModels,
selectedUseCases,
sortBy,
modelOptions,
useCaseOptions,
sortOptions,
modelLabel,
useCaseLabel
}
},
template: `
<div class="flex flex-wrap items-center justify-between gap-2" style="min-width: 700px;">
<div class="flex flex-wrap gap-2">
<MultiSelect
v-model="selectedModels"
:options="modelOptions"
:label="modelLabel()"
show-search-box
show-selected-count
show-clear-button
class="w-[250px]"
>
<template #icon>
<i class="icon-[lucide--cpu]" />
</template>
</MultiSelect>
<MultiSelect
v-model="selectedUseCases"
:options="useCaseOptions"
:label="useCaseLabel()"
show-search-box
show-selected-count
show-clear-button
>
<template #icon>
<i class="icon-[lucide--target]" />
</template>
</MultiSelect>
</div>
<SingleSelect
v-model="sortBy"
:options="sortOptions"
label="Sort by"
class="w-62.5"
>
<template #icon>
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
</template>
</SingleSelect>
</div>
`
}),
parameters: { controls: { disable: true } }
}

View File

@@ -3,29 +3,31 @@ import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue'
// SingleSelect already includes options prop, so no need to extend
const meta: Meta<typeof SingleSelect> = {
title: 'Components/Input/SingleSelect',
title: 'Components/Select/SingleSelect',
component: SingleSelect,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
() => ({
template: '<div class="pt-4"><story /></div>'
})
],
argTypes: {
label: { control: 'text' },
options: { control: 'object' },
listMaxHeight: {
control: 'text',
description: 'Maximum height of the dropdown list'
size: {
control: { type: 'select' },
options: ['lg', 'md']
},
popoverMinWidth: {
control: 'text',
description: 'Minimum width of the popover'
},
popoverMaxWidth: {
control: 'text',
description: 'Maximum width of the popover'
}
invalid: { control: 'boolean' },
loading: { control: 'boolean' }
},
args: {
label: 'Sorting Type',
label: 'Category',
size: 'lg',
invalid: false,
loading: false,
options: [
{ name: 'Popular', value: 'popular' },
{ name: 'Newest', value: 'newest' },
@@ -37,7 +39,7 @@ const meta: Meta<typeof SingleSelect> = {
}
export default meta
export type Story = StoryObj<typeof meta>
type Story = StoryObj<typeof meta>
const sampleOptions = [
{ name: 'Popular', value: 'popular' },
@@ -52,205 +54,118 @@ export const Default: Story = {
components: { SingleSelect },
setup() {
const selected = ref<string | null>(null)
const options = args.options || sampleOptions
return { selected, options, args }
return { selected, args }
},
template: `
<div>
<SingleSelect v-model="selected" :options="options" :label="args.label" />
<div class="mt-4 p-3 bg-base-background rounded">
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
</div>
</div>
`
template:
'<SingleSelect v-model="selected" :options="args.options" :label="args.label" :size="args.size" :invalid="args.invalid" :loading="args.loading" />'
})
}
export const MediumSize: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
return { selected, sampleOptions }
},
template:
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" size="md" />'
}),
parameters: { controls: { disable: true } }
}
export const WithIcon: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
const options = sampleOptions
return { selected, options }
return { selected, sampleOptions }
},
template: `
<div>
<SingleSelect v-model="selected" :options="options" label="Sorting Type">
<template #icon>
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template>
</SingleSelect>
<div class="mt-4 p-3 bg-base-background rounded">
<p class="text-sm">Selected: {{ selected }}</p>
</div>
</div>
<SingleSelect v-model="selected" :options="sampleOptions" label="Sorting Type">
<template #icon>
<i class="icon-[lucide--arrow-up-down] size-3.5" />
</template>
</SingleSelect>
`
})
}),
parameters: { controls: { disable: true } }
}
export const Preselected: Story = {
export const Disabled: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('newest')
const options = sampleOptions
return { selected, options }
const selected = ref<string | null>('popular')
return { selected, sampleOptions }
},
template: `
<SingleSelect v-model="selected" :options="options" label="Sorting Type" />
`
})
template:
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" disabled />'
}),
parameters: { controls: { disable: true } }
}
export const AllVariants: Story = {
export const Invalid: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const options = sampleOptions
const a = ref<string | null>(null)
const selected = ref<string | null>('popular')
return { selected, sampleOptions }
},
template:
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" invalid />'
}),
parameters: { controls: { disable: true } }
}
export const Loading: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
return { selected, sampleOptions }
},
template:
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" loading />'
}),
parameters: { controls: { disable: true } }
}
export const AllStates: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const a = ref<string | null>('popular')
const b = ref<string | null>('popular')
const c = ref<string | null>('az')
return { options, a, b, c }
const c = ref<string | null>('popular')
const d = ref<string | null>('popular')
const e = ref<string | null>('popular')
return { sampleOptions, a, b, c, d, e }
},
template: `
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<SingleSelect v-model="a" :options="options" label="No Icon" />
<div class="flex flex-col gap-6">
<div>
<p class="mb-2 text-xs text-muted-foreground">Large (Interface)</p>
<div class="flex flex-col gap-3">
<SingleSelect v-model="a" :options="sampleOptions" label="Default" />
<SingleSelect v-model="b" :options="sampleOptions" label="Disabled" disabled />
<SingleSelect v-model="c" :options="sampleOptions" label="Invalid" invalid />
<SingleSelect v-model="d" :options="sampleOptions" label="Loading" loading />
</div>
</div>
<div class="flex items-center gap-3">
<SingleSelect v-model="b" :options="options" label="With Icon">
<template #icon>
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template>
</SingleSelect>
</div>
<div class="flex items-center gap-3">
<SingleSelect v-model="c" :options="options" label="Preselected (A→Z)" />
<div>
<p class="mb-2 text-xs text-muted-foreground">Medium (Node)</p>
<div class="flex flex-col gap-3">
<SingleSelect v-model="a" :options="sampleOptions" label="Default" size="md" />
<SingleSelect v-model="b" :options="sampleOptions" label="Disabled" size="md" disabled />
<SingleSelect v-model="c" :options="sampleOptions" label="Invalid" size="md" invalid />
<SingleSelect v-model="e" :options="sampleOptions" label="Loading" size="md" loading />
</div>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxHeight: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>(null)
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
name: `Option ${i + 1}`,
value: `option${i + 1}`
}))
return { selected, manyOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Small Dropdown" list-max-height="10rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Default Dropdown" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Large Dropdown" list-max-height="32rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMinWidth: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected1 = ref<string | null>(null)
const selected2 = ref<string | null>(null)
const selected3 = ref<string | null>(null)
const options = [
{ name: 'A', value: 'a' },
{ name: 'B', value: 'b' },
{ name: 'Very Long Option Name Here', value: 'long' }
]
return { selected1, selected2, selected3, options }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<SingleSelect v-model="selected1" :options="options" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 15rem</h3>
<SingleSelect v-model="selected2" :options="options" label="Min 15rem" popover-min-width="15rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 25rem</h3>
<SingleSelect v-model="selected3" :options="options" label="Min 25rem" popover-min-width="25rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxWidth: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected1 = ref<string | null>(null)
const selected2 = ref<string | null>(null)
const selected3 = ref<string | null>(null)
const longOptions = [
{ name: 'Short', value: 'short' },
{
name: 'This is a very long option name that would normally expand the dropdown',
value: 'long1'
},
{
name: 'Another extremely long option that demonstrates max-width constraint',
value: 'long2'
}
]
return { selected1, selected2, selected3, longOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<SingleSelect v-model="selected1" :options="longOptions" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Max Width 15rem</h3>
<SingleSelect v-model="selected2" :options="longOptions" label="Max 15rem" popover-max-width="15rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min 10rem Max 20rem</h3>
<SingleSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="10rem" popover-max-width="20rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
controls: { disable: true }
}
}

View File

@@ -15,23 +15,26 @@
unstyled
:pt="{
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: [
// container
'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface
class: cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg',
'bg-secondary-background text-base-foreground',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'focus-within:border-node-component-border',
// disabled
{ 'opacity-60 cursor-default': props.disabled }
]
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
invalid
? 'border-destructive-background'
: 'border-transparent focus-within:border-node-component-border',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
}),
label: {
class:
// Align with MultiSelect labelContainer spacing
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
class: cn(
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
dropdown: {
class:
@@ -77,6 +80,8 @@
}
}"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
@@ -84,8 +89,16 @@
>
<!-- Trigger value -->
<template #value="slotProps">
<div class="flex items-center gap-2 text-sm">
<slot name="icon" />
<div
:class="
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
"
>
<i
v-if="loading"
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
/>
<slot v-else name="icon" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-base-foreground"
@@ -98,9 +111,12 @@
</div>
</template>
<!-- Trigger caret -->
<!-- Trigger caret (hidden when loading) -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
<i
v-if="!loading"
class="icon-[lucide--chevron-down] text-muted-foreground"
/>
</template>
<!-- Option row -->
@@ -133,6 +149,9 @@ defineOptions({
const {
label,
options,
size = 'lg',
invalid = false,
loading = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
@@ -144,6 +163,12 @@ const {
* in getLabel() to map values to their display names.
*/
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
/** Show invalid (destructive) border */
invalid?: boolean
/** Show loading spinner instead of chevron */
loading?: boolean
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */

View File

@@ -141,7 +141,7 @@ onMounted(async () => {
if (isStandaloneMode && props.modelUrl) {
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
} else if (props.node) {
const source = useLoad3dService().getLoad3d(props.node)
const source = await useLoad3dService().getLoad3dAsync(props.node)
if (source) {
await viewer.initializeViewer(containerRef.value, source)
}

View File

@@ -72,12 +72,12 @@
/>
</div>
<SliderControl
v-model="brushSize"
v-model="brushSizeSliderValue"
class="flex-1"
label=""
:min="1"
:max="250"
:step="1"
:min="0"
:max="1"
:step="0.001"
/>
</div>
@@ -182,6 +182,26 @@ const brushSize = computed({
set: (value: number) => store.setBrushSize(value)
})
const rawSliderValue = ref<number | null>(null)
const brushSizeSliderValue = computed({
get: () => {
if (rawSliderValue.value !== null) {
const cachedSize = Math.round(Math.pow(250, rawSliderValue.value))
if (cachedSize === brushSize.value) {
return rawSliderValue.value
}
}
return Math.log(brushSize.value) / Math.log(250)
},
set: (value: number) => {
rawSliderValue.value = value
const size = Math.round(Math.pow(250, value))
store.setBrushSize(size)
}
})
const brushOpacity = computed({
get: () => store.brushSettings.opacity,
set: (value: number) => store.setBrushOpacity(value)

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex min-w-0 items-center gap-2">
<SearchBox
<SearchInput
v-if="showSearch"
:model-value="searchQuery"
class="min-w-0 flex-1"
@@ -116,7 +116,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'

View File

@@ -1,5 +1,5 @@
import { computed, reactive, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { computed, reactive, ref, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
@@ -227,7 +227,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
}
export function useErrorGroups(
searchQuery: Ref<string>,
searchQuery: MaybeRefOrGetter<string>,
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
@@ -584,7 +584,7 @@ export function useErrorGroups(
})
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = searchQuery.value.trim()
const query = toValue(searchQuery).trim()
return searchErrorGroups(tabErrorGroups.value, query)
})

View File

@@ -15,10 +15,9 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
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 { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
const {
@@ -42,7 +41,6 @@ const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const dialogService = useDialogService()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
@@ -67,15 +65,8 @@ const isCurrentValueDefault = computed(() => {
})
async function handleRename() {
const newLabel = await dialogService.prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
label.value = newLabel
const newLabel = await promptWidgetLabel(widget, t)
if (newLabel !== null) label.value = newLabel
}
function handleHideInput() {

View File

@@ -11,12 +11,13 @@
}"
@click="onLogoMenuClick($event)"
>
<div class="flex size-8 items-center justify-center rounded-lg bg-black">
<div class="flex items-center gap-0.5">
<ComfyLogo
alt="ComfyUI Logo"
class="comfyui-logo h-[18px] w-[18px] text-white"
class="comfyui-logo h-[18px] w-[18px]"
mode="fill"
/>
<i class="icon-[lucide--chevron-down] size-3 text-muted-foreground" />
</div>
</div>

View File

@@ -41,7 +41,7 @@
v-if="userStore.isMultiUserServer"
:is-small="isSmall"
/>
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
@@ -95,9 +95,6 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const isConnected = computed(
() =>
selectedTab.value ||

View File

@@ -7,7 +7,7 @@
:icon-badge="shouldShowRedDot ? '' : ''"
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
:is-small="isSmall"
@click="toggleHelpCenter"
@click="toggleHelpCenter()"
/>
</template>

View File

@@ -18,6 +18,8 @@
class="flex-1"
:items="assetItems"
:grid-style="listGridStyle"
:max-columns="1"
:default-item-height="48"
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
@@ -33,7 +35,7 @@
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
name: getAssetDisplayName(item.asset),
type: getAssetMediaType(item.asset)
})
"
@@ -44,7 +46,7 @@
)
"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="item.asset.name"
:preview-alt="getAssetDisplayName(item.asset)"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
@@ -133,8 +135,12 @@ const listGridStyle = {
gap: '0.5rem'
}
function getAssetDisplayName(asset: AssetItem): string {
return asset.display_name || asset.name
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
return truncateFilename(getAssetDisplayName(asset))
}
function getAssetMediaType(asset: AssetItem) {

View File

@@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => {
const dialogStore = useDialogStore()
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: asset.name,
title: asset.display_name || asset.name,
component: Load3dViewerContent,
props: {
modelUrl: asset.preview_url || ''
@@ -615,8 +615,7 @@ const enterFolderView = async (asset: AssetItem) => {
toast.add({
severity: 'error',
summary: t('sideToolbar.folderView.errorSummary'),
detail: t('sideToolbar.folderView.errorDetail'),
life: 5000
detail: t('sideToolbar.folderView.errorDetail')
})
exitFolderView()
}
@@ -662,8 +661,7 @@ const copyJobId = async () => {
toast.add({
severity: 'error',
summary: t('mediaAsset.jobIdToast.error'),
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
life: 3000
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed')
})
}
}

View File

@@ -21,7 +21,7 @@
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SearchBox
<SearchInput
ref="searchBoxRef"
v-model:model-value="searchQuery"
class="workflows-search-box"
@@ -146,7 +146,7 @@ import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import TextDivider from '@/components/common/TextDivider.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'

View File

@@ -22,7 +22,7 @@
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SearchBox
<SearchInput
ref="searchBoxRef"
v-model:model-value="searchQuery"
:placeholder="
@@ -56,7 +56,7 @@
import { Divider } from 'primevue'
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'

View File

@@ -86,18 +86,40 @@
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
data-testid="node-library-search"
class="node-lib-search-box"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.nodes') })"
filter-icon="pi pi-filter"
:filters
@search="handleSearch"
@show-filter="($event) => searchFilter?.toggle($event)"
@remove-filter="onRemoveFilter"
/>
<div class="flex items-center gap-1">
<SearchInput
ref="searchBoxRef"
v-model="searchQuery"
data-testid="node-library-search"
class="node-lib-search-box"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.nodes') })
"
@search="handleSearch"
/>
<Button
variant="textonly"
size="icon"
class="filter-button shrink-0"
:aria-label="$t('g.filter')"
@click="(e: Event) => searchFilter?.toggle(e)"
>
<i class="pi pi-filter" />
</Button>
</div>
<div
v-if="filters?.length"
class="search-filters flex flex-wrap gap-2 pt-2"
>
<SearchFilterChip
v-for="filter in filters"
:key="filter.id"
:text="filter.text"
:badge="filter.badge"
:badge-class="filter.badgeClass"
@remove="onRemoveFilter(filter)"
/>
</div>
<Popover ref="searchFilter" class="ml-[-13px]">
<NodeSearchFilter @add-filter="onAddFilter" />
@@ -155,8 +177,9 @@ import {
} from 'vue'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import SearchBox from '@/components/common/SearchBox.vue'
import SearchFilterChip from '@/components/common/SearchFilterChip.vue'
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'

View File

@@ -69,7 +69,7 @@ vi.mock('./nodeLibrary/NodeDragPreview.vue', () => ({
}
}))
vi.mock('@/components/common/SearchBoxV2.vue', () => ({
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
default: {
name: 'SearchBox',
template: '<input data-testid="search-box" />',

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