Compare commits

..

17 Commits

Author SHA1 Message Date
Benjamin Lu
2a6968c3ab fix: use system hour cycle for queue times 2026-05-15 20:05:17 -07:00
Rizumu Ayaka
71092b2011 fix: stop trackpad pinch/swipe gestures from breaking the UI (#12052)
## Summary

On macOS trackpads, several browser default gestures were leaking
through and breaking the workflow:

- **Pinch-zoom on a focused textarea widget** triggered page-level zoom,
pushing `position: fixed` UI (notably `ComfyActionbar`) off-screen until
a reload.
- **Horizontal swipe on a focused textarea widget** triggered browser
back/forward, leaving the workflow.
- **Pinch / horizontal swipe inside the image picker dropdown** had the
same two issues, because PrimeVue `Popover` teleports content to
`document.body` and the `LGraphNode` wheel handler never sees the
events.

Fixes FE-292.

## Why

- **`overscroll-behavior: none` on `html, body`** — horizontal swipe to
back/forward is decided by the browser at gesture start; JS
preventDefault can't reliably beat it. `overscroll-behavior` is the
standards-track signal for opting out, and ComfyUI is a full-screen
editor that never benefits from native overscroll.
- **`useCanvasInteractions` now treats pinch-zoom and
horizontal-dominant wheel as canvas gestures** that override widget
wheel consumption, so the gesture pans/zooms the canvas instead of
falling through to destructive browser defaults. The check is exported
as `isCanvasGestureWheel` for reuse.
- **`FormDropdownMenu` has its own minimal `onWheel`** that only
preventDefaults destructive gestures and deliberately does not forward
to the canvas. The dropdown is its own scroll container and shouldn't
leak interactions into the editor; vertical scrolling stays native.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12052-fix-stop-trackpad-pinch-swipe-gestures-from-breaking-the-UI-3596d73d3650810aac3fcd8a71b29f9e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-15 14:35:54 +00:00
Comfy Org PR Bot
d05ec230bf 1.45.8 (#12282)
Patch version increment to 1.45.8

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12282-1-45-8-3616d73d365081efa628de04190d2a92)
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-05-15 02:07:09 +00:00
Dante
d6f632477f feat(dialog): migrate Error / NodeSearchBox / SecretForm / VideoHelp / Customization to Reka-UI (Phase 2) (#12109)
## Summary

Phase 2 of the dialog migration kicked off in #11719 and continued in
#12041. Migrates four medium-complexity dialogs to the Reka-UI
primitives. Public API of `useDialogService` / `dialogStore` is
unchanged.

Parent:
[FE-571](https://linear.app/comfyorg/issue/FE-571/dialog-system-migration-primevue-reka-ui-parent)
This phase:
[FE-574](https://linear.app/comfyorg/issue/FE-574/phase-2-migrate-error-nodesearchbox-secretform-videohelp-customization)
Predecessors: #11719 (Phase 0, merged), #12041 (Phase 1, merged)

> **NodeSearchBoxPopover deferred** — host of an inner PrimeVue Dialog
(filter panel) that teleports to body and conflicts with Reka's
DismissableLayer outside-pointer detection (CI dismissed the outer
dialog mid-interaction). Tracking as a follow-up PR; FE-574 stays open
for it.

## Changes

### `src/services/dialogService.ts`
| Call site | Renderer | Size |
| --- | --- | --- |
| `showExecutionErrorDialog()` | `'reka'` | `lg` |
| `showErrorDialog()` | `'reka'` | `lg` |

### `src/components/dialog/content/ErrorDialogContent.vue`
- Drops `import Divider from 'primevue/divider'` and `import ScrollPanel
from 'primevue/scrollpanel'`
- Replaces with `<hr class="border-t border-border-subtle">` + `<div
class="h-[400px] w-full max-w-[80vw] overflow-auto">`

### Direct PrimeVue → Reka swaps (no `dialogStore` involvement)
| File | Notes |
| --- | --- |
| `src/components/common/CustomizationDialog.vue` | Reka primitives +
DialogTitle/Header/Footer; drops PrimeVue Divider; `:modal="false"` and
`pointer-down-outside` overlay guard so the PrimeVue ColorPicker overlay
(teleported to body) does not auto-dismiss the dialog |
| `src/platform/assets/components/VideoHelpDialog.vue` | Headless Reka
content; preserves capture-phase ESC by stopping propagation on
`escape-key-down`; `VisuallyHidden` title for a11y |
| `src/platform/secrets/components/SecretFormDialog.vue` | Reka
primitives, retains `v-model:visible`, autofocus on the provider
trigger, form submit/validation |

### Tests
- `src/services/dialogService.renderer.test.ts`: extends the regression
net to cover both error-dialog call sites (renderer `'reka'`, size
`'lg'`)
- `src/components/common/CustomizationDialog.test.ts`: swaps PrimeVue
Dialog stub for Reka primitive stubs



### screenshot
<img width="1236" height="761" alt="Screenshot 2026-05-11 at 10 26
51 PM"
src="https://github.com/user-attachments/assets/086cb73f-a98d-41f8-96ee-21922da8dd73"
/>
<img width="1161" height="786" alt="Screenshot 2026-05-12 at 1 26 39 PM"
src="https://github.com/user-attachments/assets/db7383d8-f737-4472-91c0-dab5aa41547b"
/>


## Quality gates

- [x] `pnpm typecheck` — clean
- [x] `pnpm lint` — 0 errors (3 pre-existing warnings unrelated to this
PR)
- [x] `pnpm format` — applied
- [x] `pnpm test:unit` (touched + adjacent areas):
  - `dialogService.renderer.test.ts` — 5/5
  - `CustomizationDialog.test.ts` — 4/4
  - All `src/components/dialog` tests — 73/73
  - `src/platform/secrets` tests — 39/39
  - `NodeBookmarkTreeExplorer.test.ts` — 7/7
- [ ] CI Playwright matrix

## Public API impact

None. `useDialogService` / `dialogStore` signatures unchanged.
Custom-node extensions calling `app.extensionManager.dialog.*` continue
to work.

## Out of scope (later phases)

- NodeSearchBoxPopover — follow-up PR under FE-574
- Settings dialog — Phase 3 (FE-575)
- Manager dialog — Phase 4 (FE-576)
- `ConfirmDialog` callers (`SecretsPanel`, `BaseWorkflowsSidebarTab`) —
Phase 5 (FE-577)
- Removing PrimeVue `Dialog`/`<style>` overrides in `GlobalDialog.vue` —
Phase 6 (FE-578)

## Test plan

- [x] Unit: 73/73 dialog-area, 39/39 secrets
- [ ] CI: full Vitest + Playwright matrix
- [ ] Manual on a backend:
- Trigger an execution error → error dialog opens through Reka, scroll
body, copy-to-clipboard, close
- Add/edit a secret → form submits, validation errors render, ESC and
cancel close
- Open VideoHelpDialog from `UploadModelFooter` while inside the asset
modal → ESC closes only the help dialog
- Customize a node bookmark color/icon → apply/reset, color picker
overlay works
2026-05-15 02:05:46 +00:00
Alexander Brown
1ab63807e9 fix: reserve scrollbar gutter on textareas to prevent hover reflow (#12280)
*PR Created by the Glary-Bot Agent*

---

Adds the Tailwind 4.3 `scrollbar-gutter-stable` utility to the shared
shadcn `Textarea` wrapper and the `ModelInfoPanel` description textarea
so the layout no longer shifts when a vertical scrollbar appears on
hover or focus.

The wrapper edit propagates to all four consumers (`WidgetTextarea`,
`WidgetMarkdown`, `ComfyHubDescribeStep`, `ComfyHubCreateProfileForm`).
The `ModelInfoPanel` site uses a native `<textarea>` that bypasses the
wrapper, so it is fixed inline.

The `overflow-hidden hover:overflow-auto` performance pattern from
#10804 is intentionally preserved.

## Verification

- Typecheck, eslint, oxlint, oxfmt, stylelint clean on changed files
- `Textarea.test.ts`, `WidgetTextarea.test.ts`,
`WidgetMarkdown.test.ts`, `ComfyHubDescribeStep.test.ts`,
`ModelInfoPanel.test.ts` — all 68 tests pass
- `pnpm build` succeeds; the production CSS bundle contains the
`scrollbar-gutter:stable` rule
- Storybook (`UI/Textarea`): confirmed
`getComputedStyle(textarea).scrollbarGutter === 'stable'` on Default,
WithLabel, and overflowing-content scenarios

Fixes FE-697

## Screenshots

![Default Textarea story with scrollbar-gutter: stable
applied](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/be88568f9650862f90204bac94d0f79bf00a04fbfd32d37fcd46ad3c1fd3307b/pr-images/1778801341192-90974ee3-876c-4b1d-844c-487f687d80cd.png)

![Focused textarea showing vertical scrollbar with stable
gutter](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/be88568f9650862f90204bac94d0f79bf00a04fbfd32d37fcd46ad3c1fd3307b/pr-images/1778801341517-a6f52715-3ed8-4854-919e-02b5667f28b7.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12280-fix-reserve-scrollbar-gutter-on-textareas-to-prevent-hover-reflow-3606d73d3650816f9947e20134abf59e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-15 01:48:35 +00:00
Christian Byrne
e35bea51d6 refactor(browser-tests): centralize template mocking via TemplateHelper (#11837)
## Summary

Centralize template route mocking by mirroring the existing
`AssetHelper` fixture pattern, so tests no longer hand-roll
`page.route('**/templates/index.json', ...)` and
`page.route('**/templates/**.webp', ...)` blocks.

## Changes

- **What**:
- Expand `browser_tests/fixtures/data/templateFixtures.ts` with stable
distribution exports (`STABLE_CLOUD_TEMPLATE`,
`STABLE_DESKTOP_TEMPLATE`, `STABLE_LOCAL_TEMPLATE`,
`STABLE_UNRESTRICTED_TEMPLATE`), an `ALL_DISTRIBUTION_TEMPLATES` set,
and a `generateTemplates(count, distribution?)` bulk generator.
`makeTemplate` and `mockTemplateIndex` are preserved.
- Add `browser_tests/fixtures/helpers/TemplateHelper.ts` modeled on
`AssetHelper`: an immutable `TemplateConfig` driven by composable
operators (`withTemplates`, `withTemplate`, `withCloudTemplates`,
`withDesktopTemplates`, `withLocalTemplates`,
`withUnrestrictedTemplates`, `withRawIndex`), a `TemplateHelper` class
with `mock()` / `mockIndex()` / `mockThumbnails()` / `clearMocks()`, and
a `createTemplateHelper(page, ...operators)` factory.
- Add `browser_tests/fixtures/templateApiFixture.ts` exposing
`templateApi: TemplateHelper` as a Playwright fixture with automatic
`clearMocks()` teardown (mirrors `assetApiFixture`).
- Migrate `browser_tests/tests/templateFilteringCount.spec.ts` to
`mergeTests(comfyPageFixture, templateApiFixture)` and
`templateApi.configure(withTemplates(...))` + `templateApi.mockIndex()`.
The webp thumbnail mock moves into the helper.
- **Breaking**: None. `makeTemplate` and `mockTemplateIndex` exports
remain.

## Review Focus

- `TemplateHelper.mock()` is split into `mockIndex()` and
`mockThumbnails()` so the spec can install the thumbnail handler in
`beforeEach` (where it has no per-test data) and the index handler
per-test (after `configure(...)`). Both still register through
`routeHandlers` so `clearMocks()` unroutes everything.
- Operators are pure (`(config) => config`) and the helper deep-copies
the resulting `templates` array, matching the AssetHelper immutability
pattern.
- The thumbnail route is `**/templates/**.webp` and serves
`browser_tests/assets/example.webp` — identical to the previous inline
behavior.

Fixes #11431

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11837-refactor-browser-tests-centralize-template-mocking-via-TemplateHelper-3546d73d365081beac00d80c2ab5ea1c)
by [Unito](https://www.unito.io)
2026-05-14 15:54:40 -07:00
Rizumu Ayaka
f429e1e0c4 refactor: promote FormSearchInput to shared ui as AsyncSearchInput (#12185)
## Summary

Follow-up to #12183: move the debounced, searcher-driven search input
out of `src/renderer/...` and into the shared primitives folder, so both
the graph (form dropdown node widget) and the shell UI (templates
dialog, right side panel tabs) can use it without crossing the renderer
layer.

## Changes

- **What**: Renamed and relocated `FormSearchInput` → `AsyncSearchInput`
at `src/components/ui/search-input/AsyncSearchInput.vue`, joining the
existing `SearchInput` / `SearchAutocomplete` siblings.
- **What**: Updated all 9 callers (graph form dropdown, right side panel
tabs, templates dialog) to import from the new path/name. Test file
moved alongside the component.
- **Breaking**: None — pure rename + relocate, behavior is identical.

## Review Focus

- New name reflects the component's distinguishing feature (the async
`searcher` lifecycle: debounce + cancellation + loading state); see
Slack thread.
- Slack thread captured the discussion — splitting from #12183 so the
perf fix can backport cleanly to the release line.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12185-refactor-promote-FormSearchInput-to-shared-ui-as-AsyncSearchInput-35e6d73d365081c585d8d421ea4353fa)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-05-14 21:14:22 +00:00
Alexander Brown
d6b4137eec chore: upgrade tailwindcss to 4.3 (#12275)
*PR Created by the Glary-Bot Agent*

---

## Summary

Bump `tailwindcss` and `@tailwindcss/vite` from `^4.2.0` to `^4.3.0` in
the workspace catalog so the new first-class scrollbar utilities
(notably `scrollbar-gutter-stable`) are available — the missing utility
behind the FE-697 workaround. Release notes:
https://tailwindcss.com/blog/tailwindcss-v4-3.

## Changes

- **What**:
- `pnpm-workspace.yaml` catalog: `tailwindcss` and `@tailwindcss/vite` →
`^4.3.0` (lockfile resolves to `4.3.0` for `tailwindcss`,
`@tailwindcss/vite`, `@tailwindcss/node`, and all `@tailwindcss/oxide-*`
native packages).
- Auto-fix 21 lint errors that `eslint-plugin-better-tailwindcss` now
reports because the new utilities have canonical forms:
- `VirtualGrid.vue`: `[scrollbar-gutter:stable]` →
`scrollbar-gutter-stable` (the FE-697 case)
- `VirtualGrid.vue` + `RightSidePanel.vue`: `scrollbar-thin` class-order
fix
    - `TopBarHeader.vue`: `h-6.25 w-6.25` → `size-6.25` (6 button cells)
- `Dialogue.vue`: `translate-x-[-50%] translate-y-[-50%]` →
`translate-[-50%]`
- Minor `-mx-[…]`, `-inset-[…]`, `-ml-[…]` arbitrary-value reorderings
in `NodeSearchFilterBar.vue`, `LGraphNode.vue`,
`CloudNotificationContent.vue`
- All 21 are auto-fixes; all existed on `main` and would have started
failing CI on next merge.
- **Breaking**: None. v4.3 is purely additive; existing config
(CSS-first `@theme`/`@utility`/`@plugin`, custom `lucideStrokePlugin`,
`tailwindcss-primeui`, `@iconify/tailwind4`, `tw-animate-css`) is
unchanged.

## Review Focus

- Confirmed `scrollbar-gutter: stable` compiles into
`dist/assets/index-*.css` from `VirtualGrid.vue` after the
canonicalization.
- Runtime probe via Playwright (preview build) confirmed
`scrollbar-gutter-stable`, `scrollbar-thin`, `tab-4`, and `zoom-100` all
apply.
- `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm knip` all pass.
- `pnpm test:unit`: 10687/10696 pass. 1 failure in `GraphView.test.ts`
("reconnect wiring") confirmed to fail identically on `main` (flaky
`toHaveBeenCalledTimes(1)` getting `3`/`4`) — unrelated to this change.
- Oracle code review: 0 findings.

Refs FE-697.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12275-chore-upgrade-tailwindcss-to-4-3-3606d73d3650813185cece1c7315e1c2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-14 14:28:11 -07:00
Christian Byrne
b578147714 fix(ci): add unmapped to genhtml ignore-errors, remove unreachable placeholder (#12273)
## Summary

- Add `unmapped` to genhtml `--ignore-errors` flag to fix GH Pages
deploy failure
- Remove unreachable placeholder block (dead code cleanup)

## Problem

PR #11381 added `--ignore-errors source` but genhtml is now failing with
a different error:

```
genhtml: ERROR: no data for line:4291, TLA:GNC, file:src/lib/litegraph/src/LGraphNode.ts
(use "genhtml --ignore-errors unmapped ..." to bypass this error)
```

This happens when LCOV data references lines that don't map to source
(from V8 coverage instrumentation).

## Changes

1. **Add `unmapped` to ignore-errors** — `--ignore-errors
source,unmapped` handles both missing source files and unmapped line
data

2. **Remove unreachable placeholder block** — The `if [ ! -s
coverage/playwright/coverage.lcov ]` check is dead code because the step
is already gated on `has-coverage == 'true'`, which only triggers when
the merged LCOV exists and is non-empty

## Test plan

- [ ] Verify workflow completes successfully on next push to main
- [ ] Verify https://comfy-org.github.io/ComfyUI_frontend/ returns 200

Fixes #12229

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12273-fix-ci-add-unmapped-to-genhtml-ignore-errors-remove-unreachable-placeholder-3606d73d36508198a4ffddfce3354d4a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-14 20:11:45 +00:00
Benjamin Lu
06e09df673 test: replace jobs mock fixture with typed route mocks (#12267)
## Summary

Replace the merged stateful jobs API browser mock fixture with a small
declarative typed route-mock foundation.

## Changes

- **What**: Removes `JobsApiMock`, `jobsApiMockFixture`, and the old
shared `jobFixtures` helper.
- **What**: Adds a generic `RouteMocker` primitive for explicit typed
JSON route responses.
- **What**: Adds `jobsRouteFixture`, which registers explicit
`/api/jobs` list/detail responses without filtering, mutation handling,
or hidden in-memory backend behavior.
- **What**: Migrates the current queue overlay and missing-media runtime
specs onto the new jobs route fixture.
- **What**: Keeps `./browser_tests/tsconfig.json` in the ESLint
TypeScript resolver config.
- **Dependencies**: None.

## Review Focus

This is intended to be the foundation PR for the test-strategy reset:
old stateful helper out, typed declarative route mocks in. It
intentionally does not add the full asset sidebar, job history sidebar,
or floating QPO coverage suite; those should stack on top after this
fixture shape is accepted.

The boundary this PR is trying to preserve: route mocks may describe
frontend-visible API responses, but should not implement Core
queue/history mutation semantics.

Context:
https://www.notion.so/comfy-org/E2E-Test-Strategy-for-Assets-Job-History-and-Queue-Progress-Overlay-35f6d73d365081209bc5f10e6c7eb8de

## Screenshots (if applicable)

Not applicable.
2026-05-14 20:09:48 +00:00
Christian Byrne
e972d658d3 feat(settings): lower Comfy.Pointer.ClickBufferTime default from 150ms to 32ms (#12032)
*PR Created by the Glary-Bot Agent*

---

## Summary

Click vs drag is disambiguated by two thresholds: distance
(`Comfy.Pointer.ClickDrift`, 6px) and time
(`Comfy.Pointer.ClickBufferTime`). Distance does the real work — any
intentional drag immediately exceeds 6px on the first move. The time
threshold only matters in the corner case where the pointer is held
still then released without crossing the distance threshold.

The previous 150ms default forces every pointerdown to wait up to 150ms
before drag begins, even when the user is clearly dragging. This is
visible as lag when click+dragging an unselected node, where the wait
stacks on top of selection-state work that runs at drag start
([FE-558](https://linear.app/comfyorg/issue/FE-558/bug-delay-when-clickdragging-an-unselected-node)).

32ms (~2 frames at 60fps) is well below human-perceptible click latency
and still safely above incidental jitter on pointerdown.

## Changes

- `src/platform/settings/constants/coreSettings.ts` —
`Comfy.Pointer.ClickBufferTime`:
  - `defaultValue: 150` → `32`
  - `versionAdded: '1.4.3'` retained, add `versionModified: '1.44.19'`
- Slider `step: 25` → `1` (the old step made `32` unrepresentable on the
slider)
- Tooltip extended with rationale (why distance is the real
disambiguator, why this should be small)
- `src/lib/litegraph/src/CanvasPointer.ts` — Static `bufferTime = 150` →
`32` for consistency with the user-setting default; the runtime
`watchEffect` in `useLitegraphSettings.ts` continues to override at
boot. JSDoc explains the rationale and that the user setting overrides
at runtime.

## Verification

- `pnpm typecheck` clean.
- Litegraph + settings test suites: 998/998 pass.
- `pnpm format` clean (lint-staged enforced on commit).

## Why not just leave it user-overridable?

It already is. But the default is what every user — including ones who
never open settings — experiences. 150ms was never a justified value (no
comment explains it, no benchmark, and 6px distance covers the
disambiguation). Defaults should reflect best UX.

Refs FE-558.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12032-feat-settings-lower-Comfy-Pointer-ClickBufferTime-default-from-150ms-to-32ms-3586d73d365081f5a29cdc84b4b736e3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-14 20:06:51 +00:00
Christian Byrne
f090ea3d28 fix: preserve app builder inputs through graph reconfiguration (#11422)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Fixes app mode bug where custom combo inputs (and other widget inputs)
unselect and show "Widget not visible" after refreshing or reopening a
saved app workflow
- Root cause: `resetSelectedToWorkflow()` loads from
`changeTracker.activeState` which may not have linearData yet after
refresh, and `pruneLinearData()` prunes valid entries during graph
loading when nodes aren't yet in the graph
- Two defensive guards: fallback to `initialState` for authoritative
data, and skip pruning during graph loading

## Changes

- `appModeStore.ts`: `resetSelectedToWorkflow()` now falls back to
`initialState.extra.linearData` when `activeState` has none
- `appModeStore.ts`: `pruneLinearData()` skips node-existence checks
when `ChangeTracker.isLoadingGraph` is true
- Unit tests: 4 new tests covering both fix paths (pruning during
loading, fallback to initialState)
- E2E test: Save-as → close → reopen → verify all inputs persist with no
"Widget not visible"

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11422-fix-preserve-app-builder-inputs-through-graph-reconfiguration-3476d73d36508166a563f7df3967665c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-14 12:28:21 -07:00
Christian Byrne
daab936d15 feat: add Cloud Status link to website footer (#12237)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds a "Cloud Status" link to the Contact column of the website footer,
pointing to https://status.comfy.org so users can discover the service
status page from any page on the marketing site.

## Changes

- **What**: New external link in the Contact column of `SiteFooter.vue`,
plus matching i18n keys (`footer.cloudStatus` in `en`/`zh-CN`) and a
`cloudStatus` entry in the `externalLinks` config.

## Review Focus

- Placement: Slotted between Support and Press in the Contact column
(alongside the other support/operational links). Open to moving to the
Resources column instead if preferred.
- URL: `https://status.comfy.org` — assumed convention; swap if a
different status page URL is preferred.
- Also tightened the `contactColumn` type annotation to `{ title:
string; links: FooterLink[] }` to match `companyColumn` / `topColumns`
so the `external: true` field is properly typed.

## Screenshots

Desktop (Contact column, right side):

![Footer desktop](.glary/screenshots/footer-desktop.png)

Mobile (2×2 grid, Contact column bottom-right):

![Footer mobile](.glary/screenshots/footer-mobile.png)

## Screenshots

![Site footer desktop view showing Cloud Status link in the Contact
column with external arrow
icon](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9ba647494446aa0233ca1f81ce96aa1778a59c5f4bbec394dc8074d4cff611a9/pr-images/1778717012660-56f4efd5-0f20-4570-8a19-0390b573b274.png)

![Site footer mobile view showing Cloud Status link in the Contact
column](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9ba647494446aa0233ca1f81ce96aa1778a59c5f4bbec394dc8074d4cff611a9/pr-images/1778717013059-fe6803d3-94ae-49ed-bcd9-b1eeee8f5ebd.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12237-feat-add-Cloud-Status-link-to-website-footer-3606d73d36508190a906fdde6d86706d)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-14 15:02:41 +00:00
Christian Byrne
f176d18fe0 fix(website): refresh cloud nodes snapshot in release workflow + strict production builds (#12219)
*PR Created by the Glary-Bot Agent*

---

## Summary

`Release: Website` only refreshed the Ashby snapshot, so the cloud-nodes
snapshot (`apps/website/src/data/cloud-nodes.snapshot.json`) was stale
on every release. `loadPacksForBuild()` then silently fell back to that
snapshot because `WEBSITE_CLOUD_API_KEY` was never plumbed through CI or
Vercel, leaving production at `/cloud/supported-nodes` with placeholder
data (e.g. `rgthree-comfy` listed as supported when it isn't — visible
at line 104 of the committed snapshot, last fetched 2026-05-04).

## Changes

- **New composite action `.github/actions/cloud-nodes-pull`** mirroring
`ashby-pull`: runs `pnpm --filter @comfyorg/website
cloud-nodes:refresh-snapshot` with `WEBSITE_CLOUD_API_KEY`. The script
already `process.exit(1)`s on any non-`fresh` outcome, so refresh
failures are loud.
- **`release-website.yaml`** now runs both refreshes and opens a single
PR with both updated snapshots. Renamed the job to `refresh-snapshots`,
updated branch/commit/title/body for the wider scope, and kept the
existing `Release:Website` label so downstream automation is unaffected.
- **`cloudNodes.build.ts`** throws when the outcome is `'stale'` **and**
`VERCEL_ENV === 'production'`. Preview / local builds keep the snapshot
fallback so contributors without key access are unaffected. The CI
reporter still runs first so the GitHub annotation explaining *why* it's
stale is visible in the failed job.
- **`ci-vercel-website-preview.yaml`**: passes `WEBSITE_CLOUD_API_KEY`
to `vercel build` in both preview and production jobs, and adds a
preflight step on `deploy-production` that hard-fails before `vercel
build --prod` if the secret is missing — surfacing config drift with a
maintainer-friendly error annotation instead of mid-build.
- **`apps/website/README.md`**: documents the production-strictness
behavior, the new required secret (GitHub Actions + Vercel env), and the
manual refresh path.
- **New unit tests** in `cloudNodes.build.test.ts` (6 cases): fresh,
stale-no-VERCEL_ENV, stale-on-preview, stale-on-production,
failed-regardless, and "still reports on stale-in-production before
throwing".

## Manual / one-time steps required before merging

This PR cannot finish the job alone. A maintainer must also:

1. Add `WEBSITE_CLOUD_API_KEY` as a **GitHub Actions repo secret** in
`Comfy-Org/ComfyUI_frontend`.
2. Add `WEBSITE_CLOUD_API_KEY` to the **Vercel project environment**
(`production` env at minimum; `preview` recommended).
3. Investigate why `rgthree-comfy` is in the current snapshot — either
the Cloud API was actually returning it on 2026-05-04, the snapshot was
generated against a non-production environment, or it was hand-edited.
The first manual run of `Release: Website` after this PR merges will
confirm.

Without step 1, the new `Release: Website` job will fail loudly (the
refresh script exits 1 with `missing WEBSITE_CLOUD_API_KEY`). Without
step 2, the new preflight will fail the production deploy with a clear
error annotation pointing at `apps/website/README.md`. Both failure
modes are intentional — they replace today's silent stale snapshot.

## Related (out of scope for this PR)

The other half of the original report — production 404s on
`/p/supported-models/*`, `/cloud/supported-nodes/*`,
`/demos/community-workflows` from PRs #11892 / #11903 / #11942 — is a
`comfy-router` allow-list gap (those paths exist in the Vercel build as
pre-rendered static HTML). That fix needs to land in
`Comfy-Org/comfy-router` and is being handled separately since glary
doesn't have access to that repo.

## Verification

- `pnpm --filter @comfyorg/website test:unit` — 75/75 pass (6 new in
`cloudNodes.build.test.ts`)
- `pnpm --filter @comfyorg/website typecheck` — 0 errors, 0 warnings (2
pre-existing hints unrelated to this PR)
- `pnpm format` + `pnpm exec eslint` on changed files — clean
- `js-yaml` validates `release-website.yaml`,
`cloud-nodes-pull/action.yaml`, `ci-vercel-website-preview.yaml`
- Oracle code review (round 1) raised 1 warning + 1 suggestion; both
addressed in commit 2.

**Manual verification not applicable**: the runtime changes are GitHub
Actions workflows and a Vercel-env-gated branch in a build-time module —
they cannot meaningfully run outside of GitHub Actions / Vercel, and the
strict-on-stale path is exhaustively covered by the 6 unit tests
(including the exact assertions a manual run would check: throws on
`VERCEL_ENV=production` + stale, passes on preview, reports
observability annotation before throwing). The end-to-end behavior will
be verified by the first `Release: Website` dispatch and the next
production deploy after the maintainer adds the secret.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12219-fix-website-refresh-cloud-nodes-snapshot-in-release-workflow-strict-production-build-35f6d73d3650816d8f32d403cb39d733)
by [Unito](https://www.unito.io)

---------

Co-authored-by: glary <bot@glary.dev>
2026-05-14 09:49:00 +00:00
Christian Byrne
01742672bb feat: extend version warning to all comfy_package_versions entries (#12167)
*PR Created by the Glary-Bot Agent*

---

## Summary

Extend the existing frontend version-mismatch warning UI to consume the
new `comfy_package_versions` array now exposed by ComfyUI's
`/system_stats` endpoint. The backend ships installed/required versions
for every `comfy*` package pinned in `requirements.txt` (frontend,
workflow-templates, embedded-docs, kitchen, aimdo, …); the frontend now
surfaces one toast per outdated package, reusing the existing N=1
frontend-version warning shape.

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

## Changes

- `src/schemas/apiSchema.ts` — add `comfy_package_versions: Array<{name,
installed, required}>` to the `SystemStats` schema and export a
`ComfyPackageVersion` type. The field is `.optional()` so older backends
remain compatible.
- `src/platform/updates/common/versionCompatibilityStore.ts` — new
`outdatedComfyPackages` / `packageWarningMessages` computeds that mirror
the existing semver `gt` comparison (same `valid` guard). Skip
`comfyui-frontend-package` because the dedicated frontend warning above
already covers it (and uses the running bundle's version rather than the
installed pip version). Outdated packages are sorted by
`name`/`installed`/`required` before being folded into the dismissal
storage key so the key is stable across response orderings — a fresh
package bump re-shows the warning, but the same outdated set in a
different order does not.
- `src/platform/updates/common/useFrontendVersionMismatchWarning.ts` —
emit one toast per outdated package in addition to the existing frontend
toast, reusing the same i18n wrapper and the existing `hasShownWarning`
once-per-session guard.
- `src/locales/en/main.json` — new `g.comfyPackageOutdated` string.
- Unit tests — added coverage for outdated/skip/invalid-version paths,
dismissal-key inclusion, and stable ordering. Existing 21 store + 8
composable tests untouched and still passing.

CI suppression is unchanged: warnings still gate on
`versionCompatibilityStore.shouldShowWarning` (which respects the
`Comfy.VersionCompatibility.DisableWarnings` setting and the 7-day
dismissal cache), and unit tests continue to mock the store the same
way.

## Verification

- `pnpm vitest run` for the version-warning module: **31/31** passing.
- Targeted sweep across `src/platform/updates`,
`src/stores/systemStatsStore.test.ts`, `src/schemas`: **160/160**
passing.
- `pnpm typecheck`: clean.
- `pnpm lint`: 0 errors (3 pre-existing warnings in unrelated 3D test
files).
- `pnpm format`: applied, no incidental changes.
- **Manual Playwright run** against the real dev server with
`/api/system_stats` intercepted to return outdated package data —
produced exactly the expected toasts and correctly skipped
`comfyui-frontend-package` and the up-to-date `comfy-kitchen` entry.
Same run with all-up-to-date data produced zero toasts.

### Toasts produced (manual verification)

The fixture used: `required_frontend_version=99.99.99`, plus
`comfy_package_versions=[frontend-package (outdated, skipped),
workflow-templates 0.9.0→0.9.5 (outdated), embedded-docs 0.4.0→0.5.0
(outdated), comfy-kitchen 0.2.8→0.2.8 (up to date)]`.

## Screenshots

![Three version warning toasts displayed: outdated frontend (1.45.1 <
99.99.99), outdated workflow-templates (0.9.0 < 0.9.5), and outdated
embedded-docs (0.4.0 < 0.5.0). The skipped comfyui-frontend-package
entry and the up-to-date comfy-kitchen entry produced no extra
toasts.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/986a510ae8e587d3ced862efffa622d9d476c40b9ac1423b89f56e85c0ce51c1/pr-images/1778562152770-11307c64-2863-41af-a42d-1c42fb332ca6.png)

![Zoomed view of the three Alert toasts in the top-right toast
stack.](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/986a510ae8e587d3ced862efffa622d9d476c40b9ac1423b89f56e85c0ce51c1/pr-images/1778562153211-df80e3b5-48d5-416e-a11f-3a6d25b6618c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12167-feat-extend-version-warning-to-all-comfy_package_versions-entries-35e6d73d365081e7b993d0f06c9e5c98)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-14 08:24:32 +00:00
Christian Byrne
b7fe0365af fix: remove "400 Free Credits Monthly" line from cloud paywall modal (#12251)
*PR Created by the Glary-Bot Agent*

---

## Summary

The in-app cloud paywall modal shown on first install of ComfyUI Desktop
1.0 advertised "400 Free Credits Monthly" as a benefit, but the free
tier is currently disabled, making that copy misleading.

Per the thread direction, this skips the feature-flag plumbing (which
isn't available on Desktop anyway) and simply removes the line.

## Changes

- `CloudNotificationContent.vue`: change the feature loop from `n in 4`
to `n in [2, 3, 4]` so feature1 is skipped.
- `src/locales/*/main.json` (12 locales): remove the now-unused
`cloudNotification.feature1Title` key.
- `browser_tests/tests/dialog.spec.ts`: add a regression assertion that
"400" / "Free Credits" copy is no longer present in the modal.

## Test plan

- `pnpm typecheck` — passes
- `pnpm typecheck:browser` — passes
- `pnpm exec vitest run
src/platform/cloud/notification/components/DesktopCloudNotificationController.test.ts`
— 3/3 pass
- Pre-commit hooks (oxfmt, oxlint, eslint, stylelint, typecheck) — all
pass
- Manual: triggered `showCloudNotification()` against the dev server and
confirmed only 3 benefit rows render (screenshot attached); the "400
Free Credits Monthly" line is gone.

- Fixes DESK2-90

## Screenshots

![Cloud paywall modal after fix — shows 3 benefits without the '400 Free
Credits Monthly'
line](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/d96d51ee1f37a5138014eeff4c46a901e5178c013e0412f288e49c17bddb88e4/pr-images/1778728094191-423f01df-f004-4ae5-a939-7d28413f6fbd.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12251-fix-remove-400-Free-Credits-Monthly-line-from-cloud-paywall-modal-3606d73d365081eaa572e6cd995278d8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-13 22:39:32 -07:00
Dante
bdb92c845e fix: include share_id when importing published assets (FE-603) (#12055)
*PR Created by the Glary-Bot Agent*

https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778150003248989
FE-603
---

## Summary

Send `share_id` alongside `published_asset_ids` from
`workflowShareService.importPublishedAssets`, and type / zod-validate
the body against `ImportPublishedAssetsRequest` +
`zImportPublishedAssetsRequest` from `@comfyorg/ingest-types`.

This is a **parameter-schema change**, not a live bug fix. The original
`BAD_REQUEST: share_id is required` failure is **no longer reproducible
in production** — the backend rolled back the required-field enforcement
in [BE-855](https://linear.app/comfyorg/issue/BE-855) (cloud
[#3587](https://github.com/Comfy-Org/cloud/pull/3587) /
[#3588](https://github.com/Comfy-Org/cloud/pull/3588)). Today the import
succeeds without `share_id`.

- Closes FE-603

## Why we still ship this

Frontend goes first so the backend can later re-tighten the contract
without breaking shared-workflow imports a second time. Agreed sequence
in
[Slack](https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1778535349236419)
— BE-6 was split into two sub-issues to make the chain explicit:

1. **[BE-898](https://linear.app/comfyorg/issue/BE-898)** (blocks
FE-603) — BE makes `share_id` **optional**, validating when present.
[cloud PR #3633](https://github.com/Comfy-Org/cloud/pull/3633), In
Review.
2. **FE-603** (this PR) — FE sends `share_id`.
3. **[BE-899](https://linear.app/comfyorg/issue/BE-899)** (blocked by
FE-603) — BE flips `share_id` back to **required** after FE-603 deploys.

Steps 1 and 2 ship in order: BE-898 → FE-603 → BE-899.

## Changes

- `workflowShareService.importPublishedAssets` now takes `shareId:
string` and types the request body with `ImportPublishedAssetsRequest`.
The body is parsed through `zImportPublishedAssetsRequest` before the
network call so future contract drift surfaces as a typecheck or zod
parse failure rather than a silent runtime 400.
- `useSharedWorkflowUrlLoader.ts` threads `payload.shareId` (already in
scope from `SharedWorkflowPayload`) into the import call.
- Unit tests assert `share_id` is sent and that an empty `share_id` is
rejected before fetch.

## Verification

- `pnpm typecheck` — clean
- `pnpm lint` — 0 errors / 0 warnings on changed files (3 pre-existing
warnings in unrelated files)
- `pnpm format` — applied
- `pnpm vitest run` on both affected files — 34/34 passing

### Live in-browser smoke test (Vite dev server)

Loaded the built module in the browser, intercepted `fetch`, and
exercised the new code path. The new `importPublishedAssets` produces
the correct wire format and zod rejects bad input before fetch:

```json
// Happy path — sent to /api/assets/import (POST)
{
  "published_asset_ids": ["pa-1", "pa-2", "pa-3"],
  "share_id": "share-abc"
}
```

```text
// Empty share_id — zod rejects, fetch is never called
[ { code: "too_small", minimum: 1, path: ["share_id"], message: "String must contain at least 1 character(s)" } ]
fetchCallsBetweenAttempts: 0
```

## Why typecheck didn't catch the original miss

`api.fetchApi(route: string, options?: RequestInit)` accepts the
standard DOM `RequestInit`, so `body` is just `BodyInit | null`. Once
the call site does `JSON.stringify({ ... })`, the inline object is
erased into a string and TS has no schema to enforce. There was no
`@ts-ignore` or `as any` — the generated types were simply never
imported. This PR plugs that one call site; the same pattern should be
applied wherever the frontend hits ingest endpoints (the broader hey-zod
migration gap mentioned in #bug-dump).

Reported in #bug-dump.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12055-fix-include-share_id-when-importing-published-assets-3596d73d365081c0a1c7e69102f5cfcc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-14 04:13:16 +00:00
96 changed files with 2719 additions and 1346 deletions

View File

@@ -0,0 +1,19 @@
name: Cloud Nodes Pull
description: 'Refresh the apps/website cloud nodes snapshot from the Comfy Cloud /api/object_info endpoint'
inputs:
api_key:
description: 'Comfy Cloud API key (WEBSITE_CLOUD_API_KEY).'
required: true
runs:
using: 'composite'
steps:
# Note: this action assumes the frontend repo is checked out at the workspace root.
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Refresh cloud nodes snapshot
shell: bash
env:
WEBSITE_CLOUD_API_KEY: ${{ inputs.api_key }}
run: pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot

View File

@@ -106,19 +106,12 @@ jobs:
- name: Generate HTML coverage report
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}"
echo "<html><body><h1>No E2E coverage data available for this run.</h1><p><a href=\"${WORKFLOW_URL}\">View workflow run</a></p></body></html>" > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source
--ignore-errors source,unmapped
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'

View File

@@ -58,6 +58,7 @@ jobs:
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build
- name: Fetch head commit metadata
@@ -151,10 +152,20 @@ jobs:
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production
- name: Verify WEBSITE_CLOUD_API_KEY is present for production build
env:
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: |
if [ -z "${WEBSITE_CLOUD_API_KEY:-}" ]; then
echo "::error title=Missing WEBSITE_CLOUD_API_KEY::Production builds require WEBSITE_CLOUD_API_KEY so /cloud/supported-nodes is generated from fresh Cloud API data. Add it as a GitHub Actions repo secret and to the Vercel project environment. See apps/website/README.md."
exit 1
fi
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build --prod
- name: Deploy project artifacts to Vercel

View File

@@ -1,6 +1,6 @@
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
# and open a PR. Merging the PR triggers the existing Vercel website production
# deploy via ci-vercel-website-preview.yaml.
# Description: Manual workflow to refresh the apps/website Ashby roles and
# cloud nodes snapshots and open a PR. Merging the PR triggers the existing
# Vercel website production deploy via ci-vercel-website-preview.yaml.
name: 'Release: Website'
on:
@@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true
jobs:
refresh-snapshot:
refresh-snapshots:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
@@ -31,28 +31,39 @@ jobs:
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
- name: Refresh cloud nodes snapshot
uses: ./.github/actions/cloud-nodes-pull
with:
api_key: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'chore(website): refresh Ashby roles snapshot'
title: 'chore(website): refresh Ashby roles snapshot'
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
body: |
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.
Automated refresh of remote-data snapshots used by the website
build:
- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
board API
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
`/api/object_info`
**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshot.
2. This PR opens with the regenerated snapshots.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.
The snapshot fallback in `apps/website/src/utils/ashby.ts` and
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
without the respective API keys continue to use the committed
snapshot (with a warning annotation in CI).
Triggered by workflow run `${{ github.run_id }}`.
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
branch: chore/refresh-website-snapshots-${{ github.run_id }}
base: main
labels: |
Release:Website

View File

@@ -119,6 +119,44 @@ snapshots can't be accidentally committed.
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
### Production strictness
`src/utils/cloudNodes.build.ts` throws when `fetchCloudNodesForBuild()` returns
`{ status: 'stale' }` **and** `process.env.VERCEL_ENV === 'production'`. This
prevents the production deploy from silently shipping an out-of-date snapshot
when the Cloud API is unreachable or `WEBSITE_CLOUD_API_KEY` is missing. Preview
and local builds continue to use the committed snapshot with a warning
annotation.
### Required GitHub Actions / Vercel secrets
| Name | Where | Purpose |
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
| `WEBSITE_CLOUD_API_KEY` | GitHub Actions repo secret + Vercel project env | Auth for Cloud `/api/object_info`. Required for fresh production data. |
The `Release: Website` workflow uses the GitHub Actions secret to regenerate
`apps/website/src/data/cloud-nodes.snapshot.json` via
`.github/actions/cloud-nodes-pull/action.yaml`. The Vercel environment value is
read at build time by `vercel build` in `ci-vercel-website-preview.yaml`; the
`deploy-production` job hard-fails before `vercel build --prod` if the secret
is missing.
### Refreshing the snapshot
To update the committed snapshot manually (e.g. after onboarding new packs
to Comfy Cloud):
```bash
WEBSITE_CLOUD_API_KEY=\
pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
git commit apps/website/src/data/cloud-nodes.snapshot.json
```
The script exits non-zero on any non-fresh outcome so stale/empty snapshots
can't be accidentally committed. Otherwise the `Release: Website` GitHub
Actions workflow runs the same step on every manual dispatch and opens a PR
with the refreshed snapshot.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:

View File

@@ -82,7 +82,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
]
}
const contactColumn = {
const contactColumn: { title: string; links: FooterLink[] } = {
title: t('footer.contact', locale),
links: [
{ label: t('footer.sales', locale), href: routes.contact },
@@ -91,6 +91,11 @@ const contactColumn = {
href: externalLinks.support,
external: true
},
{
label: t('footer.cloudStatus', locale),
href: externalLinks.cloudStatus,
external: true
},
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }
]
}

View File

@@ -32,6 +32,7 @@ export const externalLinks = {
apiKeys: 'https://platform.comfy.org/profile/api-keys',
blog: 'https://blog.comfy.org/',
cloud: 'https://cloud.comfy.org',
cloudStatus: 'https://status.comfy.org',
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/api-reference/cloud',

View File

@@ -1773,6 +1773,7 @@ const translations = {
'footer.support': { en: 'Support', 'zh-CN': '支持' },
'footer.sales': { en: 'Sales', 'zh-CN': '销售' },
'footer.press': { en: 'Press', 'zh-CN': '媒体' },
'footer.cloudStatus': { en: 'Cloud Status', 'zh-CN': '云端状态' },
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
'footer.location': {
en: 'San Francisco, USA',

View File

@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './cloudNodes'
import type { NodesSnapshot } from '../data/cloudNodes'
const fetchCloudNodesMock = vi.hoisted(() =>
vi.fn<() => Promise<FetchOutcome>>()
)
const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn())
vi.mock('./cloudNodes', () => ({
fetchCloudNodesForBuild: fetchCloudNodesMock
}))
vi.mock('./cloudNodes.ci', () => ({
reportCloudNodesOutcome: reportCloudNodesOutcomeMock
}))
import { loadPacksForBuild } from './cloudNodes.build'
const SNAPSHOT: NodesSnapshot = {
fetchedAt: '2026-04-01T00:00:00.000Z',
packs: [
{
id: 'snapshot-pack',
displayName: 'Snapshot Pack',
nodes: [
{ name: 'SnapshotNode', displayName: 'Snapshot Node', category: 'x' }
]
}
]
}
describe('loadPacksForBuild', () => {
const savedVercelEnv = process.env.VERCEL_ENV
beforeEach(() => {
fetchCloudNodesMock.mockReset()
reportCloudNodesOutcomeMock.mockReset()
delete process.env.VERCEL_ENV
})
afterEach(() => {
if (savedVercelEnv === undefined) {
delete process.env.VERCEL_ENV
return
}
process.env.VERCEL_ENV = savedVercelEnv
})
it('returns packs when fetch is fresh', async () => {
fetchCloudNodesMock.mockResolvedValue({
status: 'fresh',
snapshot: SNAPSHOT,
droppedCount: 0,
droppedNodes: []
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('returns snapshot packs when outcome is stale outside production', async () => {
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('returns snapshot packs when outcome is stale on Vercel preview', async () => {
process.env.VERCEL_ENV = 'preview'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'HTTP 503'
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('throws when outcome is stale on Vercel production', async () => {
process.env.VERCEL_ENV = 'production'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
await expect(loadPacksForBuild()).rejects.toThrow(
/stale data in a production build/
)
await expect(loadPacksForBuild()).rejects.toThrow(
/missing WEBSITE_CLOUD_API_KEY/
)
})
it('throws when outcome is failed regardless of environment', async () => {
fetchCloudNodesMock.mockResolvedValue({
status: 'failed',
reason: 'network error: ECONNREFUSED'
})
await expect(loadPacksForBuild()).rejects.toThrow(
/Cloud nodes fetch failed and no snapshot is available/
)
await expect(loadPacksForBuild()).rejects.toThrow(/ECONNREFUSED/)
})
it('still reports outcome before throwing on stale-in-production', async () => {
process.env.VERCEL_ENV = 'production'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'HTTP 503'
})
await expect(loadPacksForBuild()).rejects.toThrow()
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
})

View File

@@ -3,6 +3,14 @@ import type { Pack } from '../data/cloudNodes'
import { fetchCloudNodesForBuild } from './cloudNodes'
import { reportCloudNodesOutcome } from './cloudNodes.ci'
const REFRESH_HINT =
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot, ' +
'or re-run the `Release: Website` workflow with a valid WEBSITE_CLOUD_API_KEY.'
function isProductionBuild(): boolean {
return process.env.VERCEL_ENV === 'production'
}
/**
* Resolve the list of packs to render at build time.
*
@@ -11,6 +19,10 @@ import { reportCloudNodesOutcome } from './cloudNodes.ci'
* same source. `fetchCloudNodesForBuild` is memoized on a module-level
* `inflight` promise, so repeated calls in the same build process share a
* single network round-trip and the same outcome.
*
* Production builds (VERCEL_ENV=production) fail hard on a stale outcome
* to prevent silently shipping out-of-date snapshot data. Preview and
* local builds continue to use the committed snapshot.
*/
export async function loadPacksForBuild(): Promise<Pack[]> {
const outcome = await fetchCloudNodesForBuild()
@@ -18,8 +30,14 @@ export async function loadPacksForBuild(): Promise<Pack[]> {
if (outcome.status === 'failed') {
throw new Error(
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot.'
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ${REFRESH_HINT}`
)
}
if (outcome.status === 'stale' && isProductionBuild()) {
throw new Error(
`Cloud nodes fetch returned stale data in a production build (VERCEL_ENV=production). ` +
`Reason: ${outcome.reason}. ${REFRESH_HINT}`
)
}

View File

@@ -2,6 +2,11 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
const Local = TemplateIncludeOnDistributionEnum.Local
export function makeTemplate(
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
@@ -26,3 +31,33 @@ export function mockTemplateIndex(
}
]
}
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
name: 'cloud-stable',
title: 'Cloud Stable',
includeOnDistributions: [Cloud]
})
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
name: 'desktop-stable',
title: 'Desktop Stable',
includeOnDistributions: [Desktop]
})
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
name: 'local-stable',
title: 'Local Stable',
includeOnDistributions: [Local]
})
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
name: 'unrestricted-stable',
title: 'Unrestricted Stable'
})
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
STABLE_CLOUD_TEMPLATE,
STABLE_DESKTOP_TEMPLATE,
STABLE_LOCAL_TEMPLATE,
STABLE_UNRESTRICTED_TEMPLATE
]

View File

@@ -1,176 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetailResponse,
JobEntry,
JobsListResponse
} from '@comfyorg/ingest-types'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
const defaultJobsListLimit = 100
export type MockJobRecord = {
listItem: JobEntry
detail: JobDetailResponse
}
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
const value = Number(url.searchParams.get(name))
return Number.isInteger(value) && value > 0 ? value : undefined
}
function getJobIdFromRequest(route: Route): string | null {
const url = new URL(route.request().url())
const jobId = url.pathname.split('/').at(-1)
return jobId ? decodeURIComponent(jobId) : null
}
export class JobsApiMock {
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
private jobsById = new Map<string, MockJobRecord>()
constructor(private readonly page: Page) {}
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
this.jobsById = new Map(
jobs.map(
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
)
)
await this.ensureRoutesRegistered()
}
async clear(): Promise<void> {
this.jobsById.clear()
if (this.listRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
this.listRouteHandler = null
}
if (this.detailRouteHandler) {
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
this.detailRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
this.historyRouteHandler = null
}
}
private async ensureRoutesRegistered(): Promise<void> {
if (!this.listRouteHandler) {
this.listRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
let filteredJobs = Array.from(
this.jobsById.values(),
({ listItem }) => listItem
)
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
const limit =
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
const total = filteredJobs.length
const visibleJobs = filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies JobsListResponse
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
}
if (!this.detailRouteHandler) {
this.detailRouteHandler = async (route: Route) => {
const jobId = getJobIdFromRequest(route)
const job = jobId ? this.jobsById.get(jobId) : undefined
if (!job) {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Job not found' })
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(job.detail)
})
}
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
}
if (!this.historyRouteHandler) {
this.historyRouteHandler = async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') {
await route.continue()
return
}
const requestBody = request.postDataJSON() as
| { delete?: string[]; clear?: boolean }
| undefined
if (requestBody?.clear) {
this.jobsById = new Map(
Array.from(this.jobsById).filter(([, job]) => {
const status = job.listItem.status
return status === 'pending' || status === 'in_progress'
})
)
}
if (requestBody?.delete?.length) {
for (const jobId of requestBody.delete) {
this.jobsById.delete(jobId)
}
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
}
await this.page.route(historyRoutePattern, this.historyRouteHandler)
}
}
}

View File

@@ -0,0 +1,198 @@
import type { Page, Route } from '@playwright/test'
import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
/**
* Generate N deterministic templates, optionally restricted to a distribution.
*
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
* for static test data with no executable fixture logic.
*/
function generateTemplates(
count: number,
distribution?: TemplateIncludeOnDistributionEnum
): TemplateInfo[] {
const slug = distribution ?? 'unrestricted'
return Array.from({ length: count }, (_, i) =>
makeTemplate({
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
title: `Generated ${slug} ${i + 1}`,
...(distribution ? { includeOnDistributions: [distribution] } : {})
})
)
}
export interface TemplateConfig {
readonly templates: readonly TemplateInfo[]
readonly index: readonly WorkflowTemplates[] | null
}
function emptyConfig(): TemplateConfig {
return { templates: [], index: null }
}
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
return templates.map((t) => structuredClone(t))
}
function cloneIndex(
index: readonly WorkflowTemplates[] | null
): WorkflowTemplates[] | null {
return index ? index.map((m) => structuredClone(m)) : null
}
function addTemplates(
config: TemplateConfig,
templates: TemplateInfo[]
): TemplateConfig {
return { ...config, templates: [...config.templates, ...templates] }
}
export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
return (config) => addTemplates(config, templates)
}
export function withTemplate(template: TemplateInfo): TemplateOperator {
return (config) => addTemplates(config, [template])
}
export function withCloudTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
)
}
export function withDesktopTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
)
}
export function withLocalTemplates(count: number): TemplateOperator {
return (config) =>
addTemplates(
config,
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
)
}
export function withUnrestrictedTemplates(count: number): TemplateOperator {
return (config) => addTemplates(config, generateTemplates(count))
}
/**
* Override the index payload entirely. Useful when a test needs a custom
* `WorkflowTemplates[]` shape (e.g. multiple modules).
*/
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
return (config) => ({ ...config, index })
}
export class TemplateHelper {
private templates: TemplateInfo[]
private index: WorkflowTemplates[] | null
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
constructor(
private readonly page: Page,
config: TemplateConfig = emptyConfig()
) {
this.templates = cloneTemplates(config.templates)
this.index = cloneIndex(config.index)
}
configure(...operators: TemplateOperator[]): void {
const config = operators.reduce<TemplateConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
this.templates = cloneTemplates(config.templates)
this.index = cloneIndex(config.index)
}
async mock(): Promise<void> {
await this.mockIndex()
await this.mockThumbnails()
}
async mockIndex(): Promise<void> {
const indexHandler = async (route: Route) => {
const payload = this.index ?? mockTemplateIndex(this.templates)
await route.fulfill({
status: 200,
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
}
const indexPattern = '**/templates/index.json'
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
await this.page.route(indexPattern, indexHandler)
}
async mockThumbnails(): Promise<void> {
const thumbnailHandler = async (route: Route) => {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
})
}
const thumbnailPattern = '**/templates/**.webp'
this.routeHandlers.push({
pattern: thumbnailPattern,
handler: thumbnailHandler
})
await this.page.route(thumbnailPattern, thumbnailHandler)
}
getTemplates(): TemplateInfo[] {
return cloneTemplates(this.templates)
}
get templateCount(): number {
return this.templates.length
}
async clearMocks(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
this.templates = []
this.index = null
}
}
export function createTemplateHelper(
page: Page,
...operators: TemplateOperator[]
): TemplateHelper {
const config = operators.reduce<TemplateConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
return new TemplateHelper(page, config)
}

View File

@@ -1,15 +0,0 @@
import { test as base } from '@playwright/test'
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
export const jobsApiMockFixture = base.extend<{
jobsApi: JobsApiMock
}>({
jobsApi: async ({ page }, use) => {
const jobsApi = new JobsApiMock(page)
await use(jobsApi)
await jobsApi.clear()
}
})

View File

@@ -0,0 +1,169 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { z } from 'zod'
import type {
JobStatus,
RawJobListItem,
zJobsListResponse
} from '@/platform/remote/comfyui/jobs/jobTypes'
type JobsListResponse = z.infer<typeof zJobsListResponse>
const terminalJobStatuses = [
'completed',
'failed',
'cancelled'
] as const satisfies readonly JobStatus[]
const activeJobStatuses = [
'in_progress',
'pending'
] as const satisfies readonly JobStatus[]
const defaultJobsListLimit = 200
const defaultScenarioHistoryLimit = 64
const defaultJobsListOffset = 0
const defaultRouteMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
interface JobsListRoute {
statuses: readonly JobStatus[]
jobs: readonly RawJobListItem[]
limit?: number
offset?: number
}
interface JobsScenario {
history?: readonly RawJobListItem[]
queue?: readonly RawJobListItem[]
}
function hasExactStatuses(url: URL, statuses: readonly JobStatus[]): boolean {
const requestedStatuses = new Set(
url.searchParams.get('status')?.split(',') ?? []
)
return (
requestedStatuses.size === statuses.length &&
statuses.every((status) => requestedStatuses.has(status))
)
}
function searchParamNumber(url: URL, name: string, fallback: number): number {
const value = url.searchParams.get(name)
return value === null ? fallback : Number(value)
}
function hasJobsListPageParams(
url: URL,
{ limit, offset }: Pick<JobsListRoute, 'limit' | 'offset'>
): boolean {
return (
searchParamNumber(url, 'limit', defaultJobsListLimit) ===
(limit ?? defaultJobsListLimit) &&
searchParamNumber(url, 'offset', defaultJobsListOffset) ===
(offset ?? defaultJobsListOffset)
)
}
function isJobsListRequest(url: URL, route: JobsListRoute): boolean {
return (
url.pathname.endsWith('/api/jobs') &&
hasExactStatuses(url, route.statuses) &&
hasJobsListPageParams(url, route)
)
}
function createJobsListResponse({
jobs,
limit = defaultJobsListLimit,
offset = defaultJobsListOffset
}: Omit<JobsListRoute, 'statuses'>): JobsListResponse {
const pageJobs = jobs.slice(offset, offset + limit)
return {
jobs: pageJobs,
pagination: {
offset,
limit,
total: jobs.length,
has_more: offset + pageJobs.length < jobs.length
}
}
}
export function createRouteMockJob({
id,
...overrides
}: { id: string } & Partial<Omit<RawJobListItem, 'id'>>): RawJobListItem {
return {
id,
status: 'completed',
create_time: defaultRouteMockJobTimestamp,
execution_start_time: defaultRouteMockJobTimestamp,
execution_end_time: defaultRouteMockJobTimestamp + 5_000,
preview_output: {
filename: `output_${id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
export class JobsRouteMocker {
constructor(private readonly page: Page) {}
async mockJobsHistory(
jobs: readonly RawJobListItem[],
limit = defaultJobsListLimit
): Promise<void> {
await this.mockJobsList({
statuses: terminalJobStatuses,
jobs,
limit
})
}
async mockJobsQueue(jobs: readonly RawJobListItem[]): Promise<void> {
await this.mockJobsList({
statuses: activeJobStatuses,
jobs
})
}
async mockJobsScenario({ history, queue }: JobsScenario): Promise<void> {
if (history) {
await this.mockJobsHistory(history, defaultScenarioHistoryLimit)
}
if (queue) {
await this.mockJobsQueue(queue)
}
}
async mockJobsList(route: JobsListRoute): Promise<void> {
const response = createJobsListResponse(route)
await this.page.route(
(url) => isJobsListRequest(url, route),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'GET') {
await requestRoute.fallback()
return
}
await requestRoute.fulfill({ json: response })
}
)
}
}
export const jobsRouteFixture = base.extend<{
jobsRoutes: JobsRouteMocker
}>({
jobsRoutes: async ({ page }, use) => {
await use(new JobsRouteMocker(page))
await page.unrouteAll({ behavior: 'wait' })
}
})

View File

@@ -0,0 +1,16 @@
import { test as base } from '@playwright/test'
import type { TemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
import { createTemplateHelper } from '@e2e/fixtures/helpers/TemplateHelper'
export const templateApiFixture = base.extend<{
templateApi: TemplateHelper
}>({
templateApi: async ({ page }, use) => {
const templateApi = createTemplateHelper(page)
await use(templateApi)
await templateApi.clearMocks()
}
})

View File

@@ -122,3 +122,19 @@ export async function saveAndReopenInAppMode(
await comfyPage.appMode.toggleAppMode()
}
export async function saveCloseAndReopenInBuilder(
comfyPage: ComfyPage,
appMode: AppModeHelper,
workflowName: string
) {
await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click()
await expect(appMode.saveAs.successDialog).toBeHidden()
await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName)
await appMode.enterBuilder()
await appMode.steps.goToInputs()
}

View File

@@ -1,52 +0,0 @@
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
export function createMockJob(
overrides: Partial<JobEntry> & { id: string }
): JobEntry {
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5_000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
function isTerminalStatus(status: JobEntry['status']) {
return status === 'completed' || status === 'failed' || status === 'cancelled'
}
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
const updateTime =
listItem.execution_end_time ??
listItem.execution_start_time ??
listItem.create_time
const detail: JobDetailResponse = {
...listItem,
update_time: updateTime,
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
}
return {
listItem,
detail
}
}
export function createMockJobRecords(
listItems: readonly JobEntry[]
): MockJobRecord[] {
return listItems.map(createMockJobRecord)
}

View File

@@ -0,0 +1,44 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import {
saveCloseAndReopenInBuilder,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']
test.describe(
'App builder input persistence after reload',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('persists selected inputs after save and reopen without visibility errors', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupBuilder(comfyPage, undefined, WIDGETS)
await appMode.steps.goToInputs()
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
const workflowName = `${Date.now()} input-persistence`
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
for (const widget of WIDGETS) {
await expect(
appMode.select.getInputItemSubtitle(widget)
).not.toContainText('Widget not visible')
}
})
}
)

View File

@@ -5,26 +5,18 @@ import {
import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
builderSaveAs,
openWorkflowFromSidebar,
saveCloseAndReopenInBuilder,
setupBuilder
} from '@e2e/fixtures/utils/builderTestUtils'
const WIDGETS = ['seed', 'steps', 'cfg']
/** Save as app, close it by loading default, reopen from sidebar, enter app mode. */
async function saveCloseAndReopenAsApp(
comfyPage: ComfyPage,
appMode: AppModeHelper,
workflowName: string
) {
await appMode.steps.goToPreview()
await builderSaveAs(appMode, workflowName)
await appMode.saveAs.closeButton.click()
await expect(appMode.saveAs.successDialog).toBeHidden()
await appMode.footer.exitBuilder()
await openWorkflowFromSidebar(comfyPage, workflowName)
await saveCloseAndReopenInBuilder(comfyPage, appMode, workflowName)
await appMode.toggleAppMode()
}

View File

@@ -247,6 +247,14 @@ test.describe('Cloud notification dialog', () => {
await dialog.back.click()
await expect(dialog.root).toBeHidden()
})
test('Should not advertise free monthly credits', async ({ comfyPage }) => {
const dialog = new CloudNotification(comfyPage.page)
await dialog.open()
await expect(dialog.root.getByText(/Free Credits/i)).toHaveCount(0)
await expect(dialog.root.getByText(/400/)).toHaveCount(0)
})
})
test('Blueprint overwrite', { tag: ['@subgraph'] }, async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -8,15 +8,15 @@ import {
} from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import {
createMockJob,
createMockJobRecords
} from '@e2e/fixtures/utils/jobFixtures'
createRouteMockJob,
jobsRouteFixture
} from '@e2e/fixtures/jobsRouteFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const ossTest = mergeTests(comfyPageFixture, jobsApiMockFixture)
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
const outputHash =
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
const plainVideoFileName = 'plain_video.mp4'
@@ -213,9 +213,9 @@ async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
.toBe(true)
}
function outputHistoryJobs() {
return createMockJobRecords([
createMockJob({
function outputHistoryJobs(): RawJobListItem[] {
return [
createRouteMockJob({
id: 'history-output-image',
preview_output: {
filename: 'ComfyUI_00001_.png',
@@ -225,7 +225,7 @@ function outputHistoryJobs() {
mediaType: 'images'
}
}),
createMockJob({
createRouteMockJob({
id: 'history-output-video',
preview_output: {
filename: 'clip.mp4',
@@ -235,7 +235,7 @@ function outputHistoryJobs() {
mediaType: 'video'
}
}),
createMockJob({
createRouteMockJob({
id: 'history-output-audio',
preview_output: {
filename: 'sound.wav',
@@ -245,7 +245,7 @@ function outputHistoryJobs() {
mediaType: 'audio'
}
})
])
]
}
ossTest.describe(
@@ -258,8 +258,9 @@ ossTest.describe(
ossTest(
'resolves annotated output media from job history',
async ({ comfyPage, jobsApi }) => {
await jobsApi.mockJobs(outputHistoryJobs())
async ({ comfyPage, jobsRoutes }) => {
await jobsRoutes.mockJobsHistory(outputHistoryJobs())
await jobsRoutes.mockJobsQueue([])
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_output_annotations'

View File

@@ -1,56 +1,54 @@
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
import {
createMockJob,
createMockJobRecords
} from '@e2e/fixtures/utils/jobFixtures'
createRouteMockJob,
jobsRouteFixture
} from '@e2e/fixtures/jobsRouteFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
const mockJobTimestamp = Date.UTC(2026, 0, 1, 12)
const now = Date.now()
const MOCK_JOBS: JobEntry[] = [
createMockJob({
const MOCK_JOBS: RawJobListItem[] = [
createRouteMockJob({
id: 'job-completed-1',
status: 'completed',
create_time: now - 60_000,
execution_start_time: now - 60_000,
execution_end_time: now - 50_000,
create_time: mockJobTimestamp - 60_000,
execution_start_time: mockJobTimestamp - 60_000,
execution_end_time: mockJobTimestamp - 50_000,
outputs_count: 2
}),
createMockJob({
createRouteMockJob({
id: 'job-completed-2',
status: 'completed',
create_time: now - 120_000,
execution_start_time: now - 120_000,
execution_end_time: now - 115_000,
create_time: mockJobTimestamp - 120_000,
execution_start_time: mockJobTimestamp - 120_000,
execution_end_time: mockJobTimestamp - 115_000,
outputs_count: 1
}),
createMockJob({
createRouteMockJob({
id: 'job-failed-1',
status: 'failed',
create_time: now - 30_000,
execution_start_time: now - 30_000,
execution_end_time: now - 28_000,
create_time: mockJobTimestamp - 30_000,
execution_start_time: mockJobTimestamp - 30_000,
execution_end_time: mockJobTimestamp - 28_000,
outputs_count: 0
}),
createMockJob({
createRouteMockJob({
id: 'job-failed-bottom',
status: 'failed',
create_time: now - 180_000,
execution_start_time: now - 180_000,
execution_end_time: now - 178_000,
create_time: mockJobTimestamp - 180_000,
execution_start_time: mockJobTimestamp - 180_000,
execution_end_time: mockJobTimestamp - 178_000,
outputs_count: 0
})
]
test.describe('Queue overlay', () => {
test.beforeEach(async ({ comfyPage, jobsApi }) => {
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
test.beforeEach(async ({ comfyPage, jobsRoutes }) => {
await jobsRoutes.mockJobsScenario({ history: MOCK_JOBS, queue: [] })
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
await comfyPage.setup()

View File

@@ -1,13 +1,13 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { makeTemplate } from '@e2e/fixtures/data/templateFixtures'
import { withTemplates } from '@e2e/fixtures/helpers/TemplateHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { templateApiFixture } from '@e2e/fixtures/templateApiFixture'
const test = mergeTests(comfyPageFixture, templateApiFixture)
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
@@ -17,7 +17,7 @@ test.describe(
'Template distribution filtering count',
{ tag: '@cloud' },
() => {
test.beforeEach(async ({ comfyPage }) => {
test.beforeEach(async ({ comfyPage, templateApi }) => {
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
await comfyPage.settings.setSetting(
'Comfy.Templates.SelectedUseCases',
@@ -26,53 +26,37 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
await comfyPage.page.route('**/templates/**.webp', async (route) => {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
})
})
await templateApi.mockThumbnails()
})
test('displayed count matches visible cards when distribution filter excludes templates', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'cloud-1',
title: 'Cloud One',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'cloud-2',
title: 'Cloud Two',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-hidden',
title: 'Desktop Hidden',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'universal',
title: 'Universal'
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'cloud-1',
title: 'Cloud One',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'cloud-2',
title: 'Cloud Two',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-hidden',
title: 'Desktop Hidden',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'universal',
title: 'Universal'
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -86,45 +70,38 @@ test.describe(
})
test('filtered count reflects distribution + model filter together', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'wan-cloud-1',
title: 'Wan Cloud 1',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-cloud-2',
title: 'Wan Cloud 2',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'wan-cloud-1',
title: 'Wan Cloud 1',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-cloud-2',
title: 'Wan Cloud 2',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -144,36 +121,29 @@ test.describe(
})
test('desktop-only templates never leak into DOM on cloud distribution', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'cloud-visible',
title: 'Cloud Visible',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-leak-check',
title: 'Desktop Leak Check',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'local-leak-check',
title: 'Local Leak Check',
includeOnDistributions: [Local]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'cloud-visible',
title: 'Cloud Visible',
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'desktop-leak-check',
title: 'Desktop Leak Check',
includeOnDistributions: [Desktop]
}),
makeTemplate({
name: 'local-leak-check',
title: 'Local Leak Check',
includeOnDistributions: [Local]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -200,28 +170,21 @@ test.describe(
})
test('templates without includeOnDistributions are visible on cloud', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
makeTemplate({
name: 'cloud-only',
title: 'Cloud Only',
includeOnDistributions: [Cloud]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
makeTemplate({
name: 'cloud-only',
title: 'Cloud Only',
includeOnDistributions: [Cloud]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -234,39 +197,32 @@ test.describe(
})
test('clear filters button resets to correct distribution-filtered total', async ({
comfyPage
comfyPage,
templateApi
}) => {
const templates: TemplateInfo[] = [
makeTemplate({
name: 'wan-cloud',
title: 'Wan Cloud',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
})
]
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(mockTemplateIndex(templates)),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
templateApi.configure(
withTemplates([
makeTemplate({
name: 'wan-cloud',
title: 'Wan Cloud',
models: ['Wan 2.2'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'flux-cloud',
title: 'Flux Cloud',
models: ['Flux'],
includeOnDistributions: [Cloud]
}),
makeTemplate({
name: 'wan-desktop',
title: 'Wan Desktop',
models: ['Wan 2.2'],
includeOnDistributions: [Desktop]
})
])
)
await templateApi.mockIndex()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -26,6 +26,10 @@
width: 100%;
height: 100%;
margin: 0;
/* Disable trackpad two-finger horizontal swipe back/forward navigation
and other overscroll gestures. ComfyUI is a full-screen editor; the
browser's overscroll behaviors only ever leave or break the workflow. */
overscroll-behavior: none;
}
body {
display: grid;

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.7",
"version": "1.45.8",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

723
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,7 @@ catalog:
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.2.0
'@tailwindcss/vite': ^4.3.0
'@tanstack/vue-virtual': ^3.13.12
'@testing-library/jest-dom': ^6.9.1
'@testing-library/user-event': ^14.6.1
@@ -112,7 +112,7 @@ catalog:
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.2.0
tailwindcss: ^4.3.0
three: ^0.170.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6

20
src/base/wheelGestures.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Wheel events whose browser default would break the editing experience.
* On macOS trackpads:
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
* recovery short of a page reload.
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
* back/forward navigation, which leaves the workflow.
*
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
* trackpad samples) intentionally falls on the false branch so native
* vertical scroll wins on a tie.
*
* Components that intercept wheel events should suppress the default for
* these gestures even when they otherwise let the browser scroll natively.
*/
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
event.ctrlKey ||
event.metaKey ||
Math.abs(event.deltaX) > Math.abs(event.deltaY)

View File

@@ -16,14 +16,6 @@ vi.mock('@/stores/nodeBookmarkStore', () => ({
})
}))
vi.mock('primevue/dialog', () => ({
default: {
name: 'Dialog',
template: '<div v-if="visible"><slot /><slot name="footer" /></div>',
props: ['visible']
}
}))
vi.mock('primevue/selectbutton', () => ({
default: {
name: 'SelectButton',
@@ -32,8 +24,29 @@ vi.mock('primevue/selectbutton', () => ({
}
}))
vi.mock('primevue/divider', () => ({
default: { name: 'Divider', template: '<hr />' }
vi.mock('@/components/ui/dialog/Dialog.vue', () => ({
default: { name: 'Dialog', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogPortal.vue', () => ({
default: { name: 'DialogPortal', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogOverlay.vue', () => ({
default: { name: 'DialogOverlay', template: '<div />' }
}))
vi.mock('@/components/ui/dialog/DialogContent.vue', () => ({
default: { name: 'DialogContent', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogHeader.vue', () => ({
default: { name: 'DialogHeader', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogFooter.vue', () => ({
default: { name: 'DialogFooter', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogTitle.vue', () => ({
default: { name: 'DialogTitle', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogClose.vue', () => ({
default: { name: 'DialogClose', template: '<button />' }
}))
vi.mock('@/components/common/ColorCustomizationSelector.vue', () => ({

View File

@@ -1,72 +1,111 @@
<template>
<Dialog v-model:visible="visible" :header="$t('g.customizeFolder')">
<div class="p-fluid">
<div class="field icon-field">
<label for="icon">{{ $t('g.icon') }}</label>
<SelectButton
v-model="selectedIcon"
:options="iconOptions"
option-label="name"
data-key="value"
>
<template #option="slotProps">
<i
:class="['pi', slotProps.option.value, 'mr-2']"
:style="{ color: finalColor }"
<Dialog v-model:open="visible" :modal="false">
<DialogPortal>
<DialogOverlay />
<DialogContent
size="md"
:aria-labelledby="titleId"
@pointer-down-outside="onPointerDownOutside"
>
<DialogHeader>
<DialogTitle :id="titleId">
{{ $t('g.customizeFolder') }}
</DialogTitle>
<DialogClose />
</DialogHeader>
<div class="flex flex-col gap-4 px-4 py-2">
<div class="flex flex-col gap-2">
<label for="customization-icon" class="text-sm font-medium">
{{ $t('g.icon') }}
</label>
<SelectButton
id="customization-icon"
v-model="selectedIcon"
:options="iconOptions"
option-label="name"
data-key="value"
>
<template #option="slotProps">
<i
:class="['pi', slotProps.option.value, 'mr-2']"
:style="{ color: finalColor }"
/>
</template>
</SelectButton>
</div>
<hr class="border-t border-border-subtle" />
<div class="flex flex-col gap-2">
<label for="customization-color" class="text-sm font-medium">
{{ $t('g.color') }}
</label>
<ColorCustomizationSelector
id="customization-color"
v-model="finalColor"
:color-options="colorOptions"
/>
</template>
</SelectButton>
</div>
<Divider />
<div class="field color-field">
<label for="color">{{ $t('g.color') }}</label>
<ColorCustomizationSelector
v-model="finalColor"
:color-options="colorOptions"
/>
</div>
</div>
<template #footer>
<Button variant="textonly" @click="resetCustomization">
<i class="pi pi-refresh" />
{{ $t('g.reset') }}
</Button>
<Button autofocus @click="confirmCustomization">
<i class="pi pi-check" />
{{ $t('g.confirm') }}
</Button>
</template>
</div>
</div>
<DialogFooter>
<Button variant="textonly" @click="resetCustomization">
<i class="pi pi-refresh" />
{{ $t('g.reset') }}
</Button>
<Button autofocus @click="confirmCustomization">
<i class="pi pi-check" />
{{ $t('g.confirm') }}
</Button>
</DialogFooter>
</DialogContent>
</DialogPortal>
</Dialog>
</template>
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import Divider from 'primevue/divider'
import SelectButton from 'primevue/selectbutton'
import { computed, ref, watch } from 'vue'
import { ref, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ColorCustomizationSelector from '@/components/common/ColorCustomizationSelector.vue'
import Button from '@/components/ui/button/Button.vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
const { t } = useI18n()
const props = defineProps<{
modelValue: boolean
const { initialIcon, initialColor } = defineProps<{
initialIcon?: string
initialColor?: string
}>()
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', icon: string, color: string): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const titleId = useId()
// PrimeVue ColorPicker overlay teleports to body. Reka treats clicks on it as
// outside and would dismiss the dialog mid-color-pick. Treat any PrimeVue
// overlay click as inside.
const PRIMEVUE_OVERLAY_SELECTORS =
'.p-colorpicker-panel, .p-overlay, .p-overlay-mask'
function onPointerDownOutside(
event: CustomEvent<{ originalEvent: PointerEvent }>
) {
const target = event.detail.originalEvent.target
if (target instanceof Element && target.closest(PRIMEVUE_OVERLAY_SELECTORS)) {
event.preventDefault()
}
}
const nodeBookmarkStore = useNodeBookmarkStore()
@@ -95,30 +134,22 @@ const defaultIcon = iconOptions.find(
)
const selectedIcon = ref(defaultIcon ?? iconOptions[0])
const finalColor = ref(
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
)
const finalColor = ref(initialColor || nodeBookmarkStore.defaultBookmarkColor)
const resetCustomization = () => {
selectedIcon.value =
iconOptions.find((option) => option.value === props.initialIcon) ??
iconOptions[0]
finalColor.value =
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
iconOptions.find((option) => option.value === initialIcon) ?? iconOptions[0]
finalColor.value = initialColor || nodeBookmarkStore.defaultBookmarkColor
}
const confirmCustomization = () => {
emit('confirm', selectedIcon.value.value, finalColor.value)
closeDialog()
}
const closeDialog = () => {
visible.value = false
}
watch(
() => props.modelValue,
(newValue: boolean) => {
visible,
(newValue) => {
if (newValue) {
resetCustomization()
}
@@ -135,10 +166,4 @@ watch(
.p-selectbutton .p-button .pi {
font-size: 1.5rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>

View File

@@ -27,7 +27,7 @@ const { t } = useI18n()
/>
<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"
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] -translate-1/2 rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
>
<div
v-if="title"

View File

@@ -13,7 +13,7 @@
<div class="grid grid-cols-2 gap-2">
<template v-for="col in systemColumns" :key="col.field">
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
{{ $t(col.headerKey) }}
{{ col.header }}
</div>
<div :class="cn(isOutdated(col) && 'text-danger-100')">
{{ getDisplayValue(col) }}
@@ -58,10 +58,8 @@ import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { useI18n } from 'vue-i18n'
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
const { t } = useI18n()
const props = defineProps<{
stats: SystemStats
@@ -80,7 +78,7 @@ type SystemInfoKey = keyof SystemStats['system']
type ColumnDef = {
field: SystemInfoKey
headerKey: string
header: string
getValue?: () => string
format?: (value: string) => string
formatNumber?: (value: number) => string
@@ -88,45 +86,31 @@ type ColumnDef = {
/** Columns for local distribution */
const localColumns: ColumnDef[] = [
{ field: 'os', headerKey: 'g.systemStatsOS' },
{ field: 'python_version', headerKey: 'g.systemStatsPythonVersion' },
{ field: 'embedded_python', headerKey: 'g.systemStatsEmbeddedPython' },
{ field: 'pytorch_version', headerKey: 'g.systemStatsPyTorchVersion' },
{ field: 'argv', headerKey: 'g.systemStatsArguments' },
{
field: 'ram_total',
headerKey: 'g.systemStatsRAMTotal',
formatNumber: formatSize
},
{
field: 'ram_free',
headerKey: 'g.systemStatsRAMFree',
formatNumber: formatSize
},
{
field: 'installed_templates_version',
headerKey: 'g.systemStatsTemplatesVersion'
}
{ field: 'os', header: 'OS' },
{ field: 'python_version', header: 'Python Version' },
{ field: 'embedded_python', header: 'Embedded Python' },
{ field: 'pytorch_version', header: 'Pytorch Version' },
{ field: 'argv', header: 'Arguments' },
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize },
{ field: 'installed_templates_version', header: 'Templates Version' }
]
/** Columns for cloud distribution */
const cloudColumns: ColumnDef[] = [
{ field: 'cloud_version', headerKey: 'g.systemStatsCloudVersion' },
{ field: 'cloud_version', header: 'Cloud Version' },
{
field: 'comfyui_version',
headerKey: 'g.systemStatsComfyUIVersion',
header: 'ComfyUI Version',
format: formatCommitHash
},
{
field: 'comfyui_frontend_version',
headerKey: 'g.systemStatsFrontendVersion',
header: 'Frontend Version',
getValue: () => frontendCommit,
format: formatCommitHash
},
{
field: 'workflow_templates_version',
headerKey: 'g.systemStatsTemplatesVersion'
}
{ field: 'workflow_templates_version', header: 'Templates Version' }
]
const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
@@ -157,7 +141,7 @@ function formatSystemInfoText(): string {
for (const col of systemColumns.value) {
const display = getDisplayValue(col)
if (display !== undefined && display !== '') {
lines.push(`${t(col.headerKey)}: ${display}`)
lines.push(`${col.header}: ${display}`)
}
}

View File

@@ -1,7 +1,7 @@
<template>
<div
ref="container"
class="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface) h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable]"
class="h-full scrollbar-thin scrollbar-thumb-(--dialog-surface) scrollbar-track-transparent scrollbar-gutter-stable overflow-y-auto [overflow-anchor:none]"
>
<div :style="topSpacerStyle" />
<div :style="mergedGridStyle">

View File

@@ -14,7 +14,7 @@
</template>
<template #header>
<FormSearchInput
<AsyncSearchInput
v-model="searchInput"
:searcher="applySearchQuery"
:debounce-ms="400"
@@ -412,7 +412,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import Tag from '@/components/chip/Tag.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'

View File

@@ -35,13 +35,13 @@
</Button>
</div>
<template v-if="reportOpen">
<Divider />
<ScrollPanel class="h-[400px] w-full max-w-[80vw]">
<hr class="border-t border-border-subtle" />
<div class="h-[400px] w-full max-w-[80vw] overflow-auto">
<pre class="wrap-break-word whitespace-pre-wrap">{{
reportContent
}}</pre>
</ScrollPanel>
<Divider />
</div>
<hr class="border-t border-border-subtle" />
</template>
<div class="flex justify-end gap-4">
<FindIssueButton
@@ -62,8 +62,6 @@
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'

View File

@@ -11,7 +11,7 @@
>
<svg
viewBox="0 0 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-current"
class="pointer-events-none size-6.25 fill-current"
>
<path
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
@@ -26,7 +26,7 @@
>
<svg
viewBox="0 0 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="pointer-events-none size-6.25 fill-(--input-text)"
>
<path
class="cls-1"
@@ -44,7 +44,7 @@
>
<svg
viewBox="-6 -7 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="pointer-events-none size-6.25 fill-(--input-text)"
>
<path
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
@@ -59,7 +59,7 @@
>
<svg
viewBox="-9 -7 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="pointer-events-none size-6.25 fill-(--input-text)"
>
<g transform="scale(-1, 1)">
<path
@@ -76,7 +76,7 @@
>
<svg
viewBox="0 0 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="pointer-events-none size-6.25 fill-(--input-text)"
>
<path
d="M7.5,1.5c-.28,0-.5.22-.5.5v11c0,.28.22.5.5.5s.5-.22.5-.5v-11c0-.28-.22-.5-.5-.5Z"
@@ -92,7 +92,7 @@
>
<svg
viewBox="0 0 15 15"
class="pointer-events-none h-6.25 w-6.25 fill-(--input-text)"
class="pointer-events-none size-6.25 fill-(--input-text)"
>
<path
d="M2,7.5c0-.28.22-.5.5-.5h11c.28,0,.5.22.5.5s-.22.5-.5.5h-11c-.28,0-.5-.22-.5-.5Z"

View File

@@ -370,7 +370,7 @@ function handleTitleCancel() {
</section>
<!-- Panel Content -->
<div class="scrollbar-thin flex-1 overflow-y-auto">
<div class="flex-1 scrollbar-thin overflow-y-auto">
<TabErrors v-if="activeTab === 'errors'" />
<template v-else-if="!hasSelection">
<TabGlobalParameters v-if="activeTab === 'parameters'" />

View File

@@ -82,7 +82,7 @@ describe('TabErrors.vue', () => {
})
],
stubs: {
FormSearchInput: {
AsyncSearchInput: {
template:
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
},

View File

@@ -4,7 +4,7 @@
<div
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<FormSearchInput v-model="searchQuery" class="flex-1" />
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
<CollapseToggleButton
v-model="isAllCollapsed"
:show="!isSearching && tabErrorGroups.length > 1"
@@ -260,7 +260,7 @@ import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'

View File

@@ -11,7 +11,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
@@ -119,7 +119,7 @@ function onCollapseUpdate() {
<div
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<FormSearchInput
<AsyncSearchInput
v-model="searchQuery"
:searcher
:update-key="favoritedWidgets"

View File

@@ -7,7 +7,7 @@ import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseTog
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
@@ -78,7 +78,7 @@ async function searcher(query: string) {
<div
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<FormSearchInput
<AsyncSearchInput
v-model="searchQuery"
:searcher
:update-key="widgetsSectionDataList"

View File

@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -122,7 +122,7 @@ const advancedLabel = computed(() => {
<div
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<FormSearchInput
<AsyncSearchInput
v-model="searchQuery"
:searcher
:update-key="widgetsSectionDataList"

View File

@@ -17,7 +17,7 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -217,7 +217,7 @@ const label = computed(() => {
<div
class="flex items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<FormSearchInput
<AsyncSearchInput
v-model="searchQuery"
:searcher
:update-key="widgetsList"

View File

@@ -20,7 +20,7 @@ import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -226,7 +226,7 @@ onMounted(() => {
<template>
<div v-if="activeNode" class="subgraph-edit-section flex h-full flex-col">
<div class="flex gap-2 border-b border-interface-stroke px-4 pt-1 pb-4">
<FormSearchInput v-model="searchQuery" />
<AsyncSearchInput v-model="searchQuery" />
</div>
<div class="flex-1">

View File

@@ -52,7 +52,7 @@
<span
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
:key="val"
class="-mx-[2px] text-lg leading-none"
class="mx-[-2px] text-lg leading-none"
:style="{ color: getLinkTypeColor(val) }"
>&bull;</span
>

View File

@@ -5,7 +5,7 @@ import { defineComponent, ref } from 'vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import FormSearchInput from './FormSearchInput.vue'
import AsyncSearchInput from './AsyncSearchInput.vue'
const i18n = createI18n({
legacy: false,
@@ -20,7 +20,7 @@ const i18n = createI18n({
}
})
type Searcher = NonNullable<ComponentProps<typeof FormSearchInput>['searcher']>
type Searcher = NonNullable<ComponentProps<typeof AsyncSearchInput>['searcher']>
function renderSearch(
initialQuery: string = '',
@@ -30,9 +30,9 @@ function renderSearch(
const query = ref(initialQuery)
const key = updateKey
const Harness = defineComponent({
components: { FormSearchInput },
components: { AsyncSearchInput },
setup: () => ({ query, searcher, key }),
template: `<FormSearchInput
template: `<AsyncSearchInput
v-model="query"
:searcher="searcher"
:update-key="key"
@@ -42,7 +42,7 @@ function renderSearch(
return { ...utils, query, key }
}
describe('FormSearchInput', () => {
describe('AsyncSearchInput', () => {
beforeEach(() => {
vi.useFakeTimers()
})

View File

@@ -47,7 +47,7 @@ watch(
searcher(debouncedSearchQuery.value, (cb) => (cleanupFn = cb))
.catch((error) => {
console.error('[SidePanelSearch] searcher failed', error)
console.error('[AsyncSearchInput] searcher failed', error)
})
.finally(() => {
if (!isCleanup) isQuerying.value = false

View File

@@ -23,7 +23,7 @@ defineExpose({
v-model="modelValue"
:class="
cn(
'flex min-h-16 w-full rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-border-default focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
'flex min-h-16 w-full scrollbar-gutter-stable rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-border-default focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
className
)
"

View File

@@ -23,8 +23,19 @@ import type { CanvasPointerEvent } from './types/events'
* - {@link LGraphCanvas.processMouseUp}
*/
export class CanvasPointer {
/** Maximum time in milliseconds to ignore click drift */
static bufferTime = 150
/**
* Maximum time in milliseconds to ignore click drift.
*
* This is the upper bound on how long after pointerdown the system will wait
* before deciding "this is a drag, not a click" when the pointer hasn't moved
* past {@link maxClickDrift}. Keep this short — drags should feel instant.
* Disambiguation between click and drag is primarily handled by distance
* ({@link maxClickDrift}); this time threshold only matters when the user
* holds the pointer still then releases. ~2 frames at 60fps is plenty.
*
* Overridden at runtime by the `Comfy.Pointer.ClickBufferTime` user setting.
*/
static bufferTime = 32
/** Maximum gap between pointerup and pointerdown events to be considered as a double click */
static doubleClickTime = 300

View File

@@ -410,7 +410,6 @@
"cloudNotification": {
"continueLocally": "المتابعة محليًا",
"exploreCloud": "جرّب السحابة مجانًا",
"feature1Title": "٤٠٠ رصيد مجاني شهريًا",
"feature2Title": "يعمل في أي مكان، فورًا",
"feature3Title": "نماذج جاهزة للاستخدام",
"feature4Title": "أفضل حزم العقد المخصصة مثبتة مسبقًا",
@@ -914,7 +913,8 @@
"extensionFileHint": "قد يكون السبب هو السكربت التالي",
"loadWorkflowTitle": "تم إلغاء التحميل بسبب خطأ في إعادة تحميل بيانات سير العمل",
"noStackTrace": "لا توجد تتبع للمكدس متاحة",
"promptExecutionError": "فشل تنفيذ الطلب"
"promptExecutionError": "فشل تنفيذ الطلب",
"queueOpenWorkflowFailedTitle": "فشل في فتح سير العمل"
},
"errorOverlay": {
"errorCount": "{count} خطأ | {count} أخطاء",
@@ -1042,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "شعار ComfyOrg",
"comfyPackageOutdated": "إصدار {name} المثبت ({installedVersion}) أقل من الإصدار المطلوب ({requiredVersion}).",
"comingSoon": "قريباً",
"command": "أمر",
"commandProhibited": "الأمر {command} محظور. يرجى التواصل مع المسؤول لمزيد من المعلومات.",

View File

@@ -64,17 +64,6 @@
"insert": "Insert",
"systemInfo": "System Info",
"devices": "Devices",
"systemStatsOS": "OS",
"systemStatsPythonVersion": "Python Version",
"systemStatsEmbeddedPython": "Embedded Python",
"systemStatsPyTorchVersion": "PyTorch Version",
"systemStatsArguments": "Arguments",
"systemStatsRAMTotal": "RAM Total",
"systemStatsRAMFree": "RAM Free",
"systemStatsTemplatesVersion": "Templates Version",
"systemStatsCloudVersion": "Cloud Version",
"systemStatsComfyUIVersion": "ComfyUI Version",
"systemStatsFrontendVersion": "Frontend Version",
"about": "About",
"add": "Add",
"confirm": "Confirm",
@@ -231,6 +220,7 @@
"versionMismatchWarningMessage": "{warning}: {detail} Visit https://docs.comfy.org/installation/update_comfyui#common-update-issues for update instructions.",
"frontendOutdated": "Frontend version {frontendVersion} is outdated. Backend requires {requiredVersion} or higher.",
"frontendNewer": "Frontend version {frontendVersion} may not be compatible with backend version {backendVersion}.",
"comfyPackageOutdated": "Installed {name} version {installedVersion} is lower than the required version {requiredVersion}.",
"updateFrontend": "Update Frontend",
"dismiss": "Dismiss",
"update": "Update",
@@ -3803,7 +3793,6 @@
"cloudNotification": {
"title": "Run ComfyUI in the Cloud",
"message": "From setup to creation in seconds. Popular models, extensions, and powerful GPUs — ready when you are.",
"feature1Title": "400 Free Credits Monthly",
"feature2Title": "Works Anywhere, Instantly",
"feature3Title": "Models Ready to Use",
"feature4Title": "Top Custom Node Packs Pre-installed",

View File

@@ -338,7 +338,7 @@
},
"Comfy_Pointer_ClickBufferTime": {
"name": "Pointer click drift delay",
"tooltip": "After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking."
"tooltip": "After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.\n\nThe distance threshold (Pointer click drift) already disambiguates clicks from drags; this time threshold only matters when the pointer is held still then released. A long delay here forces every pointerdown to wait before drag begins, which feels laggy when click+dragging an unselected node. ~2 frames at 60fps is plenty."
},
"Comfy_Pointer_ClickDrift": {
"name": "Pointer click drift (maximum distance)",

View File

@@ -410,7 +410,6 @@
"cloudNotification": {
"continueLocally": "Continuar Localmente",
"exploreCloud": "Probar la Nube Gratis",
"feature1Title": "400 Créditos Gratis al Mes",
"feature2Title": "Funciona en Cualquier Lugar, al Instante",
"feature3Title": "Modelos Listos para Usar",
"feature4Title": "Paquetes de Nodos Personalizados Preinstalados",
@@ -914,7 +913,8 @@
"extensionFileHint": "Esto puede deberse al siguiente script",
"loadWorkflowTitle": "La carga se interrumpió debido a un error al recargar los datos del flujo de trabajo",
"noStackTrace": "No hay seguimiento de pila disponible",
"promptExecutionError": "La ejecución del prompt falló"
"promptExecutionError": "La ejecución del prompt falló",
"queueOpenWorkflowFailedTitle": "No se pudo abrir el flujo de trabajo"
},
"errorOverlay": {
"errorCount": "{count} ERROR | {count} ERRORES",
@@ -1042,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo de ComfyOrg",
"comfyPackageOutdated": "La versión instalada de {name} ({installedVersion}) es inferior a la versión requerida ({requiredVersion}).",
"comingSoon": "Próximamente",
"command": "Comando",
"commandProhibited": "El comando {command} está prohibido. Contacta a un administrador para más información.",

View File

@@ -410,7 +410,6 @@
"cloudNotification": {
"continueLocally": "ادامه به صورت محلی",
"exploreCloud": "امتحان رایگان فضای ابری",
"feature1Title": "۴۰۰ اعتبار رایگان ماهانه",
"feature2Title": "قابل استفاده در هر مکان، بلافاصله",
"feature3Title": "مدل‌ها آماده استفاده",
"feature4Title": "برترین بسته‌های Node سفارشی از پیش نصب‌شده",
@@ -914,7 +913,8 @@
"extensionFileHint": "این ممکن است به دلیل اسکریپت زیر باشد",
"loadWorkflowTitle": "بارگذاری به دلیل خطا در بارگذاری مجدد داده‌های workflow متوقف شد",
"noStackTrace": "هیچ stacktraceی موجود نیست",
"promptExecutionError": "اجرای prompt با شکست مواجه شد"
"promptExecutionError": "اجرای prompt با شکست مواجه شد",
"queueOpenWorkflowFailedTitle": "باز کردن workflow با خطا مواجه شد"
},
"errorOverlay": {
"errorCount": "{count} خطا",
@@ -1042,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "لوگوی ComfyOrg",
"comfyPackageOutdated": "نسخه نصب‌شده {name} با شماره {installedVersion} پایین‌تر از نسخه مورد نیاز {requiredVersion} است.",
"comingSoon": "به‌زودی",
"command": "دستور",
"commandProhibited": "دستور {command} مجاز نیست. برای اطلاعات بیشتر با مدیر تماس بگیرید.",

View File

@@ -410,7 +410,6 @@
"cloudNotification": {
"continueLocally": "Continuer localement",
"exploreCloud": "Essayer le cloud gratuitement",
"feature1Title": "400 crédits gratuits par mois",
"feature2Title": "Fonctionne partout, instantanément",
"feature3Title": "Modèles prêts à lemploi",
"feature4Title": "Packs de nœuds personnalisés préinstallés",
@@ -914,7 +913,8 @@
"extensionFileHint": "Cela peut être dû au script suivant",
"loadWorkflowTitle": "Chargement interrompu en raison d'une erreur de rechargement des données de workflow",
"noStackTrace": "Aucune trace de pile disponible",
"promptExecutionError": "L'exécution de l'invite a échoué"
"promptExecutionError": "L'exécution de l'invite a échoué",
"queueOpenWorkflowFailedTitle": "Échec de l'ouverture du workflow"
},
"errorOverlay": {
"errorCount": "{count} ERREUR | {count} ERREURS",
@@ -1042,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo ComfyOrg",
"comfyPackageOutdated": "La version installée de {name} ({installedVersion}) est inférieure à la version requise ({requiredVersion}).",
"comingSoon": "Bientôt disponible",
"command": "Commande",
"commandProhibited": "La commande {command} est interdite. Contactez un administrateur pour plus d'informations.",

View File

@@ -410,7 +410,6 @@
"cloudNotification": {
"continueLocally": "ローカルで続行",
"exploreCloud": "クラウドを無料で試す",
"feature1Title": "毎月400クレジット無料",
"feature2Title": "どこでも即時利用可能",
"feature3Title": "すぐに使えるモデル",
"feature4Title": "人気カスタムノードパックをプリインストール",
@@ -914,7 +913,8 @@
"extensionFileHint": "これは次のスクリプトが原因かもしれません",
"loadWorkflowTitle": "ワークフローデータの再読み込みエラーにより、読み込みが中止されました",
"noStackTrace": "スタックトレースは利用できません",
"promptExecutionError": "プロンプトの実行に失敗しました"
"promptExecutionError": "プロンプトの実行に失敗しました",
"queueOpenWorkflowFailedTitle": "ワークフローのオープンに失敗しました"
},
"errorOverlay": {
"errorCount": "{count} 件のエラー",
@@ -1042,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrgロゴ",
"comfyPackageOutdated": "インストールされている{name}のバージョン{installedVersion}は、必要なバージョン{requiredVersion}よりも低いです。",
"comingSoon": "近日公開",
"command": "コマンド",
"commandProhibited": "コマンド {command} は禁止されています。詳細は管理者にお問い合わせください。",

View File

@@ -410,7 +410,6 @@
"cloudNotification": {
"continueLocally": "로컬에서 계속하기",
"exploreCloud": "클라우드 무료 체험",
"feature1Title": "매월 400 크레딧 무료 제공",
"feature2Title": "어디서나 즉시 사용 가능",
"feature3Title": "즉시 사용 가능한 모델",
"feature4Title": "최고의 커스텀 노드 팩 사전 설치",
@@ -914,7 +913,8 @@
"extensionFileHint": "다음 스크립트 때문일 수 있습니다",
"loadWorkflowTitle": "워크플로 데이터를 다시 로드하는 중 오류로 인해 로드가 중단되었습니다",
"noStackTrace": "스택 추적을 사용할 수 없습니다",
"promptExecutionError": "프롬프트 실행 실패"
"promptExecutionError": "프롬프트 실행 실패",
"queueOpenWorkflowFailedTitle": "워크플로우 열기에 실패했습니다"
},
"errorOverlay": {
"errorCount": "{count}개 오류",
@@ -1042,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg 로고",
"comfyPackageOutdated": "설치된 {name} 버전 {installedVersion}이(가) 필요한 버전 {requiredVersion}보다 낮습니다.",
"comingSoon": "곧 출시 예정",
"command": "명령",
"commandProhibited": "{command}는 금지된 명령입니다. 자세한 정보는 관리자에게 문의하십시오.",

View File

@@ -410,7 +410,6 @@
"cloudNotification": {
"continueLocally": "Continuar Localmente",
"exploreCloud": "Experimente a Nuvem Gratuitamente",
"feature1Title": "400 Créditos Grátis por Mês",
"feature2Title": "Funciona em Qualquer Lugar, Instantaneamente",
"feature3Title": "Modelos Prontos para Usar",
"feature4Title": "Principais Pacotes de Nodes Personalizados Pré-instalados",
@@ -914,7 +913,8 @@
"extensionFileHint": "Isso pode ser devido ao seguinte script",
"loadWorkflowTitle": "Carregamento abortado devido a erro ao recarregar os dados do fluxo de trabalho",
"noStackTrace": "Nenhum stacktrace disponível",
"promptExecutionError": "Falha na execução do prompt"
"promptExecutionError": "Falha na execução do prompt",
"queueOpenWorkflowFailedTitle": "Falha ao abrir o workflow"
},
"errorOverlay": {
"errorCount": "{count} ERRO | {count} ERROS",
@@ -1042,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo do ComfyOrg",
"comfyPackageOutdated": "A versão instalada de {name} ({installedVersion}) é inferior à versão necessária ({requiredVersion}).",
"comingSoon": "Em breve",
"command": "Comando",
"commandProhibited": "O comando {command} é proibido. Entre em contato com um administrador para mais informações.",

View File

@@ -410,7 +410,6 @@
"cloudNotification": {
"continueLocally": "Продолжить локально",
"exploreCloud": "Попробовать облако бесплатно",
"feature1Title": "400 бесплатных кредитов в месяц",
"feature2Title": "Работает везде и мгновенно",
"feature3Title": "Модели готовы к использованию",
"feature4Title": "Лучшие пользовательские пакеты узлов предустановлены",
@@ -914,7 +913,8 @@
"extensionFileHint": "Это может быть связано со следующим скриптом",
"loadWorkflowTitle": "Загрузка прервана из-за ошибки при перезагрузке данных рабочего процесса",
"noStackTrace": "Стек вызовов недоступен",
"promptExecutionError": "Ошибка выполнения запроса"
"promptExecutionError": "Ошибка выполнения запроса",
"queueOpenWorkflowFailedTitle": "Не удалось открыть рабочий процесс"
},
"errorOverlay": {
"errorCount": "{count} ОШИБОК | {count} ОШИБКА | {count} ОШИБКИ",
@@ -1042,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Логотип ComfyOrg",
"comfyPackageOutdated": "Установленная версия {name} ({installedVersion}) ниже требуемой версии ({requiredVersion}).",
"comingSoon": "Скоро будет",
"command": "Команда",
"commandProhibited": "Команда {command} запрещена. Свяжитесь с администратором для получения дополнительной информации.",

View File

@@ -410,7 +410,6 @@
"cloudNotification": {
"continueLocally": "Yerelde Devam Et",
"exploreCloud": "Bulutu Ücretsiz Dene",
"feature1Title": "Aylık 400 Ücretsiz Kredi",
"feature2Title": "Her Yerde, Anında Çalışır",
"feature3Title": "Kullanıma Hazır Modeller",
"feature4Title": "En İyi Özel Node Paketleri Önceden Yüklü",
@@ -914,7 +913,8 @@
"extensionFileHint": "Bu, aşağıdaki komut dosyasından kaynaklanıyor olabilir",
"loadWorkflowTitle": "İş akışı verileri yeniden yüklenirken hata nedeniyle yükleme iptal edildi",
"noStackTrace": "Yığın izi mevcut değil",
"promptExecutionError": "İstem yürütmesi başarısız oldu"
"promptExecutionError": "İstem yürütmesi başarısız oldu",
"queueOpenWorkflowFailedTitle": "İş Akışıılamadı"
},
"errorOverlay": {
"errorCount": "{count} HATA | {count} HATA",
@@ -1042,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg Logosu",
"comfyPackageOutdated": "Yüklü {name} sürümü ({installedVersion}), gerekli sürümden ({requiredVersion}) daha eski.",
"comingSoon": "Çok Yakında",
"command": "Komut",
"commandProhibited": "{command} komutu yasak. Daha fazla bilgi için bir yöneticiyle iletişime geçin.",

View File

@@ -410,7 +410,6 @@
"cloudNotification": {
"continueLocally": "在本地繼續",
"exploreCloud": "免費試用雲端",
"feature1Title": "每月 400 點免費額度",
"feature2Title": "隨處可用,立即啟動",
"feature3Title": "模型即刻可用",
"feature4Title": "頂級自訂節點包預先安裝",
@@ -914,7 +913,8 @@
"extensionFileHint": "這可能是由於以下指令碼所致",
"loadWorkflowTitle": "由於重新載入工作流程資料時發生錯誤,已中止載入",
"noStackTrace": "沒有可用的堆疊追蹤",
"promptExecutionError": "提示執行失敗"
"promptExecutionError": "提示執行失敗",
"queueOpenWorkflowFailedTitle": "開啟工作流程失敗"
},
"errorOverlay": {
"errorCount": "{count} 個錯誤",
@@ -1042,6 +1042,7 @@
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg 標誌",
"comfyPackageOutdated": "已安裝的 {name} 版本 {installedVersion} 低於所需版本 {requiredVersion}。",
"comingSoon": "即將推出",
"command": "指令",
"commandProhibited": "指令 {command} 已被禁止。如需更多資訊,請聯絡管理員。",

View File

@@ -410,7 +410,6 @@
"cloudNotification": {
"continueLocally": "本地继续",
"exploreCloud": "免费试用云端",
"feature1Title": "每月 400 免费积分",
"feature2Title": "随时随地,立即可用",
"feature3Title": "模型即刻可用",
"feature4Title": "顶级自定义节点包预装",
@@ -914,7 +913,8 @@
"extensionFileHint": "这可能是由于以下脚本",
"loadWorkflowTitle": "由于重新加载工作流数据出错,加载被中止",
"noStackTrace": "无可用堆栈跟踪",
"promptExecutionError": "提示执行失败"
"promptExecutionError": "提示执行失败",
"queueOpenWorkflowFailedTitle": "打开工作流失败"
},
"errorOverlay": {
"errorCount": "{count}个错误",
@@ -1042,6 +1042,7 @@
"comfy": "舒适",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg 徽标",
"comfyPackageOutdated": "已安装的 {name} 版本 {installedVersion} 低于所需版本 {requiredVersion}。",
"comingSoon": "即将推出",
"command": "指令",
"commandProhibited": "命令 {command} 被禁止。请联系管理员了解更多信息。",

View File

@@ -1,48 +1,51 @@
<template>
<Dialog
v-model:visible="isVisible"
modal
:closable="false"
:close-on-escape="false"
:dismissable-mask="true"
:pt="{
root: { class: 'video-help-dialog' },
header: { class: '!hidden' },
content: { class: '!p-0' },
mask: { class: '!bg-black/70' }
}"
:style="{ width: '90vw' }"
>
<div class="relative">
<Button
variant="textonly"
size="icon"
class="absolute top-4 right-6 z-10"
:aria-label="$t('g.close')"
@click="isVisible = false"
>
<i class="pi pi-times text-sm" />
</Button>
<video
autoplay
muted
loop
<Dialog v-model:open="isVisible">
<DialogPortal>
<DialogOverlay class="bg-black/70" />
<DialogContent
size="full"
class="w-[90vw] border-0 bg-transparent p-0 shadow-none"
:aria-label="ariaLabel"
class="w-full rounded-lg"
:src="videoUrl"
@escape-key-down="onEscapeKeyDown"
>
{{ $t('g.videoFailedToLoad') }}
</video>
</div>
<VisuallyHidden as-child>
<DialogTitle>{{ ariaLabel }}</DialogTitle>
</VisuallyHidden>
<div class="relative">
<Button
variant="textonly"
size="icon"
class="absolute top-4 right-6 z-10"
:aria-label="$t('g.close')"
@click="isVisible = false"
>
<i class="pi pi-times text-sm" />
</Button>
<video
autoplay
muted
loop
:aria-label="ariaLabel"
class="w-full rounded-lg"
:src="videoUrl"
>
{{ $t('g.videoFailedToLoad') }}
</video>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Dialog from 'primevue/dialog'
import { onWatcherCleanup, watch } from 'vue'
import { VisuallyHidden } from 'reka-ui'
import Button from '@/components/ui/button/Button.vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
const isVisible = defineModel<boolean>({ required: true })
@@ -51,27 +54,13 @@ const { videoUrl, ariaLabel = 'Help video' } = defineProps<{
ariaLabel?: string
}>()
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopImmediatePropagation()
event.stopPropagation()
event.preventDefault()
isVisible.value = false
}
// The dialog mounts inside other dialogs (e.g. UploadModelFooter inside an
// asset modal). Reka's Escape handling bubbles to the parent dialog and would
// close it as well. Stop propagation so only this dialog closes, and prevent
// Reka's default auto-dismiss so the close path stays solely under the model.
function onEscapeKeyDown(event: KeyboardEvent) {
event.preventDefault()
event.stopPropagation()
isVisible.value = false
}
// Add listener with capture phase to intercept before parent dialogs
// Only active when dialog is visible
watch(
isVisible,
(visible) => {
if (visible) {
const stop = useEventListener(document, 'keydown', handleEscapeKey, {
capture: true
})
onWatcherCleanup(stop)
}
},
{ immediate: true }
)
</script>

View File

@@ -196,7 +196,7 @@
rows="3"
:class="
cn(
'w-full resize-y rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground transition-colors outline-none focus:bg-component-node-widget-background',
'w-full resize-y scrollbar-gutter-stable rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground transition-colors outline-none focus:bg-component-node-widget-background',
isImmutable && 'cursor-not-allowed'
)
"

View File

@@ -21,7 +21,7 @@
loop
muted
playsinline
class="-ml-[20%] h-full min-w-5/4 object-cover p-0"
class="ml-[-20%] h-full min-w-5/4 object-cover p-0"
>
<source
src="/assets/images/cloud-subscription.webm"
@@ -42,7 +42,11 @@
</div>
<div class="mt-6 flex flex-col items-start gap-0 self-stretch">
<div v-for="n in 4" :key="n" class="flex items-center gap-2 py-2">
<div
v-for="n in [2, 3, 4]"
:key="n"
class="flex items-center gap-2 py-2"
>
<i class="pi pi-check text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ t(`cloudNotification.feature${n}Title`) }}

View File

@@ -0,0 +1,98 @@
import { render } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import SecretFormDialog from './SecretFormDialog.vue'
vi.mock('../composables/useSecretForm', () => ({
useSecretForm: () => ({
form: { provider: '', name: '', secretValue: '' },
errors: {},
loading: false,
apiError: '',
providerOptions: [],
handleSubmit: vi.fn()
})
}))
vi.mock('primevue/inputtext', () => ({
default: { name: 'InputText', template: '<input />' }
}))
vi.mock('primevue/password', () => ({
default: { name: 'Password', template: '<input type="password" />' }
}))
let capturedPointerDownOutside: ((event: Event) => void) | null = null
vi.mock('@/components/ui/button/Button.vue', () => ({
default: { name: 'Button', template: '<button><slot /></button>' }
}))
vi.mock('@/components/ui/select/Select.vue', () => ({
default: { name: 'Select', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectContent.vue', () => ({
default: { name: 'SelectContent', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
default: { name: 'SelectItem', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
vi.mock('@/components/ui/dialog/Dialog.vue', () => ({
default: { name: 'Dialog', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogPortal.vue', () => ({
default: { name: 'DialogPortal', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogOverlay.vue', () => ({
default: { name: 'DialogOverlay', template: '<div />' }
}))
vi.mock('@/components/ui/dialog/DialogContent.vue', () => ({
default: defineComponent({
name: 'DialogContent',
inheritAttrs: false,
setup(_, { attrs }) {
const onPointerDownOutside = (attrs as Record<string, unknown>)[
'onPointerDownOutside'
] as ((event: Event) => void) | undefined
capturedPointerDownOutside = onPointerDownOutside ?? null
},
template: '<div data-testid="dialog-content"><slot /></div>'
})
}))
vi.mock('@/components/ui/dialog/DialogHeader.vue', () => ({
default: { name: 'DialogHeader', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogTitle.vue', () => ({
default: { name: 'DialogTitle', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/dialog/DialogClose.vue', () => ({
default: { name: 'DialogClose', template: '<button />' }
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
describe('SecretFormDialog', () => {
beforeEach(() => {
capturedPointerDownOutside = null
})
it('prevents backdrop pointer-down-outside from closing the dialog', () => {
render(SecretFormDialog, {
global: { plugins: [i18n] },
props: { visible: true }
})
expect(capturedPointerDownOutside).not.toBeNull()
const event = new CustomEvent('pointerDownOutside', { cancelable: true })
capturedPointerDownOutside!(event)
expect(event.defaultPrevented).toBe(true)
})
})

View File

@@ -1,106 +1,130 @@
<template>
<Dialog
v-model:visible="visible"
:header="
mode === 'create' ? $t('secrets.addSecret') : $t('secrets.editSecret')
"
modal
class="w-full max-w-md"
>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-1">
<label for="secret-provider" class="text-sm font-medium">
{{ $t('secrets.provider') }}
</label>
<Select v-model="form.provider" :disabled="mode === 'edit'">
<SelectTrigger id="secret-provider" class="w-full" autofocus>
<SelectValue :placeholder="$t('g.none')" />
</SelectTrigger>
<SelectContent disable-portal>
<SelectItem
v-for="option in providerOptions"
:key="option.value || 'none'"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<small v-if="errors.provider" class="text-red-500">
{{ errors.provider }}
</small>
</div>
<div class="flex flex-col gap-1">
<label for="secret-name" class="text-sm font-medium">
{{ $t('secrets.name') }}
</label>
<InputText
id="secret-name"
v-model="form.name"
:placeholder="$t('secrets.namePlaceholder')"
:class="{ 'p-invalid': errors.name }"
/>
<small v-if="errors.name" class="text-red-500">{{ errors.name }}</small>
</div>
<div class="flex flex-col gap-1">
<label for="secret-value" class="text-sm font-medium">
{{ $t('secrets.secretValue') }}
</label>
<Password
id="secret-value"
v-model="form.secretValue"
:placeholder="
mode === 'edit'
? $t('secrets.secretValuePlaceholderEdit')
: $t('secrets.secretValuePlaceholder')
"
:feedback="false"
toggle-mask
fluid
:class="{ 'p-invalid': errors.secretValue }"
/>
<small v-if="errors.secretValue" class="text-red-500">
{{ errors.secretValue }}
</small>
<small v-else class="text-muted">
{{
mode === 'edit'
? $t('secrets.secretValueHintEdit')
: $t('secrets.secretValueHint')
}}
</small>
</div>
<span v-if="apiError" class="text-destructive text-sm">
{{ apiError }}
</span>
<div class="flex justify-end gap-2 pt-2">
<Button
variant="secondary"
type="button"
tabindex="0"
@click="visible = false"
<Dialog v-model:open="visible">
<DialogPortal>
<DialogOverlay />
<DialogContent
size="md"
:aria-labelledby="titleId"
@pointer-down-outside.prevent
>
<DialogHeader>
<DialogTitle :id="titleId">
{{
mode === 'create'
? $t('secrets.addSecret')
: $t('secrets.editSecret')
}}
</DialogTitle>
<DialogClose />
</DialogHeader>
<form
class="flex flex-col gap-4 px-4 py-2"
@submit.prevent="handleSubmit"
>
{{ $t('g.cancel') }}
</Button>
<Button type="submit" tabindex="0" :loading="loading">
{{ $t('g.save') }}
</Button>
</div>
</form>
<div class="flex flex-col gap-1">
<label for="secret-provider" class="text-sm font-medium">
{{ $t('secrets.provider') }}
</label>
<Select v-model="form.provider" :disabled="mode === 'edit'">
<SelectTrigger id="secret-provider" class="w-full" autofocus>
<SelectValue :placeholder="$t('g.none')" />
</SelectTrigger>
<SelectContent disable-portal>
<SelectItem
v-for="option in providerOptions"
:key="option.value || 'none'"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<small v-if="errors.provider" class="text-red-500">
{{ errors.provider }}
</small>
</div>
<div class="flex flex-col gap-1">
<label for="secret-name" class="text-sm font-medium">
{{ $t('secrets.name') }}
</label>
<InputText
id="secret-name"
v-model="form.name"
:placeholder="$t('secrets.namePlaceholder')"
:class="{ 'p-invalid': errors.name }"
/>
<small v-if="errors.name" class="text-red-500">
{{ errors.name }}
</small>
</div>
<div class="flex flex-col gap-1">
<label for="secret-value" class="text-sm font-medium">
{{ $t('secrets.secretValue') }}
</label>
<Password
id="secret-value"
v-model="form.secretValue"
:placeholder="
mode === 'edit'
? $t('secrets.secretValuePlaceholderEdit')
: $t('secrets.secretValuePlaceholder')
"
:feedback="false"
toggle-mask
fluid
:class="{ 'p-invalid': errors.secretValue }"
/>
<small v-if="errors.secretValue" class="text-red-500">
{{ errors.secretValue }}
</small>
<small v-else class="text-muted">
{{
mode === 'edit'
? $t('secrets.secretValueHintEdit')
: $t('secrets.secretValueHint')
}}
</small>
</div>
<span v-if="apiError" class="text-destructive text-sm">
{{ apiError }}
</span>
<div class="flex justify-end gap-2 py-2">
<Button
variant="secondary"
type="button"
tabindex="0"
@click="visible = false"
>
{{ $t('g.cancel') }}
</Button>
<Button type="submit" tabindex="0" :loading="loading">
{{ $t('g.save') }}
</Button>
</div>
</form>
</DialogContent>
</DialogPortal>
</Dialog>
</template>
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import { useId } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
@@ -126,6 +150,8 @@ const emit = defineEmits<{
saved: []
}>()
const titleId = useId()
const { form, errors, loading, apiError, providerOptions, handleSubmit } =
useSecretForm({
mode,

View File

@@ -803,16 +803,17 @@ export const CORE_SETTINGS: SettingParams[] = [
category: ['LiteGraph', 'Pointer', 'ClickBufferTime'],
name: 'Pointer click drift delay',
tooltip:
'After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.',
'After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.\n\nThe distance threshold (Pointer click drift) already disambiguates clicks from drags; this time threshold only matters when the pointer is held still then released. A long delay here forces every pointerdown to wait before drag begins, which feels laggy when click+dragging an unselected node. ~2 frames at 60fps is plenty.',
experimental: true,
type: 'slider',
attrs: {
min: 0,
max: 1000,
step: 25
step: 1
},
defaultValue: 150,
versionAdded: '1.4.3'
defaultValue: 32,
versionAdded: '1.4.3',
versionModified: '1.44.19'
},
{
id: 'Comfy.Pointer.DoubleClickTime',

View File

@@ -55,6 +55,10 @@ vi.mock('vue-i18n', () => ({
const p = params as Record<string, string>
return `Frontend version ${p.frontendVersion} may not be compatible with backend version ${p.backendVersion}.`
}
if (key === 'g.comfyPackageOutdated' && params) {
const p = params as Record<string, string>
return `Installed ${p.name} version ${p.installedVersion} is lower than the required version ${p.requiredVersion}.`
}
return key
}
}),
@@ -233,4 +237,37 @@ describe('useFrontendVersionMismatchWarning', () => {
// Should only have been called once
expect(addAlertSpy).toHaveBeenCalledTimes(1)
})
it('should emit a separate alert for each outdated comfy package', () => {
const toastStore = useToastStore()
const versionStore = useVersionCompatibilityStore()
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue(null)
vi.spyOn(versionStore, 'packageWarningMessages', 'get').mockReturnValue([
{
name: 'comfyui-workflow-templates',
installedVersion: '0.9.0',
requiredVersion: '0.9.5'
},
{
name: 'comfyui-embedded-docs',
installedVersion: '0.4.0',
requiredVersion: '0.5.0'
}
])
const { showWarning } = useFrontendVersionMismatchWarning()
showWarning()
expect(addAlertSpy).toHaveBeenCalledTimes(2)
expect(addAlertSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Installed comfyui-workflow-templates version 0.9.0'
)
)
expect(addAlertSpy).toHaveBeenCalledWith(
expect.stringContaining('Installed comfyui-embedded-docs version 0.4.0')
)
})
})

View File

@@ -1,5 +1,5 @@
import { whenever } from '@vueuse/core'
import { computed, nextTick, onMounted } from 'vue'
import { computed, nextTick, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToastStore } from './toastStore'
@@ -41,47 +41,66 @@ export function useFrontendVersionMismatchWarning(
// Track if we've already shown the warning
let hasShownWarning = false
const emitAlert = (detail: string) => {
const fullMessage = t('g.versionMismatchWarningMessage', {
warning: t('g.versionMismatchWarning'),
detail
})
toastStore.addAlert(fullMessage)
}
const showWarning = () => {
// Prevent showing the warning multiple times
if (hasShownWarning) return
const message = versionCompatibilityStore.warningMessage
if (!message) return
const packageMessages = versionCompatibilityStore.packageWarningMessages
if (!message && packageMessages.length === 0) return
const detailMessage = t('g.frontendOutdated', {
frontendVersion: message.frontendVersion,
requiredVersion: message.requiredVersion
})
if (message) {
emitAlert(
t('g.frontendOutdated', {
frontendVersion: message.frontendVersion,
requiredVersion: message.requiredVersion
})
)
}
const fullMessage = t('g.versionMismatchWarningMessage', {
warning: t('g.versionMismatchWarning'),
detail: detailMessage
})
for (const pkg of packageMessages) {
emitAlert(
t('g.comfyPackageOutdated', {
name: pkg.name,
installedVersion: pkg.installedVersion,
requiredVersion: pkg.requiredVersion
})
)
}
toastStore.addAlert(fullMessage)
hasShownWarning = true
// Automatically dismiss the warning so it won't show again for 7 days
versionCompatibilityStore.dismissWarning()
}
onMounted(async () => {
// Only set up the watcher if immediate is true
if (immediate) {
// Wait for next tick to ensure reactive updates from settings load have propagated
await nextTick()
let stopWatcher: (() => void) | null = null
whenever(
() => versionCompatibilityStore.shouldShowWarning,
() => {
showWarning()
},
{
immediate: true,
once: true
}
)
}
onMounted(async () => {
if (!immediate) return
// Wait for next tick to ensure reactive updates from settings load have propagated
await nextTick()
stopWatcher = whenever(
() => versionCompatibilityStore.shouldShowWarning,
() => {
showWarning()
},
{ immediate: true }
)
})
onUnmounted(() => {
stopWatcher?.()
stopWatcher = null
})
return {

View File

@@ -64,6 +64,7 @@ describe('useVersionCompatibilityStore', () => {
afterEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
describe('version compatibility detection', () => {
@@ -279,7 +280,8 @@ describe('useVersionCompatibilityStore', () => {
describe('dismissal persistence', () => {
it('should save dismissal to reactive storage with expiration', async () => {
const mockNow = 1000000
vi.spyOn(Date, 'now').mockReturnValue(mockNow)
vi.useFakeTimers()
vi.setSystemTime(mockNow)
mockSystemStatsStore.systemStats = {
system: {
@@ -381,4 +383,231 @@ describe('useVersionCompatibilityStore', () => {
expect(vi.mocked(until)).not.toHaveBeenCalled()
})
})
describe('comfy package version warnings', () => {
it('should detect outdated comfy packages and skip comfyui-frontend-package', async () => {
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.24.0',
required_frontend_version: '1.24.0',
comfy_package_versions: [
{
name: 'comfyui-frontend-package',
installed: '1.0.0',
required: '2.0.0'
},
{
name: 'comfyui-workflow-templates',
installed: '0.9.0',
required: '0.9.5'
},
{
name: 'comfyui-embedded-docs',
installed: '0.5.0',
required: '0.5.0'
}
]
}
}
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility()
expect(store.outdatedComfyPackages).toEqual([
{
name: 'comfyui-workflow-templates',
installed: '0.9.0',
required: '0.9.5'
}
])
expect(store.hasVersionMismatch).toBe(true)
expect(store.shouldShowWarning).toBe(true)
expect(store.packageWarningMessages).toEqual([
{
name: 'comfyui-workflow-templates',
installedVersion: '0.9.0',
requiredVersion: '0.9.5'
}
])
})
it('should ignore packages with missing or invalid versions', async () => {
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.24.0',
required_frontend_version: '1.24.0',
comfy_package_versions: [
{
name: 'comfy-kitchen',
installed: null,
required: '1.0.0'
},
{
name: 'comfy-aimdo',
installed: 'not-a-version',
required: '1.0.0'
}
]
}
}
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility()
expect(store.outdatedComfyPackages).toEqual([])
expect(store.hasVersionMismatch).toBe(false)
})
it('should detect outdated PEP 440 versions via coerce', async () => {
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.24.0',
required_frontend_version: '1.24.0',
comfy_package_versions: [
{
name: 'comfy-kitchen',
installed: '0.3.0.post1',
required: '0.4.0rc1'
},
{
name: 'comfy-aimdo',
installed: '0.4.0',
required: '0.4.0.post1'
}
]
}
}
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility()
expect(store.outdatedComfyPackages.map((p) => p.name)).toEqual([
'comfy-kitchen'
])
})
it('should include outdated packages in dismissal key', async () => {
const mockNow = 1000000
vi.useFakeTimers()
vi.setSystemTime(mockNow)
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.24.0',
required_frontend_version: '1.24.0',
comfy_package_versions: [
{
name: 'comfyui-workflow-templates',
installed: '0.9.0',
required: '0.9.5'
}
]
}
}
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility()
store.dismissWarning()
expect(mockDismissalStorage.value).toEqual({
'1.24.0-1.24.0-1.24.0-comfyui-workflow-templates@0.9.0->0.9.5':
mockNow + 7 * 24 * 60 * 60 * 1000
})
})
it('should produce the same dismissal key regardless of package order', async () => {
const mockNow = 1000000
vi.useFakeTimers()
vi.setSystemTime(mockNow)
const packageA = {
name: 'comfy-aimdo',
installed: '0.1.0',
required: '0.2.0'
}
const packageB = {
name: 'comfyui-workflow-templates',
installed: '0.9.0',
required: '0.9.5'
}
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.24.0',
required_frontend_version: '1.24.0',
comfy_package_versions: [packageA, packageB]
}
}
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility()
store.dismissWarning()
const firstKey = Object.keys(mockDismissalStorage.value)[0]
mockDismissalStorage.value = {}
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.24.0',
required_frontend_version: '1.24.0',
comfy_package_versions: [packageB, packageA]
}
}
await store.checkVersionCompatibility()
store.dismissWarning()
const secondKey = Object.keys(mockDismissalStorage.value)[0]
expect(firstKey).toBe(secondKey)
})
it('should prune expired dismissals when writing a new one', async () => {
const mockNow = 10_000_000
vi.useFakeTimers()
vi.setSystemTime(mockNow)
mockDismissalStorage.value = {
'expired-key': mockNow - 1,
'still-valid-key': mockNow + 5000
}
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.25.0',
required_frontend_version: '1.25.0'
}
}
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility()
store.dismissWarning()
expect(mockDismissalStorage.value).not.toHaveProperty('expired-key')
expect(mockDismissalStorage.value).toHaveProperty('still-valid-key')
expect(mockDismissalStorage.value).toHaveProperty('1.24.0-1.25.0-1.25.0')
})
it('should allow dismissal when only package warnings are present', async () => {
const mockNow = 1000000
vi.useFakeTimers()
vi.setSystemTime(mockNow)
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '',
required_frontend_version: '',
comfy_package_versions: [
{
name: 'comfyui-workflow-templates',
installed: '0.9.0',
required: '0.9.5'
}
]
}
}
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility()
expect(store.shouldShowWarning).toBe(true)
store.dismissWarning()
expect(Object.keys(mockDismissalStorage.value)).toHaveLength(1)
expect(store.shouldShowWarning).toBe(false)
})
})
})

View File

@@ -1,14 +1,35 @@
import { until, useStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { gt, valid } from 'semver'
import { coerce, gt } from 'semver'
import { computed } from 'vue'
import config from '@/config'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
interface OutdatedComfyPackage {
name: string
installed: string
required: string
}
const DISMISSAL_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
// Already covered by the dedicated frontend warning, which uses the
// running bundle's version rather than the installed pip version.
const FRONTEND_PACKAGE_NAME = 'comfyui-frontend-package'
// Backend reports PEP 440 versions (e.g. "0.3.0.post1", "1.0.0rc1");
// coerce strips the suffix so we can compare with semver. Note: this means
// "0.4.0" vs "0.4.0.post1" both coerce to "0.4.0" and compare equal — a
// post-release alone is not treated as outdated.
function isOutdated(installed: string, required: string): boolean {
const installedSemver = coerce(installed)
const requiredSemver = coerce(required)
if (!installedSemver || !requiredSemver) return false
return gt(requiredSemver, installedSemver)
}
export const useVersionCompatibilityStore = defineStore(
'versionCompatibility',
() => {
@@ -25,16 +46,8 @@ export const useVersionCompatibilityStore = defineStore(
)
const isFrontendOutdated = computed(() => {
if (
!frontendVersion.value ||
!requiredFrontendVersion.value ||
!valid(frontendVersion.value) ||
!valid(requiredFrontendVersion.value)
) {
return false
}
// Returns true if required version is greater than frontend version
return gt(requiredFrontendVersion.value, frontendVersion.value)
if (!frontendVersion.value || !requiredFrontendVersion.value) return false
return isOutdated(frontendVersion.value, requiredFrontendVersion.value)
})
const isFrontendNewer = computed(() => {
@@ -43,19 +56,48 @@ export const useVersionCompatibilityStore = defineStore(
return false
})
const outdatedComfyPackages = computed<OutdatedComfyPackage[]>(() => {
const packages =
systemStatsStore.systemStats?.system?.comfy_package_versions ?? []
const out: OutdatedComfyPackage[] = []
for (const pkg of packages) {
if (pkg.name === FRONTEND_PACKAGE_NAME) continue
if (!pkg.installed || !pkg.required) continue
if (!isOutdated(pkg.installed, pkg.required)) continue
out.push({
name: pkg.name,
installed: pkg.installed,
required: pkg.required
})
}
return out
})
const hasVersionMismatch = computed(() => {
return isFrontendOutdated.value
return isFrontendOutdated.value || outdatedComfyPackages.value.length > 0
})
const versionKey = computed(() => {
if (!frontendVersion.value) return null
if (
!frontendVersion.value ||
!backendVersion.value ||
!requiredFrontendVersion.value
!backendVersion.value &&
!requiredFrontendVersion.value &&
outdatedComfyPackages.value.length === 0
) {
return null
}
return `${frontendVersion.value}-${backendVersion.value}-${requiredFrontendVersion.value}`
const baseKey = `${frontendVersion.value}-${backendVersion.value}-${requiredFrontendVersion.value}`
if (outdatedComfyPackages.value.length === 0) return baseKey
const packageKey = [...outdatedComfyPackages.value]
.sort(
(a, b) =>
a.name.localeCompare(b.name) ||
a.installed.localeCompare(b.installed) ||
a.required.localeCompare(b.required)
)
.map((pkg) => `${pkg.name}@${pkg.installed}->${pkg.required}`)
.join(',')
return `${baseKey}-${packageKey}`
})
// Use reactive storage for dismissals - creates a reactive ref that syncs with localStorage
@@ -111,6 +153,14 @@ export const useVersionCompatibilityStore = defineStore(
return null
})
const packageWarningMessages = computed(() =>
outdatedComfyPackages.value.map((pkg) => ({
name: pkg.name,
installedVersion: pkg.installed,
requiredVersion: pkg.required
}))
)
async function checkVersionCompatibility() {
if (!systemStatsStore.systemStats) {
await until(systemStatsStore.isInitialized)
@@ -120,11 +170,13 @@ export const useVersionCompatibilityStore = defineStore(
function dismissWarning() {
if (!versionKey.value) return
const dismissUntil = Date.now() + DISMISSAL_DURATION_MS
dismissalStorage.value = {
...dismissalStorage.value,
[versionKey.value]: dismissUntil
const now = Date.now()
const pruned: Record<string, number> = {}
for (const [key, until] of Object.entries(dismissalStorage.value)) {
if (until > now) pruned[key] = until
}
pruned[versionKey.value] = now + DISMISSAL_DURATION_MS
dismissalStorage.value = pruned
}
async function initialize() {
@@ -138,6 +190,8 @@ export const useVersionCompatibilityStore = defineStore(
hasVersionMismatch,
shouldShowWarning,
warningMessage,
packageWarningMessages,
outdatedComfyPackages,
isFrontendOutdated,
isFrontendNewer,
checkVersionCompatibility,

View File

@@ -244,7 +244,7 @@ describe('useSharedWorkflowUrlLoader', () => {
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
await loadSharedWorkflowFromUrl()
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'])
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'], 'share-id-1')
})
it('does not call import when user chooses open-only', async () => {
@@ -348,7 +348,7 @@ describe('useSharedWorkflowUrlLoader', () => {
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
await loadSharedWorkflowFromUrl()
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'])
expect(mockImportPublishedAssets).toHaveBeenCalledWith(['a1'], 'share-id-1')
})
it('restores preserved share query before loading', async () => {

View File

@@ -161,7 +161,8 @@ export function useSharedWorkflowUrlLoader() {
if (result.action === 'copy-and-open' && nonOwnedAssets.length > 0) {
try {
await workflowShareService.importPublishedAssets(
nonOwnedAssets.map((a) => a.id)
nonOwnedAssets.map((a) => a.id),
payload.shareId
)
} catch (importError) {
console.error(

View File

@@ -334,16 +334,45 @@ describe(useWorkflowShareService, () => {
)
})
it('imports published assets via POST /assets/import', async () => {
it('imports published assets via POST /assets/import with share_id', async () => {
mockFetchApi.mockResolvedValue(mockJsonResponse({}, true, 200))
const service = useWorkflowShareService()
await service.importPublishedAssets(['pa-1', 'pa-2'])
await service.importPublishedAssets(['pa-1', 'pa-2'], 'share-id-1')
expect(mockFetchApi).toHaveBeenCalledWith('/assets/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ published_asset_ids: ['pa-1', 'pa-2'] })
body: JSON.stringify({
published_asset_ids: ['pa-1', 'pa-2'],
share_id: 'share-id-1'
})
})
})
it('omits share_id from the payload when not provided', async () => {
mockFetchApi.mockResolvedValue(mockJsonResponse({}, true, 200))
const service = useWorkflowShareService()
await service.importPublishedAssets(['pa-1'])
expect(mockFetchApi).toHaveBeenCalledWith('/assets/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ published_asset_ids: ['pa-1'] })
})
})
it('omits share_id from the payload when shareId is an empty string', async () => {
mockFetchApi.mockResolvedValue(mockJsonResponse({}, true, 200))
const service = useWorkflowShareService()
await service.importPublishedAssets(['pa-1'], '')
expect(mockFetchApi).toHaveBeenCalledWith('/assets/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ published_asset_ids: ['pa-1'] })
})
})
@@ -352,9 +381,9 @@ describe(useWorkflowShareService, () => {
const service = useWorkflowShareService()
await expect(service.importPublishedAssets(['bad-id'])).rejects.toThrow(
'Failed to import assets: 400'
)
await expect(
service.importPublishedAssets(['bad-id'], 'share-id-1')
).rejects.toThrow('Failed to import assets: 400')
})
it('throws when shared workflow payload is invalid', async () => {

View File

@@ -1,3 +1,5 @@
import type { ImportPublishedAssetsRequest } from '@comfyorg/ingest-types'
import type {
PublishPrefill,
SharedWorkflowPayload,
@@ -255,11 +257,19 @@ export function useWorkflowShareService() {
return workflow
}
async function importPublishedAssets(assetIds: string[]): Promise<void> {
async function importPublishedAssets(
assetIds: string[],
shareId?: string
): Promise<void> {
const body: ImportPublishedAssetsRequest = {
published_asset_ids: assetIds,
...(shareId ? { share_id: shareId } : {})
}
const response = await api.fetchApi('/assets/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ published_asset_ids: assetIds })
body: JSON.stringify(body)
})
if (!response.ok) {

View File

@@ -46,10 +46,17 @@ function createMockPointerEvent(
return mockEvent as PointerEvent
}
function createMockWheelEvent(ctrlKey = false, metaKey = false): WheelEvent {
function createMockWheelEvent(
ctrlKey = false,
metaKey = false,
deltaX = 0,
deltaY = 0
): WheelEvent {
const mockEvent: Partial<WheelEvent> = {
ctrlKey,
metaKey,
deltaX,
deltaY,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
}
@@ -222,5 +229,107 @@ describe('useCanvasInteractions', () => {
document.body.removeChild(captureElement)
})
/** Regression: trackpad pinch-zoom inside a focused textarea must not
* fall through to browser page zoom in non-standard navigation modes. */
it.for(['legacy', 'custom'])(
'should forward ctrl+wheel to canvas when capture element IS focused in %s mode',
(mode) => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue(mode)
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')
const textarea = document.createElement('textarea')
captureElement.appendChild(textarea)
document.body.appendChild(captureElement)
textarea.focus()
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent(true)
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
document.body.removeChild(captureElement)
}
)
it('should forward meta+wheel to canvas when capture element IS focused', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')
const textarea = document.createElement('textarea')
captureElement.appendChild(textarea)
document.body.appendChild(captureElement)
textarea.focus()
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent(false, true)
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
document.body.removeChild(captureElement)
})
/** Regression: trackpad two-finger horizontal swipes inside a focused
* textarea must not fall through to browser back/forward navigation. */
it.for(['standard', 'legacy', 'custom'])(
'should forward horizontal-dominant wheel to canvas when capture element IS focused in %s mode',
(mode) => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue(mode)
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')
const textarea = document.createElement('textarea')
captureElement.appendChild(textarea)
document.body.appendChild(captureElement)
textarea.focus()
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent(false, false, 30, 5)
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
document.body.removeChild(captureElement)
}
)
it('should NOT forward vertical-dominant wheel when capture element IS focused', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')
const textarea = document.createElement('textarea')
captureElement.appendChild(textarea)
document.body.appendChild(captureElement)
textarea.focus()
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent(false, false, 0, 30)
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
document.body.removeChild(captureElement)
})
})
})

View File

@@ -1,6 +1,7 @@
import { computed } from 'vue'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import { isCanvasGestureWheel } from '@/base/wheelGestures'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
@@ -41,30 +42,34 @@ export function useCanvasInteractions() {
return !!(captureElement && active && captureElement.contains(active))
}
/**
* Forward to canvas when the event is not consumed by a focused widget,
* or when it is a canvas gesture (which must override widget consumption
* to prevent destructive browser defaults).
*/
const shouldForwardWheelEvent = (event: WheelEvent): boolean =>
!wheelCapturedByFocusedElement(event) ||
(isStandardNavMode.value && (event.ctrlKey || event.metaKey))
!wheelCapturedByFocusedElement(event) || isCanvasGestureWheel(event)
/**
* Handles wheel events from UI components that should be forwarded to canvas
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
* when appropriate (e.g., Ctrl+wheel for zoom, two-finger pan in standard
* mode; all wheel events in legacy mode).
*/
const handleWheel = (event: WheelEvent) => {
if (!shouldForwardWheelEvent(event)) return
// In standard mode, Ctrl+wheel should go to canvas for zoom
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
forwardEventToCanvas(event)
// In standard mode, only canvas gestures (zoom/pan) are forwarded;
// vertical wheel falls through so the document/widget scrolls normally.
// The re-check is intentional and NOT redundant with shouldForwardWheelEvent:
// that function also returns true for unfocused vertical wheel (its
// `!wheelCapturedByFocusedElement` branch), which here must stay native.
if (isStandardNavMode.value) {
if (isCanvasGestureWheel(event)) forwardEventToCanvas(event)
return
}
// In legacy mode, all wheel events go to canvas for zoom
if (!isStandardNavMode.value) {
forwardEventToCanvas(event)
return
}
// Otherwise, let the component handle it normally
// In legacy mode, all forwardable wheel events go to canvas for zoom/pan.
forwardEventToCanvas(event)
}
/**

View File

@@ -58,7 +58,7 @@
cn(
'pointer-events-none absolute z-0 border-3 outline-none',
selectionShapeClass,
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
hasAnyError ? 'inset-[-7px]' : 'inset-[-3px]',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing'

View File

@@ -94,14 +94,59 @@ describe('FormDropdownMenu', () => {
})
it('has data-capture-wheel="true" on the root element', () => {
const { container } = render(FormDropdownMenu, {
render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
expect(
// eslint-disable-next-line testing-library/no-node-access
container.firstElementChild!.getAttribute('data-capture-wheel')
screen
.getByTestId('form-dropdown-menu')
.getAttribute('data-capture-wheel')
).toBe('true')
})
/** Regression: PrimeVue Popover teleports the menu to document.body, so
* trackpad pinch-zoom and horizontal swipes must be guarded on the menu
* itself rather than relying on the LGraphNode wheel handler. */
it.for([
{ name: 'pinch-zoom', overrides: { ctrlKey: true, deltaY: -10 } },
{ name: 'horizontal swipe', overrides: { deltaX: 30, deltaY: 5 } }
])('suppresses browser default for $name', ({ overrides }) => {
render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
const root = screen.getByTestId('form-dropdown-menu')
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true
})
Object.entries(overrides).forEach(([key, value]) => {
Object.defineProperty(event, key, { value })
})
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
/** Vertical scrolling must remain native so the dropdown's own scroll
* container can scroll its content. */
it('does not suppress vertical scroll', () => {
render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
const root = screen.getByTestId('form-dropdown-menu')
const event = new WheelEvent('wheel', {
deltaY: 30,
bubbles: true,
cancelable: true
})
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
})
})

View File

@@ -3,6 +3,7 @@ import type { CSSProperties } from 'vue'
import { computed } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { isCanvasGestureWheel } from '@/base/wheelGestures'
import type {
FilterOption,
@@ -93,12 +94,25 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
key: String(item.id)
}))
)
/**
* The dropdown content is teleported to `document.body` by PrimeVue Popover,
* detaching it from the LGraphNode subtree where the canvas wheel guard lives.
* Suppress only the destructive browser defaults (page zoom on pinch and
* back/forward on horizontal swipe); regular vertical scrolling still
* scrolls the dropdown's own content.
*/
const onWheel = (event: WheelEvent) => {
if (isCanvasGestureWheel(event)) event.preventDefault()
}
</script>
<template>
<div
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
data-capture-wheel="true"
data-testid="form-dropdown-menu"
@wheel="onWheel"
>
<FormDropdownMenuFilter
v-if="filterOptions.length > 0"

View File

@@ -11,7 +11,7 @@ import type {
} from '@/platform/assets/types/filterTypes'
import { cn } from '@comfyorg/tailwind-utils'
import FormSearchInput from '../FormSearchInput.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
@@ -99,7 +99,7 @@ function toggleBaseModelSelection(item: FilterOption) {
<template>
<div class="text-secondary flex gap-2 px-4">
<FormSearchInput
<AsyncSearchInput
v-model="searchQuery"
autofocus
:class="

View File

@@ -238,6 +238,12 @@ const zDeviceStats = z.object({
torch_vram_free: z.number()
})
const zComfyPackageVersion = z.object({
name: z.string(),
installed: z.string().nullable(),
required: z.string().nullable()
})
const zSystemStats = z.object({
system: z.object({
os: z.string(),
@@ -254,7 +260,8 @@ const zSystemStats = z.object({
comfyui_frontend_version: z.string().optional(),
workflow_templates_version: z.string().optional(),
installed_templates_version: z.string().optional(),
required_templates_version: z.string().optional()
required_templates_version: z.string().optional(),
comfy_package_versions: z.array(zComfyPackageVersion).optional()
}),
devices: z.array(zDeviceStats)
})

View File

@@ -1,8 +1,7 @@
/**
* Phase 1 dialog migration regression net: when `dialogService.prompt()`,
* `dialogService.confirm()`, or `dialogService.showBillingComingSoonDialog()`
* is invoked, the dialog stack item must carry `renderer: 'reka'`. Catches
* accidental reverts of the Reka renderer flip.
* Dialog migration regression net: when callers in `dialogService` open a
* Reka-migrated dialog, the dialog stack item must carry `renderer: 'reka'`.
* Catches accidental reverts of the Reka renderer flip.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -34,7 +33,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
import { useDialogService } from '@/services/dialogService'
describe('dialogService Reka renderer opt-in (Phase 1)', () => {
describe('dialogService Reka renderer opt-in', () => {
beforeEach(() => {
showDialog.mockReset()
})
@@ -60,4 +59,24 @@ describe('dialogService Reka renderer opt-in (Phase 1)', () => {
expect(args.dialogComponentProps.size).toBe('sm')
expect(args.dialogComponentProps.contentClass).toBe('max-w-[360px]')
})
it("showExecutionErrorDialog() sets renderer 'reka' and size 'lg'", () => {
useDialogService().showExecutionErrorDialog({
exception_type: 'RuntimeError',
exception_message: 'boom',
node_id: 1,
node_type: 'KSampler',
traceback: ['line 1', 'line 2']
})
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('lg')
})
it("showErrorDialog() sets renderer 'reka' and size 'lg'", () => {
useDialogService().showErrorDialog(new Error('boom'))
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('lg')
})
})

View File

@@ -99,6 +99,8 @@ export const useDialogService = () => {
component: ErrorDialogContent,
props,
dialogComponentProps: {
renderer: 'reka',
size: 'lg',
onClose: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_closed'
@@ -163,6 +165,8 @@ export const useDialogService = () => {
component: ErrorDialogContent,
props,
dialogComponentProps: {
renderer: 'reka',
size: 'lg',
onClose: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_closed'

View File

@@ -5,11 +5,16 @@ import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
InputWidgetConfig,
LinearInput,
LoadedComfyWorkflow
} from '@/platform/workflow/management/stores/comfyWorkflow'
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import type { ChangeTracker } from '@/scripts/changeTracker'
import { ChangeTracker } from '@/scripts/changeTracker'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
const mockEmptyWorkflowDialog = vi.hoisted(() => {
@@ -100,6 +105,29 @@ function createBuilderWorkflowWithOutputs(
return workflow
}
function createWorkflowWithLinearData(
activeMode: string,
inputs: LinearInput[],
outputs: NodeId[]
): LoadedComfyWorkflow {
const workflow = createBuilderWorkflow(activeMode)
workflow.changeTracker = createMockChangeTracker(
fromPartial<Partial<ChangeTracker>>({
activeState: {
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: [],
groups: [],
config: {},
version: 0.4,
extra: { linearData: fromAny({ inputs, outputs }) }
}
})
)
return workflow
}
describe('appModeStore', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let store: ReturnType<typeof useAppModeStore>
@@ -107,6 +135,7 @@ describe('appModeStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.mocked(app.rootGraph).extra = {}
ChangeTracker.isLoadingGraph = false
mockResolveNode.mockReturnValue(undefined)
mockSettings.reset()
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
@@ -163,6 +192,28 @@ describe('appModeStore', () => {
)
expect(workflowStore.activeWorkflow!.activeMode).toBe('graph')
})
it('prunes selections from workflow state on entry', () => {
const node1 = { id: 1 }
mockResolveNode.mockImplementation((id) =>
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
)
workflowStore.activeWorkflow = createWorkflowWithLinearData(
'graph',
[
[1, 'seed'],
[99, 'steps']
],
[1, 99]
)
store.selectedInputs = [[42, 'prompt']]
store.selectedOutputs = [42]
store.enterBuilder()
expect(store.selectedInputs).toEqual([[1, 'seed']])
expect(store.selectedOutputs).toEqual([1])
})
})
describe('empty workflow dialog callbacks', () => {
@@ -202,33 +253,36 @@ describe('appModeStore', () => {
})
})
describe('exitBuilder', () => {
it('prunes selections from workflow state on exit', () => {
const node1 = { id: 1 }
mockResolveNode.mockImplementation((id) =>
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
)
workflowStore.activeWorkflow = createWorkflowWithLinearData(
'builder:inputs',
[
[1, 'seed'],
[99, 'steps']
],
[1, 99]
)
store.selectedInputs = [[42, 'prompt']]
store.selectedOutputs = [42]
store.exitBuilder()
expect(store.selectedInputs).toEqual([[1, 'seed']])
expect(store.selectedOutputs).toEqual([1])
expect(workflowStore.activeWorkflow!.activeMode).toBe('graph')
})
})
describe('loadSelections pruning', () => {
function mockNode(id: number) {
return { id }
}
function workflowWithLinearData(
inputs: [number, string][],
outputs: number[]
) {
const workflow = createBuilderWorkflow('app')
workflow.changeTracker = createMockChangeTracker(
fromPartial<Partial<ChangeTracker>>({
activeState: {
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: [],
groups: [],
config: {},
version: 0.4,
extra: { linearData: { inputs, outputs } }
}
})
)
return workflow
}
it('removes inputs referencing deleted nodes on load', async () => {
const node1 = mockNode(1)
mockResolveNode.mockImplementation((id) =>
@@ -294,7 +348,11 @@ describe('appModeStore', () => {
// Initially nodes are not resolvable — pruning removes them
mockResolveNode.mockReturnValue(undefined)
const inputs: [number, string][] = [[1, 'seed']]
workflowStore.activeWorkflow = workflowWithLinearData(inputs, [1])
workflowStore.activeWorkflow = createWorkflowWithLinearData(
'app',
inputs,
[1]
)
store.loadSelections({ inputs })
await nextTick()
@@ -324,6 +382,141 @@ describe('appModeStore', () => {
})
})
describe('loadSelections edge cases', () => {
it('clears existing selections on undefined or empty data', () => {
store.selectedInputs = [[1, 'seed']]
store.selectedOutputs = [1]
store.loadSelections(undefined)
expect(store.selectedInputs).toEqual([])
expect(store.selectedOutputs).toEqual([])
store.selectedInputs = [[1, 'seed']]
store.selectedOutputs = [1]
store.loadSelections({})
expect(store.selectedInputs).toEqual([])
expect(store.selectedOutputs).toEqual([])
})
})
describe('pruneLinearData', () => {
it('returns empty selections for undefined data', () => {
expect(store.pruneLinearData(undefined)).toEqual({
inputs: [],
outputs: []
})
})
it('does not prune when rootGraph is empty', () => {
const originalRootGraph = app.rootGraph
Object.defineProperty(app, 'rootGraph', { value: null, writable: true })
try {
expect(
store.pruneLinearData({
inputs: [[1, 'seed']],
outputs: [1]
})
).toEqual({
inputs: [[1, 'seed']],
outputs: [1]
})
} finally {
Object.defineProperty(app, 'rootGraph', {
value: originalRootGraph,
writable: true
})
}
})
})
describe('pruneLinearData during graph loading', () => {
it('preserves all entries when ChangeTracker.isLoadingGraph is true', () => {
ChangeTracker.isLoadingGraph = true
store.loadSelections({
inputs: [
[1, 'seed'],
[999, 'steps']
],
outputs: [1, 999]
})
expect(store.selectedInputs).toEqual([
[1, 'seed'],
[999, 'steps']
])
expect(store.selectedOutputs).toEqual([1, 999])
})
it('prunes entries for deleted nodes when not loading', () => {
const node1 = { id: 1 }
mockResolveNode.mockImplementation((id) =>
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
)
store.loadSelections({
inputs: [
[1, 'seed'],
[999, 'steps']
],
outputs: [1, 999]
})
expect(store.selectedInputs).toEqual([[1, 'seed']])
expect(store.selectedOutputs).toEqual([1])
})
})
describe('resetSelectedToWorkflow fallback', () => {
it('falls back to initialState when activeState has no linearData', () => {
const node1 = { id: 1 }
mockResolveNode.mockImplementation((id) =>
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
)
const workflow = createBuilderWorkflow('app')
workflow.changeTracker.activeState.extra = {}
workflow.changeTracker.initialState = fromAny({
...workflow.changeTracker.activeState,
extra: {
linearData: { inputs: [[1, 'seed']], outputs: [1] }
}
})
workflowStore.activeWorkflow = workflow
store.resetSelectedToWorkflow()
expect(store.selectedInputs).toEqual([[1, 'seed']])
expect(store.selectedOutputs).toEqual([1])
})
it('prefers activeState linearData when available', () => {
const node1 = { id: 1 }
mockResolveNode.mockImplementation((id) =>
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
)
const workflow = createBuilderWorkflow('app')
workflow.changeTracker.activeState.extra = {
linearData: { inputs: [[1, 'steps']], outputs: [1] }
}
workflow.changeTracker.initialState = fromAny({
...workflow.changeTracker.activeState,
extra: {
linearData: { inputs: [[1, 'seed']], outputs: [1] }
}
})
workflowStore.activeWorkflow = workflow
store.resetSelectedToWorkflow()
expect(store.selectedInputs).toEqual([[1, 'steps']])
expect(store.selectedOutputs).toEqual([1])
})
})
describe('linearData sync watcher', () => {
it('writes linearData to rootGraph.extra when in builder mode', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
@@ -353,15 +546,22 @@ describe('appModeStore', () => {
await nextTick()
const originalRootGraph = app.rootGraph
const dataBefore = JSON.parse(
JSON.stringify(originalRootGraph.extra.linearData)
)
Object.defineProperty(app, 'rootGraph', { value: null, writable: true })
store.selectedOutputs.push(1)
await nextTick()
try {
store.selectedOutputs.push(1)
await nextTick()
} finally {
Object.defineProperty(app, 'rootGraph', {
value: originalRootGraph,
writable: true
})
}
Object.defineProperty(app, 'rootGraph', {
value: originalRootGraph,
writable: true
})
expect(originalRootGraph.extra.linearData).toEqual(dataBefore)
})
it('calls captureCanvasState when input is selected', async () => {
@@ -428,6 +628,18 @@ describe('appModeStore', () => {
expect(store.selectedInputs[0][2]).toEqual({ height: 200 })
})
it('merges existing config with new values', () => {
const existingConfig: InputWidgetConfig & { width: number } = {
height: 120,
width: 240
}
store.selectedInputs.push([1, 'prompt', existingConfig])
store.updateInputConfig(1 as NodeId, 'prompt', { height: 300 })
expect(store.selectedInputs[0][2]).toEqual({ height: 300, width: 240 })
})
it('triggers linearData sync watcher', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.selectedInputs.push([42, 'prompt'])
@@ -443,6 +655,17 @@ describe('appModeStore', () => {
})
})
describe('removeSelectedInput', () => {
it('removes the matching input entry only', () => {
store.selectedInputs.push([1, 'prompt'])
store.selectedInputs.push([2, 'steps'])
store.removeSelectedInput({ name: 'steps' } as IBaseWidget, { id: 2 })
expect(store.selectedInputs).toEqual([[1, 'prompt']])
})
})
describe('autoEnableVueNodes', () => {
it('enables Vue nodes when entering select mode with them disabled', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false

View File

@@ -49,14 +49,13 @@ export const useAppModeStore = defineStore('appMode', () => {
function pruneLinearData(data: Partial<LinearData> | undefined): LinearData {
const rawInputs = data?.inputs ?? []
const rawOutputs = data?.outputs ?? []
if (!app.rootGraph || ChangeTracker.isLoadingGraph) {
return { inputs: rawInputs, outputs: rawOutputs }
}
return {
inputs: app.rootGraph
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
: rawInputs,
outputs: app.rootGraph
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
: rawOutputs
inputs: rawInputs.filter(([nodeId]) => resolveNode(nodeId)),
outputs: rawOutputs.filter((nodeId) => resolveNode(nodeId))
}
}
@@ -70,7 +69,10 @@ export const useAppModeStore = defineStore('appMode', () => {
const { activeWorkflow } = workflowStore
if (!activeWorkflow) return
loadSelections(activeWorkflow.changeTracker?.activeState?.extra?.linearData)
const source =
activeWorkflow.changeTracker?.activeState?.extra?.linearData ??
activeWorkflow.initialState?.extra?.linearData
loadSelections(source)
}
useEventListener(

View File

@@ -212,8 +212,24 @@ describe('formatShortMonthDay', () => {
describe('formatClockTime', () => {
it('formats time with hours, minutes, and seconds', () => {
const ts = new Date(2024, 5, 15, 14, 5, 6).getTime()
const result = formatClockTime(ts, 'en-GB')
const result = formatClockTime(ts, 'en-GB', 'en-GB')
// en-GB uses 24-hour format
expect(result).toBe('14:05:06')
})
it('uses app locale with browser/system hour-cycle preference', () => {
const ts = new Date(2024, 5, 15, 14, 5, 6).getTime()
const hourCycle = new Intl.DateTimeFormat(undefined, {
hour: 'numeric'
}).resolvedOptions().hourCycle
const options = {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hourCycle
} satisfies Intl.DateTimeFormatOptions
const expected = new Intl.DateTimeFormat('es', options).format(ts)
expect(formatClockTime(ts, 'es')).toBe(expected)
})
})

View File

@@ -84,17 +84,30 @@ export const formatShortMonthDay = (ts: number, locale: string): string => {
}
/**
* Localized clock time, e.g. "10:05:06" with locale defaults for 12/24 hour.
* Localized clock time, e.g. "10:05:06" with the app locale for language and
* the browser/system locale preference for 12/24-hour formatting.
*
* @param ts Unix timestamp in milliseconds
* @param locale BCP-47 locale string
* @param clockPreferenceLocale Optional locale source for hour-cycle preference
* @returns Localized time string
*/
export const formatClockTime = (ts: number, locale: string): string => {
export const formatClockTime = (
ts: number,
locale: string,
clockPreferenceLocale?: string
): string => {
const d = new Date(ts)
return new Intl.DateTimeFormat(locale, {
const hourCycle = new Intl.DateTimeFormat(clockPreferenceLocale, {
hour: 'numeric'
}).resolvedOptions().hourCycle
const options: Intl.DateTimeFormatOptions = {
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(d)
}
if (hourCycle) {
options.hourCycle = hourCycle
}
return new Intl.DateTimeFormat(locale, options).format(d)
}