Compare commits

...

23 Commits

Author SHA1 Message Date
Comfy Org PR Bot
06e4e98b52 [backport core/1.46] Simplify missing model error presentation (#12852)
Backport of #12793 to `core/1.46`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-06-15 22:16:54 +09:00
Alexander Brown
2b0bcda41f chore(deps): update oxc toolchain to 1.69 (fixes Windows oxlint OOM) (#12838)
## Summary

Update the oxc toolchain to 1.69 to fix the Windows fixed-size allocator
OOM that made oxlint commit ~130 GB per process and intermittently panic
with "Insufficient memory to create fixed-size allocator pool".

## Changes

- **What**: Bump catalog deps — `oxlint` 1.59.0 → 1.69.0,
`oxlint-tsgolint` 0.20.0 → 0.23.0, `oxfmt` 0.44.0 → 0.54.0,
`eslint-plugin-oxlint` 1.59.0 → 1.69.0. oxlint 1.69 includes the Windows
`VirtualAlloc` reserve/commit fix (oxc-project/oxc#22124, shipped in oxc
0.131.0).
- **What**: Add `--no-error-on-unmatched-pattern` to the lint-staged
oxlint command. oxlint 1.69 now exits non-zero when no lintable files
match (e.g. a yaml/lock-only commit), which broke the pre-commit hook;
this mirrors the existing stylelint `--allow-empty-input`.
- **Dependencies**: oxc toolchain only; no runtime/app dependencies
changed.

## Review Focus

- `pnpm format:check` reports no reformatting (oxfmt 0.54 produces
identical output on all 3655 files).
- `pnpm lint` passes (0 errors). Note: oxlint 1.69 adds
`typescript/no-useless-default-assignment`, which surfaces ~840
non-blocking warnings repo-wide (not auto-fixable). Left enabled here;
happy to disable in `.oxlintrc.json` in a follow-up if preferred.
- Verified locally that the new oxlint LSP commits ~0 GB instead of ~130
GB.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-14 19:33:36 +00:00
Terry Jia
b9112f9bd7 feat(load3d) FE-999: export point cloud / splat files as-is (#12810)
## Summary
PLY, SPZ, SPLAT and KSPLAT have no THREE.js exporter, so the 3D node now
offers them as export options only when the loaded file already uses
that format, and exports the original file unchanged instead of
attempting a conversion. Mesh files keep the convertible GLB/OBJ/STL/FBX
set.

Splat models (spz/splat/ksplat and gaussian-splat ply) previously had
exportable: false, which hid the export UI entirely; enable it so these
formats can be downloaded as-is. Also fixes STL export dropping its
originalURL fast-path arg.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/8417c697-eb25-4cfb-af44-3855b814fa5d
2026-06-14 04:48:06 -04:00
Dante
b5b124fa9e refactor: extract Cloud-JWT mint + dormant unified refresh lifecycle (FE-950) - step 2 (#12704)
## Summary

Behind `unified_cloud_auth` (default OFF), extract the `/auth/token`
Cloud-JWT mint out of `switchWorkspace` and add a parallel mint/refresh
lifecycle that writes a dedicated, **dormant** `unifiedToken` slot —
read by no consumer until PR3 — so this PR alone cannot change which
token any request carries.

Stacked on #12702 (PR1, flag registration). Base will auto-retarget to
`main` once #12702 merges.

## Changes

- **What**:
- Extract `requestToken(workspaceId?)` (network + parse only) from
`switchWorkspace`. An id-less `{}` body mints the personal-workspace
token; a concrete `workspace_id` keeps the legacy body byte-identical.
The legacy `switchWorkspace`/`refreshToken` path is behaviorally
unchanged (still owns its own state writes, `isLoading`, request-id, and
`scheduleTokenRefresh`).
- `mintAtLogin()` mints the personal default into `unifiedToken`, gated
on `unifiedCloudAuthEnabled` **only** (decoupled from
`teamWorkspacesEnabled`). Silent — no `isLoading` flash.
- `refreshUnified()` — parallel buffer-based refresh off the parsed
`expires_at` (reuses `TOKEN_REFRESH_BUFFER_MS`, no hardcoded TTL). The
legacy `refreshToken` is left untouched so its team-workspaces gate is
preserved.
- `remintUnifiedOnce()` — guarded single re-mint primitive for PR3's 401
path; a shared `unifiedRefreshRequestId` stale-guard prevents concurrent
mints clobbering each other (last mint to start wins).
- `clearWorkspaceContext()` tears down the unified slot + timer on
logout.
- `authStore`: mint at login for cloud users; add
`notifyTokenRefreshed()` and gate the Firebase `onIdTokenChanged`
rotation bump off under the flag (the unified lifecycle becomes the sole
rotation driver — no double rotation).
- **Breaking**: None. Every flag-OFF path is byte-for-byte the current
cascade.

## Review Focus

- **Dormancy**: `unifiedToken` is written only by the flag-gated mint
lifecycle and read by no consumer in this PR (the
`getAuthHeader`/`getAuthToken` flip is PR3). Tests assert
`workspaceToken` is never touched by `mintAtLogin`.
- **Legacy parity**: the `requestToken` extraction is a pure refactor —
the full existing `switchWorkspace`/`refreshToken` suite is the
regression net and stays green; flag-OFF + team-workspaces-OFF fires
zero network from any timer.
- **Concurrency**: `unifiedRefreshRequestId` stale-guard covers a
401-driven re-mint racing the scheduled refresh.
- **Rotation**: `notifyTokenRefreshed` fires only on a refresh re-mint
(never the initial login mint or a switch), and the legacy L129 bump is
gated off under the flag.

Tests cover legacy parity, the dormant slot, buffer-based refresh
(re-mint fires off parsed expiry), rotation-trigger semantics, the
single re-mint primitive (no loop on persistent 401), the concurrency
stale-guard, logout teardown, and full flag-OFF dormancy.

Part of FE-950 (single Cloud-JWT provider at login, Phase 1). Follow-up:
PR3 flips consumers + adds the 401-retry interceptor.
2026-06-13 03:30:48 +00:00
AustinMroz
e138d17459 Fix themeing of nodes (#12712)
In moving styling to `documentElement`, #9516 introduced a regression
preventing themes from styling nodes.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/0a27ea1b-ff15-4524-b491-a0de9fee6ed2"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/36aee446-6b7d-4b05-96de-39b19989af0d"
/>|

Note: Some elements (like the app mode toggle) are themed using
different variables (`--secondary-background` instead of
`--component-node-background`). I think the sanest approach is to define
`--secondary-background` to be `--component-node-background` by default,
but that feels better handled as a followup PR

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-13 02:40:58 +00:00
Comfy Org PR Bot
f212c7d409 1.46.14 (#12807)
Patch version increment to 1.46.14

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-13 02:10:41 +00:00
Benjamin Lu
1d5801d6ef feat: track funnel telemetry attributes (#12778)
## Summary

Adds the frontend telemetry attribution needed to analyze settings,
app-mode, and sharing funnel usage for MAR-321: re-enables three funnel
events that were disabled by default, attaches
app-mode/view-mode/dock-state context to UI click, run, and share
events, and adds a per-session `shell_layout` snapshot plus
right-side-panel toggle tracking.

## Changes

- **What**:
- Removes `setting_changed`, `template_filter_changed`, and
`ui_button_click` from the code-default `DEFAULT_DISABLED_EVENTS` lists
in the Mixpanel and PostHog providers, so these events now send by
default (see deployment note).
- `ui_button_click` now requires an `element_group`; all call sites are
tagged (`sidebar`, `queue`, `actionbar`, `breadcrumb`, `error_dialog`,
`errors_panel`, `graph_menu`, `graph_node`, `selection_toolbox`,
`node_library`, `workflow_actions`, `cloud_notification`, `app_mode`,
`top_menu`, `right_side_panel`) and the GTM provider forwards the field.
- Run events (`run_button_clicked`, GTM `run_workflow`) now carry
required `view_mode`/`is_app_mode` plus a new `dock_state`
(`docked`/`floating`), read from the `Comfy.MenuPosition.Docked`
localStorage key by a new `getActionbarDockState()` util.
- Share funnel events (`share_flow`, `share_link_opened`,
`shared_workflow_run`) now carry required `view_mode`/`is_app_mode`. A
new `useShareFlowContext()` composable dedupes the source/view-mode
context across the share dialog, URL copy field, and `useShareDialog`.
GTM `share_flow` forwards the new fields and still omits `share_id`.
- `shared_workflow_run` attribution is snapshotted onto the queued job
at queue time, so switching app/graph mode while a job runs no longer
misattributes the completion event (falls back to live values when no
snapshot exists).
- New `shell_layout` event fired once per session at graph-ready (cloud
only): `view_mode`, `is_app_mode`, `dock_state`, `actionbar_position`,
`active_sidebar_tab`, `right_side_panel_open`, `bottom_panel_open`,
`open_workflow_tabs`. Forwarded by Mixpanel and PostHog; not sent to
GTM.
- The right side panel open button (top menu) and close button now fire
`ui_button_click` (`right_side_panel_opened`/`right_side_panel_closed`),
covering the panel open-rate gap.
- **Dependencies**: None.

## Review Focus

- `view_mode`/`is_app_mode` changed from optional to required (typed as
`AppMode`) on run/share metadata — check no call sites were missed.
- The queue-time snapshot in `executionStore` (`queuedJob.viewMode ??
mode.value`) and its regression test.
- Share IDs remain limited to the providers/events that already carry
share attribution (GTM still strips `share_id`).
- `shell_layout` cadence is once per session (graph-ready idle
callback), matching the gap analysis's "session snapshot" wording.

Linear: MAR-321

Validation:
- `pnpm test:unit
src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts
src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts
src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts
src/platform/telemetry/utils/getShellLayoutSnapshot.test.ts
src/platform/workflow/sharing/components/ShareWorkflowDialogContent.test.ts
src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.test.ts
src/stores/executionStore.test.ts src/components/TopMenuSection.test.ts
src/components/graph/selectionToolbox/InfoButton.test.ts
src/components/rightSidePanel/errors/useErrorActions.test.ts
src/views/GraphView.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm knip`
- `git diff --check`

## Deployment note

`telemetry_disabled_events` is currently unset in the prod/staging/test
dynamicconfig rows, so the code-default change here is what enables
these events. The remote value remains available as a kill switch, but
it **replaces** the code defaults rather than merging: if ops sets it to
re-disable an event, the list must include every event that should stay
disabled (`tab_count_tracking`, `node_search`,
`node_search_result_selected`, `help_center_*`, `workflow_created`), not
just the new ones.
2026-06-12 19:02:41 +00:00
pythongosssss
193f23e8c2 Revert "feat: default search to essentials when graph is empty" (#12814)
Reverts Comfy-Org/ComfyUI_frontend#12377
2026-06-12 18:18:34 +00:00
AustinMroz
eaa6776559 Fix broken e2e test (#12818) 2026-06-12 18:08:47 +00:00
Dante
afd42525fe B2 - refactor(billing): complete the billing facade — resubscribe/topup + status fields (FE-904) (#12622)
## What
Implements **B2 — Complete the billing facade** from the FE
billing-divergence survey. Adds the members missing from the shared
`BillingContext` so components stop bypassing `useBillingContext` with
raw `workspaceApi` calls.

Part of the billing convergence plan — **FE-904** (parent **FE-903**).

## Why this PR — an *enabling* refactor (near-zero standalone user
value)
On its own B2 changes no endpoint and is user-invisible (see
**Behavioral impact**). Its entire purpose is to be the **prerequisite**
that unblocks the rest of the convergence — it gives the facade a single
entry point and the missing capability/state surface that the next
levers depend on:

- **Unblocks B3 — repoint direct-bypass consumers (the next PR; a live
bug fix).** `SubscribeButton` (`current_tier`) and
`PostHogTelemetryProvider` (the `subscription_tier` person property)
currently read the **legacy** `useSubscription` tier, so the value is
**stale/empty for team users today** (telemetry + analytics are wrong
right now). They can only be repointed to a correct, workspace-aware
tier by sourcing it from the facade — which requires the **`tier`** (and
`renewalDate`, for `FreeTierDialog`) fields **this PR adds**. Without B2
there is literally no facade `tier` to read.
- **Unblocks B6 — orientation banners.** The 6 billing-state banners
need `billingStatus` / `subscriptionStatus`, exposed here.
- **Unblocks B1 — dispatcher flip (personal → workspace path).** B1 can
only collapse the personal/team fork once (a) every billing operation
flows through the facade — no raw `workspaceApi` bypass left — and (b)
the facade actually supports `resubscribe`/`topup`. This PR removes the
last bypasses and completes the action surface so the unified personal
path will work. (B1 itself stays gated on the BE-DATA unification.)

## Changes
- **Contract** (`composables/billing/types.ts`): `BillingActions` gains
`resubscribe()` and `topup(amountCents)`; `BillingState` gains
`billingStatus`, `subscriptionStatus`, `tier`, `renewalDate`. Exported
`BillingStatus`, `BillingSubscriptionStatus`, `CreateTopupResponse` from
`workspaceApi`.
- **Workspace adapter** (`useWorkspaceBilling`): real wiring —
`workspaceApi.resubscribe()` / `createTopup()`, surfaces
`statusData.billing_status` / `subscription_status` /
`subscription_tier` / `renewal_date`.
- **Legacy adapter** (`useLegacyBilling`): equivalents — `resubscribe` =
fresh checkout via `useSubscription`; `topup` converts **cents →
dollars** through `purchaseCredits`; `billingStatus` = `null` (no legacy
concept); `subscriptionStatus` synthesized from active/cancelled flags.
- **Dispatcher** (`useBillingContext`): proxies the new members.
- **Orphaned callers migrated** off raw `workspaceApi`:
  - `SubscriptionPanelContentWorkspace.vue` → `resubscribe()`
  - `useSubscriptionCheckout.ts` → `resubscribe()`
  - `TopUpCreditsDialogContentWorkspace.vue` → `topup(amountCents)`

## Notes
- **Unit divergence absorbed:** the facade standardizes `topup` on
**cents**; the legacy adapter converts to dollars for
`/customers/credit`.
- **FE-only, no backend dependency** — safe to merge/deploy standalone;
independent of the B1 dispatcher flip (which is gated on the BE-DATA
unification).

## Behavioral impact (verified — safe to merge/deploy standalone)
This is a structural refactor: **endpoints, request payloads, and fetch
counts are unchanged**, and there is **no user-visible change** on
OSS/Desktop or Cloud-personal.

- **OSS / Desktop** (`teamWorkspacesEnabled` off): no change. The only
B2 code that runs is the eager `useAuthActions()` in `useLegacyBilling`
setup — side-effect-free, and already instantiated transitively via
`useSubscription` today. New computeds are lazy with zero readers; new
legacy `resubscribe`/`topup` are never invoked (their callers are
team-only surfaces).
- **Cloud personal**: no change. The migrated handlers are structurally
unreachable on the legacy path (dialog/panel variant gating,
`isCancelled` gated to `!isInPersonalWorkspace`).
- **Cloud team**: same endpoints/payloads/refresh counts. **One
intentional behavioral nuance:** routing `resubscribe`/`topup` through
the facade now toggles the shared `useBillingContext().isLoading` flag
during the call (the previous raw `workspaceApi` calls did not). This is
deliberate — it aligns these two with every other facade action
(`subscribe`, `cancelSubscription`, …). Net effect is at most a brief
loading-indicator flicker in the subscription panel; no change to
network, ordering, or state correctness.

> Follow-up (pre-existing, out of scope): **FE-932** — a completed
top-up refreshes balance but not status, so `subscription.hasFunds` can
be briefly stale. Predates B2 (`main` did balance-only too); to be fixed
with B6.

> Import-cycle note: this closes `useBillingContext → useLegacyBilling →
useAuthActions → useBillingContext`. It is module-eval safe — every
cross-cycle call is at composable-runtime, none at module top level.

## Verification
- `vue-tsc --noEmit`: clean.
- `oxlint --type-aware` on touched files: 0 errors / 0 warnings.
- Runtime no-op confirmed by an adversarial code-path review across the
3 build targets (OSS / Cloud-personal / Cloud-team).
- eslint + unit tests: deferred to CI.

Survey: **FE Billing API Divergence — Personal vs Team Workspace**
(Notion) — notes D4, P6, T1, E7, E9.

> Draft: opened for early review of the facade shape and the
legacy-equivalent semantics (esp. legacy `resubscribe` = fresh checkout,
and the cents/dollars conversion).

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-12 04:02:43 +00:00
Matt Miller
0c392e53a2 fix(oauth): allow reverse-DNS custom-scheme redirects on consent (#12806)
## ELI-5

After you click Continue on the sign-in consent page, the page sends
your browser back to the app that asked. Our safety check only knew
about web-style addresses (`http://...`), so when the iOS app — whose
return address looks like `org.comfy.ios://...` — finished sign-in, the
page refused to deliver and showed "OAuth request failed." The fix:
instead of the page keeping a list of address styles it trusts, it now
asks the backend "what return address did this app register?" and goes
exactly there or nowhere. Truly dangerous addresses (ones that run code
in the page) stay banned outright.

## Problem

The consent success handler hard-allowlists `http(s)` for the
post-consent redirect (`oauthApi.ts`). That covers the loopback
redirects `comfy-desktop`/`comfy-cli` register, but rejects RFC 8252
reverse-DNS custom schemes — the callback shape native-app OAuth clients
use.

Live failure (prod, 2026-06-11, first `comfy-ios` sign-in test): user
approves consent → backend persists consent, consumes the auth request,
and mints an authorization code for `org.comfy.ios://oauth-callback` →
frontend throws `'unsafe scheme'` → user sees the generic **"OAuth
request failed"** → the code expires unused 60s later. Verified
end-to-end in the prod DB.

## Fix (final design — binding, not scheme lists)

Bind the post-consent navigation to the **challenge's registered
`redirect_uri`** (scheme + authority + path equality; the server only
appends `code`/`state` query params to the registered URI). The backend
supplies that field per-request — Comfy-Org/cloud#4230 — so the frontend
carries **zero per-client knowledge**: registering a future native
client is a backend-only change.

Layers:
1. **Executable-scheme denylist**
(`javascript:`/`data:`/`blob:`/`vbscript:`/`about:`) — unconditional;
the actual XSS line.
2. **Registration binding** when `challenge.redirect_uri` is present —
also rejects wrong-client redirects, which no scheme policy could.
3. **http(s)-only fallback** when the challenge doesn't surface
`redirect_uri` (older backend) — preserves today's behavior; the two PRs
can land in either order, but iOS sign-in needs both.

Also per the earlier review pass: navigation uses the parsed URL
(parser/sink consistency), malformed URLs throw structured errors,
single-navigation asserted.

## History

This PR went through three designs: dotted-scheme heuristic → four-lab
adversarial review (68 findings, Opus-judge consolidated) flagged the
heuristic as bypassable → exact scheme allowlist → product feedback
(hardcoding per-client schemes in shared frontend code doesn't scale and
shouldn't exist for non-product test apps) → registration binding, which
the review panel had independently flagged as the strongest option.
41/41 oauth tests passing.

## Tests
- Navigates: bound custom-scheme redirect
(`org.comfy.ios://oauth-callback?code=…` vs registered
`org.comfy.ios://oauth-callback`), http loopback (legacy fallback)
- Rejects: unbound custom scheme (fallback), wrong-client redirect
(`com.evil.app://…` vs registered iOS URI), path mutation, executable
schemes even when 'registered', malformed URLs

Related: BE-1341, BE-1350, Comfy-Org/cloud#4230.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 03:17:57 +00:00
AustinMroz
46526cfabd On mode toggle apply to group children (#12809)
When performing mode toggle operations (like bypass or mute) with a
group (the colored rectangles) selected, nodes contained within the
group will be considered selected and will have their state toggled.
<img width="1024" height="1024" alt="AnimateDiff_00002"
src="https://github.com/user-attachments/assets/c4e9db17-3fe8-4fd8-9012-0e9a0bc59707"
/>
2026-06-12 02:44:02 +00:00
Benjamin Lu
fefbe7843c refactor(website): dedupe download label and add shared Platform type (#12776)
## Summary

Cleanup of the website download button: deduplicate the label lookup in
`DownloadLocalButton.vue`, and export a `Platform` domain type from
`useDownloadUrl` so the button spec, icon map, and analytics all consume
it instead of loose `string`s.

## Changes

- **What**:
- Hoist `t('download.hero.downloadLocal', locale)` into a single `label`
computed, reused by the fallback aria-labels and the button text
(previously evaluated in two places)
- Export `Platform` (`'windows' | 'mac'`) from `useDownloadUrl`;
`ButtonSpec.key`, the `ICONS` map (`Record<Platform, string>`), and
`captureDownloadClick` now consume it — adding a platform without an
icon becomes a compile error, and typo'd platform names can't reach
PostHog
- Drop the redundant `aria-hidden="true"` on the icon (`alt=""` already
marks it decorative)

## Review Focus

`ppformula-text-center` on the icon is intentionally kept — it is
positional (`position: relative; top: 0.19em`), not typographic, and
keeps the icon aligned with the optically-shifted PP Formula text.

No behavior change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-11 16:20:14 +00:00
imick-io
6445690ed3 fix(website): rebrand "Comfy Local" to "Comfy Desktop" (#12794)
## Summary

- Rebrand "Comfy Local" → "Comfy Desktop" across all user-facing copy on
`apps/website`. The nav already showed "Comfy Desktop"; this pulls the
rest of the site in line.
- Updates the homepage product card, the `/cloud` page FAQ + pricing
copy, the `/download` page SEO title/keywords, and matching Storybook
stories. Both `en` and `zh-CN`.
- Internal identifiers left untouched (i18n key names, the
`src/components/product/local/` folder, the `Product = 'local' | …` type
discriminator, and the `media.comfy.org/website/local/*` CDN paths).

## Test plan

- [ ] `pnpm --filter @comfyorg/website dev` and visually verify:
- [ ] `/` — product card section reads "Comfy Desktop" / "SEE DESKTOP
FEATURES"
- [ ] `/cloud` (en + zh-CN) — every FAQ "Local" reference now reads
"Comfy Desktop"
  - [ ] `/download` — page `<title>` and meta keywords show new values
- [ ] Nav + footer still read "Comfy Desktop" / "DOWNLOAD DESKTOP"
(unchanged)
- [ ] `pnpm --filter @comfyorg/website test:e2e -- navigation.spec.ts`
passes
- [ ] Storybook: `Website/Common/ProductCard` and
`Website/Common/FooterLinkColumn` render with new labels
- [ ] Regenerate any screenshot baselines that snapshot the homepage
product cards or the `/cloud` FAQ section

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-11 16:15:19 +00:00
Comfy Org PR Bot
603914e78f 1.46.13 (#12779)
Patch version increment to 1.46.13

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-11 04:04:09 +00:00
jaeone94
c7797b201e Simplify swap node error presentation (#12768)
## Summary

Simplifies the Swap Nodes error card as the fourth slice of the
catalog/error-tab presentation refactor, aligning it with the newer
compact error-row patterns while preserving the existing replace and
locate behavior.

This follows the staged rollout plan from the earlier error-tab PRs:

1. #12683 refined execution-style errors: validation, runtime, and
prompt errors.
2. #12705 simplified missing media errors into flat, locatable rows.
3. #12735 simplified missing node pack errors and aligned grouped-row
behavior.
4. This PR applies the same simplification pass to Swap Nodes errors.
5. A later PR is expected to handle Missing Models, which is larger and
intentionally kept separate.

After the Missing Models slice lands, a follow-up consistency PR will
normalize the shared row/disclosure pattern across Missing Node Packs,
Swap Nodes, and Missing Models together. That follow-up will cover
parameterized i18n labels for disclosure controls, shared text-button
styling, and consistent disclosure semantics/accessibility across those
grouped rows.

## Changes

- **What**: Reworks the Swap Nodes card rows so each replacement group
is presented as a compact row with the source node type, replacement
target, replace action, and locate action.
- **What**: For a single affected node, the visible row label can be
clicked to locate the node, matching the interaction model used by the
newer missing-media and missing-node rows.
- **What**: For multiple affected nodes with the same replacement
target, the group renders a count badge and a disclosure row. Expanding
the group shows the affected node rows, each with its own locate action.
- **What**: Removes the old node-id badge path from Swap Nodes rows.
Node-id badges remain available to the other error cards that still own
that behavior.
- **What**: Keeps replacement behavior unchanged: per-group replacement
and replace-all still call through the existing node replacement store
flow.
- **What**: Adds regression coverage for the new grouped-row UI,
including same-type grouping in both Vue Nodes and LiteGraph render
modes.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Please focus on the Swap Nodes presentation and interaction symmetry
with the previous error-tab PRs:

- Single-node groups should remain directly locatable via the row label
and the locate icon.
- Multi-node groups should expose the count and expand/collapse behavior
without adding duplicate focusable disclosure controls.
- The visible row labels intentionally keep their own accessible names,
while the separate locate icon uses the generic `Locate node on canvas`
accessible name. This mirrors the established pattern from the previous
slices.
- The newly added Playwright fixture covers two same-type replaceable
nodes so duplicate group keys and grouped disclosure behavior are
exercised end-to-end.

## Validation

- `pnpm format`
- `pnpm test:unit
src/platform/nodeReplacement/components/SwapNodeGroupRow.test.ts`
- `pnpm test:browser:local browser_tests/tests/nodeReplacement.spec.ts
--project=chromium`
- Pre-commit hook: lint-staged, stylelint, oxfmt, oxlint, eslint, `pnpm
typecheck`, `pnpm typecheck:browser`
- Pre-push hook: `pnpm knip --cache`
- Additional parallel code review pass completed locally; no blocker or
major issues remained.

## Screenshots (if applicable)

This PR 

<img width="561" height="362" alt="스크린샷 2026-06-11 오전 3 46 06"
src="https://github.com/user-attachments/assets/65395467-6c2f-4aa1-84c5-3d9614c00c80"
/>

old (Main)
<img width="611" height="798" alt="스크린샷 2026-06-11 오전 3 46 32"
src="https://github.com/user-attachments/assets/3862d5df-f839-40c0-9488-ce64b051378e"
/>
2026-06-11 03:50:16 +00:00
Matt Miller
aa68573a6e fix: remove broken throttle from VirtualGrid scroll tracking (#12781)
## Summary

Every `VirtualGrid` consumer (assets sidebar, manager dialog, widget
select dropdown) is blind to discrete mouse-wheel scrolling:
`useScroll`'s `throttle: 64` never reports scroll position, so the
virtualization window stays frozen — users see blank space below the
first viewport of items and `approach-end` (infinite scroll) never
fires. Trackpad scrolling masks the bug by emitting events faster than
the throttle window.

## Changes

- **What**: drop the `throttle` option from `useScroll` in `VirtualGrid`
and remove the `scrollThrottle` prop (no consumer passes it). Scroll
events are frame-aligned and the handler is cheap, so the throttle
bought nothing even when it worked.
- **Root cause**: VueUse ≥14 `throttleFilter` with `leading=false` (what
`useScroll` uses) marks spaced-out events as executed without executing
them — each event re-arms an `isLeading` restore timer that makes the
next event skip its invoke, and the trailing branch is unreachable when
`elapsed > duration`. Regression of vueuse#2390; still present on vueuse
`main`.

## Review Focus

- Verified live against staging: with the throttle, sidebar scrolled to
`scrollTop` 1250 while `scrollY` stayed 0 and the render window stayed
at `[0..3)` of 27 (blank viewport); with this fix, `scrollY` tracks 1:1
and the window advances. Bare-vs-throttled `useScroll` compared
side-by-side on the same element to isolate the cause.
- Unblocks wheel-scroll for #12780's dropdown infinite scroll with no
changes there.

- Fixes FE-990

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-11 03:27:54 +00:00
Deep Mehta
79acf7be5e fix: re-encode favicon.ico with PNG frames to fix white corner artifacts (#12753)
## Summary

The rebranded `favicon.ico` renders with **opaque white corners** in
browsers — the rounded mark shows white slivers around its corners on
any background. This is a decode bug, not a design issue: the ICO
contains **BMP-format frames** whose alpha channel Chrome (and other
consumers) mishandle. Verified by loading the raw `.ico` in headless
Chrome on a dark page: corners render white instead of transparent.

Every surface that consumes the `.ico` directly shows the artifact —
Google search results, connector icon scraping, raw image views — while
browser tabs look fine because they prefer the SVG favicon.

## Changes

- **What**: Re-encode `apps/website/public/favicon.ico` and
`public/assets/favicon.ico` with **PNG-format ICO frames** (16/32/48).
PNG frames carry unambiguous alpha and decode correctly in all modern
browsers.
- **No design change**: identical rounded artwork, same transparency,
same sizes and filenames. SVG, PNGs, apple-touch-icon, and manifest
icons are untouched.
- **Breaking**: none.

## Review Focus

- Headless-Chrome verified: the old ico renders white corners on a dark
page; the re-encoded one renders transparent corners. (Comparison in PR
comments.)
- PNG-in-ICO is supported by all modern browsers and Google's favicon
pipeline.
- After merge, please add `needs-backport` + `cloud/1.45` so
cloud.comfy.org's copy gets the same fix.

## Screenshots (if applicable)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-11 03:27:53 +00:00
Robin Huang
02adfd4b83 feat: identify prompt source via comfy_usage_source extra_data (#12772)
Adds `comfy_usage_source: 'comfyui-frontend'` to the prompt body's
`extra_data`. The backend forwards this to API nodes' upstream requests
via the `Comfy-Usage-Source` header, so partner node API usage can be
attributed to the frontend.

Used in https://github.com/Comfy-Org/ComfyUI/pull/14404
2026-06-10 22:43:34 +00:00
Robin Huang
7c2c78b537 feat: send deploy_environment as Comfy-Env header on /releases requests (#12771)
Reads `system.deploy_environment` from `/system_stats` (added in
Comfy-Org/ComfyUI#14402) and sends it as the `Comfy-Env` header when
fetching `/releases`, matching the header name the backend already uses
for outbound API node requests. The header is omitted when the backend
doesn't report the field, so older backends are unaffected.

Note: api.comfy.org must allow `Comfy-Env` in
`Access-Control-Allow-Headers` for the CORS preflight to pass.
2026-06-10 21:30:08 +00:00
Matt Miller
bd1fd0680e feat(assets): walk getAllAssetsByTag via keyset cursor (#12720)
## ELI-5

When the app needs *all* the assets for a tag (like every input image),
it asks the server for them one page at a time. Today it says "give me
page starting at item #500" (offset paging). If items get added or
removed while it's flipping through, pages shift and it can show the
same thing twice or skip something.

This switches to "give me the page *after this bookmark*" (cursor
paging). The server now hands back a `next_cursor` bookmark with each
page; we pass it to fetch the next one. Bookmarks don't slip when the
list changes underneath, so the walk is stable and drift-free.

## What

Migrates the full-walk asset pager (`getAllAssetsByTag`) from offset to
keyset (`after` / `next_cursor`) pagination, now that the list-assets
endpoint exposes a cursor contract in the generated types.

- `handleAssetRequest` accepts an `after` cursor and sends it instead of
`offset` when present (the server ignores `offset` alongside a cursor)
- `getAllAssetsByTag` resumes each page from the prior response's
`next_cursor`, and terminates when `has_more` is false or `next_cursor`
is omitted
- `next_cursor` is exposed on the asset response schema; `after` is
threaded through `getAssetsByTag` / `getAssetsPageByTag` for
cursor-aware callers
- offset remains supported for random-access callers; only the full-walk
path changes

## Why

Offset pagination double-counts or skips records when the underlying set
changes mid-walk. Keyset cursors are stable under concurrent
inserts/deletes and scale better than deep offsets.

## Stacking

Based on `update-ingest-types` because the `after`/`next_cursor` types
land there first; this targets that branch and will retarget to the
default branch once it merges. Changes here touch only the asset
service/schema, disjoint from the generated types.

## Follow-ups

The asset store's bespoke offset loops (model loader, flat-output
infinite scroll) and the missing-media resolver still walk by offset;
those migrate in separate PRs.

## Tests

`assetService.test.ts` updated to assert the cursor walk, that the first
page carries neither `after` nor `offset`, that subsequent pages resume
from `next_cursor`, and that the walk halts when `next_cursor` is absent
even if `has_more` is true. Full asset/service + missing-media + store
suites pass locally (193 tests).

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-10 21:15:19 +00:00
Robin Huang
9617e498c9 feat: track desktop download button clicks on website (#12770)
Adds a `website:download_button_clicked` PostHog event (with `platform`
property) fired when a user clicks the desktop installer download button
on comfy.org. Previously we only had `/download` pageviews as a proxy —
autocapture is not active on the website project, so these clicks were
untracked. Includes unit tests for the new capture helper.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:44:57 +00:00
Terry Jia
25205c0f55 feat: add Load3DAdvanced node (#12723)
## Summary
add Load3DAdvanced node, without FE render, upload BE and HDRI.
BE https://github.com/Comfy-Org/ComfyUI/pull/14316

## Screenshots (if applicable)

https://github.com/user-attachments/assets/e561c919-bb52-4904-97da-fb01885762a7
2026-06-10 13:51:50 -04:00
219 changed files with 8645 additions and 2865 deletions

View File

@@ -65,6 +65,7 @@
],
"no-unsafe-optional-chaining": "error",
"no-self-assign": "allow",
"no-unreachable": "error",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
"no-useless-rename": "off",
@@ -73,12 +74,14 @@
"import/namespace": "error",
"import/no-duplicates": "error",
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"jest/expect-expect": "off",
"jest/no-conditional-expect": "off",
"jest/no-disabled-tests": "off",
"jest/no-standalone-expect": "off",
"jest/valid-title": "off",
"vitest/expect-expect": "off",
"vitest/no-conditional-expect": "off",
"vitest/no-disabled-tests": "off",
"vitest/no-standalone-expect": "off",
"vitest/valid-title": "off",
"vitest/require-to-throw-message": "off",
"typescript/no-this-alias": "off",
"typescript/no-useless-default-assignment": "off",
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",

View File

@@ -15,7 +15,9 @@ test.describe('Download page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
await expect(page).toHaveTitle(
'Download Comfy Desktop — Run AI on Your Hardware'
)
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1,23 +1,23 @@
{
"name": "Comfy",
"short_name": "Comfy",
"id": "/",
"start_url": "/",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
"purpose": "any maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
"purpose": "any maskable"
}
],
"theme_color": "#211927",
"background_color": "#211927",
"display": "standalone"
"display": "standalone",
"id": "/",
"start_url": "/"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -21,7 +21,7 @@ export const Default: Story = {
args: {
title: 'Product',
links: [
{ label: 'Local', href: '/local' },
{ label: 'Desktop', href: '/download' },
{ label: 'Cloud', href: '/cloud' },
{ label: 'API', href: '/api' },
{ label: 'Enterprise', href: '/enterprise' }

View File

@@ -12,9 +12,9 @@ const meta: Meta<typeof ProductCard> = {
})
],
args: {
title: 'Comfy\nLocal',
title: 'Comfy\nDesktop',
description: 'Run ComfyUI on your own hardware.',
cta: 'SEE LOCAL FEATURES',
cta: 'SEE DESKTOP FEATURES',
href: '#',
bg: 'bg-primary-warm-gray'
}
@@ -31,9 +31,9 @@ export const AllCards: Story = {
template: `
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<ProductCard
title="Comfy\nLocal"
title="Comfy\nDesktop"
description="Run ComfyUI on your own hardware."
cta="SEE LOCAL FEATURES"
cta="SEE DESKTOP FEATURES"
href="#"
bg="bg-primary-warm-gray"
/>

View File

@@ -3,11 +3,13 @@ import type { Locale } from '../../../i18n/translations'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import type { Platform } from '../../../composables/useDownloadUrl'
import {
downloadUrls,
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import { captureDownloadClick } from '../../../scripts/posthog'
import BrandButton from '../../common/BrandButton.vue'
const { locale = 'en', class: customClass = '' } = defineProps<{
@@ -17,13 +19,15 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
const { downloadUrl, platform, showFallback } = useDownloadUrl()
const ICONS = {
const label = computed(() => t('download.hero.downloadLocal', locale))
const ICONS: Record<Platform, string> = {
windows: '/icons/os/windows.svg',
mac: '/icons/os/apple.svg'
} as const
}
interface ButtonSpec {
key: string
key: Platform
href: string
icon: string
ariaLabel?: string
@@ -40,19 +44,18 @@ const buttons = computed<ButtonSpec[]>(() => {
]
}
if (showFallback.value) {
const label = t('download.hero.downloadLocal', locale)
return [
{
key: 'windows',
href: downloadUrls.windows,
icon: ICONS.windows,
ariaLabel: `${label} — Windows`
ariaLabel: `${label.value} — Windows`
},
{
key: 'mac',
href: downloadUrls.macArm,
icon: ICONS.mac,
ariaLabel: `${label} — macOS`
ariaLabel: `${label.value} — macOS`
}
]
}
@@ -69,17 +72,15 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">
<img
:src="btn.icon"
alt=""
class="ppformula-text-center size-5 -translate-y-0.75"
aria-hidden="true"
/>
<span class="ppformula-text-center">{{
t('download.hero.downloadLocal', locale)
}}</span>
<span class="ppformula-text-center">{{ label }}</span>
</span>
</BrandButton>
</template>

View File

@@ -7,13 +7,13 @@ export const downloadUrls = {
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const
type DetectedPlatform = 'windows' | 'mac' | null
export type Platform = 'windows' | 'mac'
function isMobile(ua: string): boolean {
return /iphone|ipad|ipod|android/.test(ua)
}
function detectPlatform(ua: string): DetectedPlatform {
function detectPlatform(ua: string): Platform | null {
if (isMobile(ua)) return null
if (ua.includes('win')) return 'windows'
if (ua.includes('macintosh') || ua.includes('mac os x')) return 'mac'
@@ -23,7 +23,7 @@ function detectPlatform(ua: string): DetectedPlatform {
// TODO: Only Windows x64 and macOS arm64 are available today.
// When Linux and/or macIntel builds are added, extend detection and URLs here.
export function useDownloadUrl() {
const platform = ref<DetectedPlatform>(null)
const platform = ref<Platform | null>(null)
const detected = ref(false)
const isMobileUa = ref(false)

View File

@@ -174,16 +174,16 @@ const translations = {
'zh-CN': '掌控每个模型、每个节点、每个步骤、每个输出。'
},
'products.local.title': {
en: 'Comfy\nLocal',
'zh-CN': 'Comfy\n本地版'
en: 'Comfy\nDesktop',
'zh-CN': 'Comfy\n桌面版'
},
'products.local.description': {
en: 'Run ComfyUI on your own hardware.',
'zh-CN': '在您自己的硬件上运行 ComfyUI。'
},
'products.local.cta': {
en: 'SEE LOCAL FEATURES',
'zh-CN': '查看本地版属性'
en: 'SEE DESKTOP FEATURES',
'zh-CN': '查看桌面版属性'
},
'products.cloud.title': {
en: 'Comfy\nCloud',
@@ -1057,18 +1057,18 @@ const translations = {
'zh-CN': 'Cloud 与本地运行 ComfyUI 有什么区别?'
},
'cloud.faq.2.a': {
en: 'Cloud runs on powerful remote GPUs and is accessible from any device. Local runs entirely on your computer, giving you full control and offline use.',
en: 'Cloud runs on powerful remote GPUs and is accessible from any device. Comfy Desktop runs entirely on your computer, giving you full control and offline use.',
'zh-CN':
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。本地版完全在您的电脑上运行,提供完全控制和离线使用。'
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。Comfy 桌面版完全在您的电脑上运行,提供完全控制和离线使用。'
},
'cloud.faq.3.q': {
en: 'Which version should I choose, Comfy Cloud or local ComfyUI (self-hosted)?',
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI自托管'
en: 'Which version should I choose, Comfy Cloud or Comfy Desktop?',
'zh-CN': '我应该选择 Comfy Cloud 还是 Comfy 桌面版'
},
'cloud.faq.3.a': {
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nComfy Desktop is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
'zh-CN':
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\nComfy 桌面版可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
},
'cloud.faq.4.q': {
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
@@ -1091,9 +1091,9 @@ const translations = {
'zh-CN': '我可以在 Comfy Cloud 上使用现有的工作流吗?'
},
'cloud.faq.6.a': {
en: 'Yes, your workflows work across Local and Cloud. Just note that only the most popular custom nodes are supported for now, but more will be added soon.',
en: 'Yes, your workflows work across Desktop and Cloud. Just note that only the most popular custom nodes are supported for now, but more will be added soon.',
'zh-CN':
'可以,您的工作流在本地和云端都能使用。请注意,目前仅支持最热门的自定义节点,但很快会添加更多。'
'可以,您的工作流在桌面版和云端都能使用。请注意,目前仅支持最热门的自定义节点,但很快会添加更多。'
},
'cloud.faq.7.q': {
en: 'Are all ComfyUI extensions and custom nodes supported?',
@@ -1145,9 +1145,9 @@ const translations = {
'zh-CN': '合作伙伴节点积分和我的 Cloud 订阅有什么区别?'
},
'cloud.faq.12.a': {
en: 'Comfy Cloud has a credit system that is used for both Partner nodes (formerly API nodes) and running workflows on cloud.\n1. Partner Nodes (Pay-as-you-go): These nodes (formerly called API nodes) run third-party models via API calls and can be used on both Comfy Cloud and Local/Self-Hosted ComfyUI. Each node has its own usage cost, determined by the API provider, and we directly match their pricing.\n2. Running workflows on cloud: Exclusive to Comfy Cloud, you get a set amount of credits per month, with the amount differing based on your plan. More credits can be topped up anytime. Credits are only used up for GPU time while workflows are running — not while editing or building them. No idle costs, no setup, and no infrastructure to manage.',
en: 'Comfy Cloud has a credit system that is used for both Partner nodes (formerly API nodes) and running workflows on cloud.\n1. Partner Nodes (Pay-as-you-go): These nodes (formerly called API nodes) run third-party models via API calls and can be used on both Comfy Cloud and Comfy Desktop. Each node has its own usage cost, determined by the API provider, and we directly match their pricing.\n2. Running workflows on cloud: Exclusive to Comfy Cloud, you get a set amount of credits per month, with the amount differing based on your plan. More credits can be topped up anytime. Credits are only used up for GPU time while workflows are running — not while editing or building them. No idle costs, no setup, and no infrastructure to manage.',
'zh-CN':
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和本地/自托管 ComfyUI 上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和 Comfy 桌面版上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
},
'cloud.faq.13.q': {
en: 'Can I cancel my subscription?',
@@ -1411,9 +1411,9 @@ const translations = {
'zh-CN': '合作伙伴节点'
},
'pricing.included.feature8.description': {
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and Comfy Desktop. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
'zh-CN':
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和 Comfy 桌面版间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
},
'pricing.included.feature9.title': {
en: 'Job queue',

View File

@@ -73,7 +73,7 @@ const websiteJsonLd = {
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="shortcut icon" href="/favicon.ico" sizes="48x48" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#211927" />

View File

@@ -11,9 +11,9 @@ import { t } from '../i18n/translations'
---
<BaseLayout
title="Download Comfy — Run AI Locally"
title="Download Comfy Desktop — Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
keywords={['comfyui app', 'comfyui desktop app', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux', 'comfyui local']}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
>
<CloudBannerSection />
<HeroSection client:load />

View File

@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
---
<BaseLayout
title="下载 Comfy"
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
description={t('download.hero.subtitle', 'zh-CN')}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>

View File

@@ -53,3 +53,28 @@ describe('initPostHog', () => {
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})
describe('captureDownloadClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the download event with the platform', async () => {
const { initPostHog, captureDownloadClick } = await import('./posthog')
initPostHog()
captureDownloadClick('mac')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:download_button_clicked',
{ platform: 'mac' }
)
})
it('does not capture before PostHog is initialized', async () => {
const { captureDownloadClick } = await import('./posthog')
captureDownloadClick('windows')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -2,6 +2,8 @@ import posthog from 'posthog-js'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
import type { Platform } from '@/composables/useDownloadUrl'
const POSTHOG_KEY =
import.meta.env.PUBLIC_POSTHOG_KEY ??
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
@@ -38,3 +40,12 @@ export function capturePageview() {
console.error('PostHog pageview capture failed', error)
}
}
export function captureDownloadClick(platform: Platform) {
if (!initialized) return
try {
posthog.capture('website:download_button_clicked', { platform })
} catch (error) {
console.error('PostHog download click capture failed', error)
}
}

View File

@@ -0,0 +1,60 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [100, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["cloud_importable_model.safetensors"]
},
{
"id": 2,
"type": "LoadImage",
"pos": [560, 100],
"size": [400, 314],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": null },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["cloud_unknown_model.safetensors", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "cloud_importable_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -0,0 +1,61 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "E2E_OldSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [42, 20, 7, "euler", "normal"]
},
{
"id": 2,
"type": "E2E_OldSampler",
"pos": [520, 100],
"size": [400, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [43, 20, 7, "euler", "normal"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
"version": 0.4
}

View File

@@ -51,20 +51,6 @@ export class FeatureFlagHelper {
})
}
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, flags)
}
async setServerFlag(name: string, value: unknown): Promise<void> {
await this.setServerFlags({ [name]: value })
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -60,16 +60,19 @@ export const TestIds = {
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model',
missingModelExpand: 'missing-model-expand',
missingModelImport: 'missing-model-import',
missingModelImportableRows: 'missing-model-importable-rows',
missingModelLocate: 'missing-model-locate',
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelReferenceCount: 'missing-model-reference-count',
missingModelUnsupportedSection:
'missing-model-import-not-supported-section',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingModelRefresh: 'missing-model-header-refresh',
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
swapNodeGroupCount: 'swap-node-group-count',
missingMediaRow: 'missing-media-row',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',
@@ -136,7 +139,8 @@ export const TestIds = {
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red',
convertSubgraph: 'convert-to-subgraph-button'
convertSubgraph: 'convert-to-subgraph-button',
bypass: 'bypass-button'
},
menu: {
moreMenuContent: 'more-menu-content'

View File

@@ -190,6 +190,16 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
'custom-color-palette-obsidian-dark.png'
)
})
test('Palette can modify @vue-nodes color', async ({ comfyPage }) => {
const node = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const getColor = () =>
node.body.evaluate((el) => getComputedStyle(el).backgroundColor)
const initialColor = await getColor()
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'solarized')
await expect.poll(getColor).not.toEqual(initialColor)
})
})
test.describe(

View File

@@ -48,6 +48,36 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
).toBeVisible()
})
test('Shows direct row label and locate action for a single replacement group', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const rowLabel = swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
await expect(rowLabel).toBeVisible()
await expect(
swapGroup.getByRole('button', {
name: 'Locate node on canvas',
exact: true
})
).toBeVisible()
await expect(
swapGroup.getByTestId(TestIds.dialogs.swapNodeGroupCount)
).toHaveCount(0)
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await rowLabel.click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
})
test('Replace Node replaces a single group in-place', async ({
comfyPage
}) => {
@@ -116,6 +146,55 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
})
})
test.describe('Same-type replacement group', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_same_type'
)
})
test('Groups same-type replacement rows behind the title disclosure', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const countBadge = swapGroup.getByTestId(
TestIds.dialogs.swapNodeGroupCount
)
const childRows = swapGroup.getByRole('listitem')
const expandButton = swapGroup.getByRole('button', {
name: 'Expand E2E_OldSampler',
exact: true
})
await expect(expandButton).toBeVisible()
await expect(countBadge).toHaveText('2')
await expect(childRows).toHaveCount(0)
await expandButton.click()
await expect(childRows).toHaveCount(2)
await expect(
swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
).toHaveCount(2)
await swapGroup
.getByRole('button', {
name: 'Collapse E2E_OldSampler',
exact: true
})
.click()
await expect(childRows).toHaveCount(0)
})
})
test.describe('Multi-type replacement', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(

View File

@@ -309,50 +309,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
)
})
test.describe('Empty graph defaults', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setServerFlag(
'node_library_essentials_enabled',
true
)
})
test('Defaults to Essentials when graph is empty', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await searchBoxV2.open()
const essentialsBtn = searchBoxV2.rootCategoryButton(
RootCategory.Essentials
)
await expect(essentialsBtn).toBeVisible()
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
})
test('Defaults to Most Relevant when graph has nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
await searchBoxV2.open()
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
'aria-current',
'true'
)
await expect(
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
).toHaveAttribute('aria-pressed', 'false')
})
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage

View File

@@ -1,16 +1,29 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import type {
Asset,
AssetCreated,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import {
countAssetRequestsByTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
const WORKFLOW = 'missing/nested_subgraph_installed_model'
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
'models/checkpoints/cloud_importable_model.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
id: 'test-lotus-depth-d-v1-1',
@@ -27,13 +40,62 @@ const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
}
}
const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
id: 'test-existing-cloud-importable-model',
name: 'asset-record-display-name.safetensors',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000204',
size: 2_048,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2026-05-05T00:00:00Z',
updated_at: '2026-05-05T00:00:00Z',
last_access_time: '2026-05-05T00:00:00Z',
user_metadata: {
filename: CLOUD_IMPORTED_CANONICAL_MODEL_NAME
}
}
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
function getRequestedIncludeTags(requestUrl: string): string[] {
return (
new URL(requestUrl).searchParams
.get('include_tags')
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
function filterAssetsByRequest(
assets: ReadonlyArray<Asset>,
requestUrl: string
): Asset[] {
const includeTags = getRequestedIncludeTags(requestUrl)
return includeTags.length
? assets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: [...assets]
}
async function enableMissingModelImportFeatures(page: Page): Promise<void> {
await page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
model_upload_button_enabled: true,
private_models_enabled: true
}
})
}
test.describe(
'Errors tab - Cloud missing models',
{ tag: ['@cloud', '@vue-nodes'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await enableMissingModelImportFeatures(comfyPage.page)
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
@@ -88,5 +150,216 @@ test.describe(
await expect(errorsTab).toBeHidden()
})
test('separates importable cloud models from unsupported rows', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const importableRows = missingModelsGroup.getByTestId(
TestIds.dialogs.missingModelImportableRows
)
const unsupportedSection = missingModelsGroup.getByTestId(
TestIds.dialogs.missingModelUnsupportedSection
)
await expect(
importableRows.getByRole('button', {
name: CLOUD_IMPORTABLE_MODEL_NAME,
exact: true
})
).toBeVisible()
await expect(
importableRows.getByTestId(TestIds.dialogs.missingModelImport)
).toBeVisible()
await expect(unsupportedSection).toBeVisible()
await expect(
unsupportedSection.getByText('Import Not Supported')
).toBeVisible()
await expect(
unsupportedSection.getByText(
/Nodes that reference the models below do not support imported models/
)
).toBeVisible()
await expect(
unsupportedSection.getByText(CLOUD_UNKNOWN_MODEL_NAME)
).toBeVisible()
await expect(
unsupportedSection.getByText('Unknown', { exact: true })
).toBeVisible()
await expect(
unsupportedSection.getByRole('button', {
name: 'Load Image',
exact: true
})
).toBeVisible()
await expect(
unsupportedSection.getByTestId(TestIds.dialogs.missingModelImport)
).toHaveCount(0)
})
test('opens cloud import with missing-model replacement context', async ({
comfyPage
}) => {
await comfyPage.modelLibrary.mockModelFolders([
{ name: 'checkpoints', folders: [] }
])
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
const response: AssetMetadata = {
content_length: 1024,
final_url:
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors',
content_type: 'application/octet-stream',
filename: 'replacement.safetensors',
tags: ['loras']
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await missingModelsGroup
.getByTestId(TestIds.dialogs.missingModelImport)
.click()
const urlInput = comfyPage.page.locator(
'[data-attr="upload-model-step1-url-input"]'
)
await expect(urlInput).toBeVisible()
await urlInput.fill(
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors'
)
await comfyPage.page
.locator('[data-attr="upload-model-step1-continue-button"]')
.click()
const uploadDialog = comfyPage.page.getByRole('dialog', {
name: /Import a model/
})
await expect(
uploadDialog.getByText(
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
)
).toBeVisible()
await expect(uploadDialog.getByText('Load Checkpoint')).toBeVisible()
await expect(uploadDialog.getByText('- ckpt_name')).toBeVisible()
await expect(
uploadDialog.getByText(
/Locked to (Checkpoints|checkpoints) for this missing model/
)
).toBeVisible()
})
test('uses the synced asset filename when applying an already imported cloud model', async ({
comfyPage
}) => {
let isImportedAssetAvailable = false
const visibleAssets = () =>
isImportedAssetAvailable
? [LOTUS_DIFFUSION_MODEL, EXISTING_CLOUD_IMPORTABLE_MODEL]
: [LOTUS_DIFFUSION_MODEL]
await comfyPage.modelLibrary.mockModelFolders([
{ name: 'checkpoints', folders: [] }
])
await comfyPage.page.route(/\/api\/assets(?:\?.*)?$/, (route) => {
const assets = filterAssetsByRequest(
visibleAssets(),
route.request().url()
)
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await comfyPage.page.route('**/assets/remote-metadata?**', (route) => {
const response: AssetMetadata = {
content_length: 2048,
final_url:
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors',
content_type: 'application/octet-stream',
filename: CLOUD_IMPORTABLE_MODEL_NAME,
tags: ['checkpoints']
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await comfyPage.page.route('**/assets/download', (route) => {
isImportedAssetAvailable = true
const response: AssetCreated = {
...EXISTING_CLOUD_IMPORTABLE_MODEL,
created_new: false
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await missingModelsGroup
.getByTestId(TestIds.dialogs.missingModelImport)
.click()
const uploadDialog = comfyPage.page.getByRole('dialog', {
name: /Import a model/
})
const urlInput = uploadDialog.locator(
'[data-attr="upload-model-step1-url-input"]'
)
await urlInput.fill(
'https://huggingface.co/comfy/test/resolve/main/cloud_importable_model.safetensors'
)
await uploadDialog
.locator('[data-attr="upload-model-step1-continue-button"]')
.click()
await expect(
uploadDialog.getByText(
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
)
).toBeVisible()
await uploadDialog
.locator('[data-attr="upload-model-step2-confirm-button"]')
.click()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph.getNodeById(1)
return node?.widgets?.find((widget) => widget.name === 'ckpt_name')
?.value
})
)
.toBe(CLOUD_IMPORTED_CANONICAL_MODEL_NAME)
})
}
)

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -11,6 +12,18 @@ import {
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
).toHaveText(String(count))
}
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
@@ -34,15 +47,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
).toHaveText(/\S/)
})
test('Should display model name with referencing node count', async ({
comfyPage
}) => {
test('Should display model name and metadata', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
await expect(getModelLabel(modelsGroup)).toBeVisible()
await expect(modelsGroup.getByText('checkpoints')).toBeVisible()
})
test('Should expand model row to show referencing nodes', async ({
@@ -53,32 +65,33 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
'missing/missing_models_with_nodes'
)
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelLocate
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(locateButton.first()).toBeHidden()
const expandButton = comfyPage.page.getByTestId(
const expandButton = modelsGroup.getByTestId(
TestIds.dialogs.missingModelExpand
)
await expect(expandButton.first()).toBeVisible()
await expectReferenceBadge(modelsGroup, 2)
await expandButton.first().click()
await expect(locateButton.first()).toBeVisible()
await expect(
modelsGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(2)
})
test('Should copy model name to clipboard', async ({ comfyPage }) => {
test('Should copy model URL to clipboard', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await interceptClipboardWrite(comfyPage.page)
const copyButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyName
)
const copyButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
await expect(copyButton.first()).toBeVisible()
await copyButton.first().dispatchEvent('click')
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toContain('fake_model.safetensors')
expect(copiedText).toContain('/api/devtools/')
})
test.describe('OSS-specific', { tag: '@oss' }, () => {
@@ -87,9 +100,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
const copyUrlButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
await expect(copyUrlButton.first()).toBeVisible()
})
@@ -102,6 +115,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelDownload
)
await expect(downloadButton.first()).toBeVisible()
await expect(downloadButton.first()).toHaveText('Download')
})
test('Should render Download all and Refresh actions for one downloadable model', async ({

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -8,6 +9,18 @@ import {
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
).toHaveText(String(count))
}
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -130,9 +143,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
'missing/missing_models_from_node_properties'
)
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
const copyUrlButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
await expect(copyUrlButton.first()).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
@@ -156,9 +169,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
@@ -168,9 +179,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(2\)/
)
await expectReferenceBadge(missingModelGroup, 2)
})
test('Pasting a bypassed node does not add a new error', async ({
@@ -252,14 +261,17 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(/\(2\)/)
await expectReferenceBadge(missingModelGroup, 2)
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(missingModelGroup).toContainText(/\(1\)/)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(1)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(/\(2\)/)
await expectReferenceBadge(missingModelGroup, 2)
})
})
@@ -384,9 +396,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
@@ -439,9 +449,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle('Subgraph with Promoted Missing Model')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -129,23 +129,18 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// A group + a KSampler node
await comfyPage.workflow.loadWorkflow('groups/single_group')
const bypass = comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass)
// Select group + node should show bypass button
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+A')
await expect(
comfyPage.page.locator(
'.selection-toolbox *[data-testid="bypass-button"]'
)
).toBeVisible()
// Deselect node (Only group is selected) should hide bypass button
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(
comfyPage.page.locator(
'.selection-toolbox *[data-testid="bypass-button"]'
)
).toBeHidden()
await expect(bypass).toBeVisible()
await comfyPage.keyboard.delete()
// (Only empty group is selected) should hide bypass button
await comfyPage.keyboard.selectAll()
await expect(comfyPage.selectionToolbox).toBeVisible()
await expect(bypass).toBeHidden()
})
test.describe('Color Picker', () => {

View File

@@ -3,6 +3,8 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
const CREATE_GROUP_HOTKEY = 'Control+g'
@@ -217,4 +219,40 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
)
}).toPass({ timeout: 5000 })
})
test('Bypassing a group bypasses contents', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press('.')
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
const toggleBypass = () =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass).click()
const bypassCount = () =>
comfyPage.page.evaluate(
() => graph!.nodes.filter((node) => node.mode === 4).length
)
expect(await bypassCount()).toBe(0)
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
await expect.poll(groupCount, 'create group').toBe(1)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await ksampler.select()
await toggleBypass()
await expect.poll(bypassCount, 'setup bypass of single node').toBe(1)
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
await comfyPage.page.mouse.click(groupPos.x, groupPos.y)
await toggleBypass()
await expect.poll(bypassCount, 'all nodes are set to bypassed').toBe(7)
await toggleBypass()
await expect.poll(bypassCount, 'all nodes are unbypassed').toBe(0)
await comfyPage.page.keyboard.down('Shift')
await ksampler.select()
await comfyPage.page.keyboard.up('Shift')
await toggleBypass()
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -34,7 +34,7 @@ function formatAndEslint(fileNames: string[]) {
const joinedPaths = toJoinedRelativePaths(fileNames)
return [
`pnpm exec oxfmt --write ${joinedPaths}`,
`pnpm exec oxlint --type-aware --fix ${joinedPaths}`,
`pnpm exec oxlint --type-aware --no-error-on-unmatched-pattern --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}

View File

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

View File

@@ -111,6 +111,7 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
})

View File

@@ -591,7 +591,15 @@ const IMAGE_EXTENSIONS = [
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const
const THREE_D_EXTENSIONS = [
'obj',
'fbx',
'gltf',
'glb',
'stl',
'usdz',
'ply'
] as const
const TEXT_EXTENSIONS = [
'txt',
'md',

443
pnpm-lock.yaml generated
View File

@@ -211,8 +211,8 @@ catalogs:
specifier: ^4.16.2
version: 4.16.2
eslint-plugin-oxlint:
specifier: 1.59.0
version: 1.59.0
specifier: 1.69.0
version: 1.69.0
eslint-plugin-playwright:
specifier: ^2.10.1
version: 2.10.1
@@ -277,14 +277,14 @@ catalogs:
specifier: ^2.12.9
version: 2.12.9
oxfmt:
specifier: ^0.44.0
version: 0.44.0
specifier: ^0.54.0
version: 0.54.0
oxlint:
specifier: ^1.59.0
version: 1.59.0
specifier: ^1.69.0
version: 1.69.0
oxlint-tsgolint:
specifier: ^0.20.0
version: 0.20.0
specifier: ^0.23.0
version: 0.23.0
picocolors:
specifier: ^1.1.1
version: 1.1.1
@@ -717,13 +717,13 @@ importers:
version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1)))(eslint@10.4.0(jiti@2.6.1))
eslint-plugin-better-tailwindcss:
specifier: 'catalog:'
version: 4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.3.0)(typescript@5.9.3)
version: 4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(tailwindcss@4.3.0)(typescript@5.9.3)
eslint-plugin-import-x:
specifier: 'catalog:'
version: 4.16.2(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))
eslint-plugin-oxlint:
specifier: 'catalog:'
version: 1.59.0(oxlint@1.59.0(oxlint-tsgolint@0.20.0))
version: 1.69.0(oxlint@1.69.0(oxlint-tsgolint@0.23.0))
eslint-plugin-playwright:
specifier: 'catalog:'
version: 2.10.1(eslint@10.4.0(jiti@2.6.1))
@@ -777,13 +777,13 @@ importers:
version: 2.12.9
oxfmt:
specifier: 'catalog:'
version: 0.44.0
version: 0.54.0
oxlint:
specifier: 'catalog:'
version: 1.59.0(oxlint-tsgolint@0.20.0)
version: 1.69.0(oxlint-tsgolint@0.23.0)
oxlint-tsgolint:
specifier: 'catalog:'
version: 0.20.0
version: 0.23.0
picocolors:
specifier: 'catalog:'
version: 1.1.1
@@ -2751,276 +2751,276 @@ packages:
cpu: [x64]
os: [win32]
'@oxfmt/binding-android-arm-eabi@0.44.0':
resolution: {integrity: sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ==}
'@oxfmt/binding-android-arm-eabi@0.54.0':
resolution: {integrity: sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxfmt/binding-android-arm64@0.44.0':
resolution: {integrity: sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A==}
'@oxfmt/binding-android-arm64@0.54.0':
resolution: {integrity: sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxfmt/binding-darwin-arm64@0.44.0':
resolution: {integrity: sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg==}
'@oxfmt/binding-darwin-arm64@0.54.0':
resolution: {integrity: sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxfmt/binding-darwin-x64@0.44.0':
resolution: {integrity: sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ==}
'@oxfmt/binding-darwin-x64@0.54.0':
resolution: {integrity: sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxfmt/binding-freebsd-x64@0.44.0':
resolution: {integrity: sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ==}
'@oxfmt/binding-freebsd-x64@0.54.0':
resolution: {integrity: sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxfmt/binding-linux-arm-gnueabihf@0.44.0':
resolution: {integrity: sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg==}
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
resolution: {integrity: sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm-musleabihf@0.44.0':
resolution: {integrity: sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw==}
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
resolution: {integrity: sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm64-gnu@0.44.0':
resolution: {integrity: sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw==}
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
resolution: {integrity: sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.44.0':
resolution: {integrity: sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg==}
'@oxfmt/binding-linux-arm64-musl@0.54.0':
resolution: {integrity: sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.44.0':
resolution: {integrity: sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A==}
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
resolution: {integrity: sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.44.0':
resolution: {integrity: sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ==}
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
resolution: {integrity: sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.44.0':
resolution: {integrity: sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw==}
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
resolution: {integrity: sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.44.0':
resolution: {integrity: sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA==}
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
resolution: {integrity: sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.44.0':
resolution: {integrity: sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA==}
'@oxfmt/binding-linux-x64-gnu@0.54.0':
resolution: {integrity: sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.44.0':
resolution: {integrity: sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A==}
'@oxfmt/binding-linux-x64-musl@0.54.0':
resolution: {integrity: sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.44.0':
resolution: {integrity: sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw==}
'@oxfmt/binding-openharmony-arm64@0.54.0':
resolution: {integrity: sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxfmt/binding-win32-arm64-msvc@0.44.0':
resolution: {integrity: sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg==}
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
resolution: {integrity: sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxfmt/binding-win32-ia32-msvc@0.44.0':
resolution: {integrity: sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ==}
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
resolution: {integrity: sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxfmt/binding-win32-x64-msvc@0.44.0':
resolution: {integrity: sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ==}
'@oxfmt/binding-win32-x64-msvc@0.54.0':
resolution: {integrity: sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.20.0':
resolution: {integrity: sha512-KKQcIHZHMxqpHUA1VXIbOG6chNCFkUWbQy6M+AFVtPKkA/3xAeJkJ3njoV66bfzwPHRcWQO+kcj5XqtbkjakoA==}
'@oxlint-tsgolint/darwin-arm64@0.23.0':
resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.20.0':
resolution: {integrity: sha512-7HeVMuclGfG+NLZi2ybY0T4fMI7/XxO/208rJk+zEIloKkVnlh11Wd241JMGwgNFXn+MLJbOqOfojDb2Dt4L1g==}
'@oxlint-tsgolint/darwin-x64@0.23.0':
resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.20.0':
resolution: {integrity: sha512-zxhUwz+WSxE6oWlZLK2z2ps9yC6ebmgoYmjAl0Oa48+GqkZ56NVgo+wb8DURNv6xrggzHStQxqQxe3mK51HZag==}
'@oxlint-tsgolint/linux-arm64@0.23.0':
resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.20.0':
resolution: {integrity: sha512-/1l6FnahC9im8PK+Ekkx/V3yetO/PzZnJegE2FXcv/iXEhbeVxP/ouiTYcUQu9shT1FWJCSNti1VJHH+21Y1dg==}
'@oxlint-tsgolint/linux-x64@0.23.0':
resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.20.0':
resolution: {integrity: sha512-oPZ5Yz8sVdo7P/5q+i3IKeix31eFZ55JAPa1+RGPoe9PoaYVsdMvR6Jvib6YtrqoJnFPlg3fjEjlEPL8VBKYJA==}
'@oxlint-tsgolint/win32-arm64@0.23.0':
resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.20.0':
resolution: {integrity: sha512-4stx8RHj3SP9vQyRF/yZbz5igtPvYMEUR8CUoha4BVNZihi39DpCR8qkU7lpjB5Ga1DRMo2pHaA4bdTOMaY4mw==}
'@oxlint-tsgolint/win32-x64@0.23.0':
resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.59.0':
resolution: {integrity: sha512-etYDw/UaEv936AQUd/CRMBVd+e+XuuU6wC+VzOv1STvsTyZenLChepLWqLtnyTTp4YMlM22ypzogDDwqYxv5cg==}
'@oxlint/binding-android-arm-eabi@1.69.0':
resolution: {integrity: sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.59.0':
resolution: {integrity: sha512-TgLc7XVLKH2a4h8j3vn1MDjfK33i9MY60f/bKhRGWyVzbk5LCZ4X01VZG7iHrMmi5vYbAp8//Ponigx03CLsdw==}
'@oxlint/binding-android-arm64@1.69.0':
resolution: {integrity: sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.59.0':
resolution: {integrity: sha512-DXyFPf5ZKldMLloRHx/B9fsxsiTQomaw7cmEW3YIJko2HgCh+GUhp9gGYwHrqlLJPsEe3dYj9JebjX92D3j3AA==}
'@oxlint/binding-darwin-arm64@1.69.0':
resolution: {integrity: sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.59.0':
resolution: {integrity: sha512-LgvrsdgVLX1qWqIEmNsSmMXJhpAWdtUQ0M+oR0CySwi+9IHWyOGuIL8w8+u/kbZNMyZr4WUyYB5i0+D+AKgkLg==}
'@oxlint/binding-darwin-x64@1.69.0':
resolution: {integrity: sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.59.0':
resolution: {integrity: sha512-bOJhqX/ny4hrFuTPlyk8foSRx/vLRpxJh0jOOKN2NWW6FScXHPAA5rQbrwdQPcgGB5V8Ua51RS03fke8ssBcug==}
'@oxlint/binding-freebsd-x64@1.69.0':
resolution: {integrity: sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.59.0':
resolution: {integrity: sha512-vVUXxYMF9trXCsz4m9H6U0IjehosVHxBzVgJUxly1uz4W1PdDyicaBnpC0KRXsHYretLVe+uS9pJy8iM57Kujw==}
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
resolution: {integrity: sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.59.0':
resolution: {integrity: sha512-TULQW8YBPGRWg5yZpFPL54HLOnJ3/HiX6VenDPi6YfxB/jlItwSMFh3/hCeSNbh+DAMaE1Py0j5MOaivHkI/9Q==}
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
resolution: {integrity: sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.59.0':
resolution: {integrity: sha512-Gt54Y4eqSgYJ90xipm24xeyaPV854706o/kiT8oZvUt3VDY7qqxdqyGqchMaujd87ib+/MXvnl9WkK8Cc1BExg==}
'@oxlint/binding-linux-arm64-gnu@1.69.0':
resolution: {integrity: sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.59.0':
resolution: {integrity: sha512-3CtsKp7NFB3OfqQzbuAecrY7GIZeiv7AD+xutU4tefVQzlfmTI7/ygWLrvkzsDEjTlMq41rYHxgsn6Yh8tybmA==}
'@oxlint/binding-linux-arm64-musl@1.69.0':
resolution: {integrity: sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.59.0':
resolution: {integrity: sha512-K0diOpT3ncDmOfl9I1HuvpEsAuTxkts0VYwIv/w6Xiy9CdwyPBVX88Ga9l8VlGgMrwBMnSY4xIvVlVY/fkQk7Q==}
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
resolution: {integrity: sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.59.0':
resolution: {integrity: sha512-xAU7+QDU6kTJJ7mJLOGgo7oOjtAtkKyFZ0Yjdb5cEo3DiCCPFLvyr08rWiQh6evZ7RiUTf+o65NY/bqttzJiQQ==}
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
resolution: {integrity: sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.59.0':
resolution: {integrity: sha512-KUmZmKlTTyauOnvUNVxK7G40sSSx0+w5l1UhaGsC6KPpOYHenx2oqJTnabmpLJicok7IC+3Y6fXAUOMyexaeJQ==}
'@oxlint/binding-linux-riscv64-musl@1.69.0':
resolution: {integrity: sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.59.0':
resolution: {integrity: sha512-4usRxC8gS0PGdkHnRmwJt/4zrQNZyk6vL0trCxwZSsAKM+OxhB8nKiR+mhjdBbl8lbMh2gc3bZpNN/ik8c4c2A==}
'@oxlint/binding-linux-s390x-gnu@1.69.0':
resolution: {integrity: sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.59.0':
resolution: {integrity: sha512-s/rNE2gDmbwAOOP493xk2X7M8LZfI1LJFSSW1+yanz3vuQCFPiHkx4GY+O1HuLUDtkzGlhtMrIcxxzyYLv308w==}
'@oxlint/binding-linux-x64-gnu@1.69.0':
resolution: {integrity: sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.59.0':
resolution: {integrity: sha512-+yYj1udJa2UvvIUmEm0IcKgc0UlPMgz0nsSTvkPL2y6n0uU5LgIHSwVu4AHhrve6j9BpVSoRksnz8c9QcvITJA==}
'@oxlint/binding-linux-x64-musl@1.69.0':
resolution: {integrity: sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.59.0':
resolution: {integrity: sha512-bUplUb48LYsB3hHlQXP2ZMOenpieWoOyppLAnnAhuPag3MGPnt+7caxE3w/Vl9wpQsTA3gzLntQi9rxWrs7Xqg==}
'@oxlint/binding-openharmony-arm64@1.69.0':
resolution: {integrity: sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.59.0':
resolution: {integrity: sha512-/HLsLuz42rWl7h7ePdmMTpHm2HIDmPtcEMYgm5BBEHiEiuNOrzMaUpd2z7UnNni5LGN9obJy2YoAYBLXQwazrA==}
'@oxlint/binding-win32-arm64-msvc@1.69.0':
resolution: {integrity: sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.59.0':
resolution: {integrity: sha512-rUPy+JnanpPwV/aJCPnxAD1fW50+XPI0VkWr7f0vEbqcdsS8NpB24Rw6RsS7SdpFv8Dw+8ugCwao5nCFbqOUSg==}
'@oxlint/binding-win32-ia32-msvc@1.69.0':
resolution: {integrity: sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.59.0':
resolution: {integrity: sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA==}
'@oxlint/binding-win32-x64-msvc@1.69.0':
resolution: {integrity: sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -5395,10 +5395,10 @@ packages:
eslint-import-resolver-node:
optional: true
eslint-plugin-oxlint@1.59.0:
resolution: {integrity: sha512-g0DR+xSsnUdyaMc2KAXvBVGWz5V4GwlAE1PM+ocKxl2Eg7YgOjkRLLbxgJ3bhYOhRLhD8F0X4DjJu2FSDvrvAg==}
eslint-plugin-oxlint@1.69.0:
resolution: {integrity: sha512-ryJT8Pqb3jgWhmQcKA/D98K6UckthAR70wPTBI4rOjcaKJ9nmQkysTLbTVVEcdzfT9mznV/2MKspBsCCpXm36w==}
peerDependencies:
oxlint: ~1.59.0
oxlint: ~1.69.0
eslint-plugin-playwright@2.10.1:
resolution: {integrity: sha512-qea3UxBOb8fTwJ77FMApZKvRye5DOluDHcev0LDJwID3RELeun0JlqzrNIXAB/SXCyB/AesCW/6sZfcT9q3Edg==}
@@ -7017,24 +7017,35 @@ packages:
oxc-resolver@11.20.0:
resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==}
oxfmt@0.44.0:
resolution: {integrity: sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
oxlint-tsgolint@0.20.0:
resolution: {integrity: sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ==}
hasBin: true
oxlint@1.59.0:
resolution: {integrity: sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw==}
oxfmt@0.54.0:
resolution: {integrity: sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.18.0'
svelte: ^5.0.0
vite-plus: '*'
peerDependenciesMeta:
svelte:
optional: true
vite-plus:
optional: true
oxlint-tsgolint@0.23.0:
resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==}
hasBin: true
oxlint@1.69.0:
resolution: {integrity: sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.22.1'
vite-plus: '*'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
vite-plus:
optional: true
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
@@ -8629,8 +8640,8 @@ packages:
vue-component-type-helpers@3.3.2:
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
vue-component-type-helpers@3.3.3:
resolution: {integrity: sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==}
vue-component-type-helpers@3.3.4:
resolution: {integrity: sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -10739,136 +10750,136 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.20.0':
optional: true
'@oxfmt/binding-android-arm-eabi@0.44.0':
'@oxfmt/binding-android-arm-eabi@0.54.0':
optional: true
'@oxfmt/binding-android-arm64@0.44.0':
'@oxfmt/binding-android-arm64@0.54.0':
optional: true
'@oxfmt/binding-darwin-arm64@0.44.0':
'@oxfmt/binding-darwin-arm64@0.54.0':
optional: true
'@oxfmt/binding-darwin-x64@0.44.0':
'@oxfmt/binding-darwin-x64@0.54.0':
optional: true
'@oxfmt/binding-freebsd-x64@0.44.0':
'@oxfmt/binding-freebsd-x64@0.54.0':
optional: true
'@oxfmt/binding-linux-arm-gnueabihf@0.44.0':
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
optional: true
'@oxfmt/binding-linux-arm-musleabihf@0.44.0':
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
optional: true
'@oxfmt/binding-linux-arm64-gnu@0.44.0':
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-arm64-musl@0.44.0':
'@oxfmt/binding-linux-arm64-musl@0.54.0':
optional: true
'@oxfmt/binding-linux-ppc64-gnu@0.44.0':
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-riscv64-gnu@0.44.0':
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-riscv64-musl@0.44.0':
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
optional: true
'@oxfmt/binding-linux-s390x-gnu@0.44.0':
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-x64-gnu@0.44.0':
'@oxfmt/binding-linux-x64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-x64-musl@0.44.0':
'@oxfmt/binding-linux-x64-musl@0.54.0':
optional: true
'@oxfmt/binding-openharmony-arm64@0.44.0':
'@oxfmt/binding-openharmony-arm64@0.54.0':
optional: true
'@oxfmt/binding-win32-arm64-msvc@0.44.0':
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
optional: true
'@oxfmt/binding-win32-ia32-msvc@0.44.0':
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
optional: true
'@oxfmt/binding-win32-x64-msvc@0.44.0':
'@oxfmt/binding-win32-x64-msvc@0.54.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.20.0':
'@oxlint-tsgolint/darwin-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/darwin-x64@0.20.0':
'@oxlint-tsgolint/darwin-x64@0.23.0':
optional: true
'@oxlint-tsgolint/linux-arm64@0.20.0':
'@oxlint-tsgolint/linux-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/linux-x64@0.20.0':
'@oxlint-tsgolint/linux-x64@0.23.0':
optional: true
'@oxlint-tsgolint/win32-arm64@0.20.0':
'@oxlint-tsgolint/win32-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/win32-x64@0.20.0':
'@oxlint-tsgolint/win32-x64@0.23.0':
optional: true
'@oxlint/binding-android-arm-eabi@1.59.0':
'@oxlint/binding-android-arm-eabi@1.69.0':
optional: true
'@oxlint/binding-android-arm64@1.59.0':
'@oxlint/binding-android-arm64@1.69.0':
optional: true
'@oxlint/binding-darwin-arm64@1.59.0':
'@oxlint/binding-darwin-arm64@1.69.0':
optional: true
'@oxlint/binding-darwin-x64@1.59.0':
'@oxlint/binding-darwin-x64@1.69.0':
optional: true
'@oxlint/binding-freebsd-x64@1.59.0':
'@oxlint/binding-freebsd-x64@1.69.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.59.0':
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.59.0':
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.59.0':
'@oxlint/binding-linux-arm64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.59.0':
'@oxlint/binding-linux-arm64-musl@1.69.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.59.0':
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.59.0':
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.59.0':
'@oxlint/binding-linux-riscv64-musl@1.69.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.59.0':
'@oxlint/binding-linux-s390x-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.59.0':
'@oxlint/binding-linux-x64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.59.0':
'@oxlint/binding-linux-x64-musl@1.69.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.59.0':
'@oxlint/binding-openharmony-arm64@1.69.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.59.0':
'@oxlint/binding-win32-arm64-msvc@1.69.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.59.0':
'@oxlint/binding-win32-ia32-msvc@1.69.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.59.0':
'@oxlint/binding-win32-x64-msvc@1.69.0':
optional: true
'@package-json/types@0.0.12': {}
@@ -11312,7 +11323,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.34(typescript@5.9.3)
vue-component-type-helpers: 3.3.3
vue-component-type-helpers: 3.3.4
'@swc/helpers@0.5.21':
dependencies:
@@ -13448,7 +13459,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-better-tailwindcss@4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.3.0)(typescript@5.9.3):
eslint-plugin-better-tailwindcss@4.3.1(eslint@10.4.0(jiti@2.6.1))(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(tailwindcss@4.3.0)(typescript@5.9.3):
dependencies:
'@eslint/css-tree': 3.6.9
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
@@ -13461,7 +13472,7 @@ snapshots:
valibot: 1.2.0(typescript@5.9.3)
optionalDependencies:
eslint: 10.4.0(jiti@2.6.1)
oxlint: 1.59.0(oxlint-tsgolint@0.20.0)
oxlint: 1.69.0(oxlint-tsgolint@0.23.0)
transitivePeerDependencies:
- typescript
@@ -13483,10 +13494,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-oxlint@1.59.0(oxlint@1.59.0(oxlint-tsgolint@0.20.0)):
eslint-plugin-oxlint@1.69.0(oxlint@1.69.0(oxlint-tsgolint@0.23.0)):
dependencies:
jsonc-parser: 3.3.1
oxlint: 1.59.0(oxlint-tsgolint@0.20.0)
oxlint: 1.69.0(oxlint-tsgolint@0.23.0)
eslint-plugin-playwright@2.10.1(eslint@10.4.0(jiti@2.6.1)):
dependencies:
@@ -15453,61 +15464,61 @@ snapshots:
'@oxc-resolver/binding-win32-arm64-msvc': 11.20.0
'@oxc-resolver/binding-win32-x64-msvc': 11.20.0
oxfmt@0.44.0:
oxfmt@0.54.0:
dependencies:
tinypool: 2.1.0
optionalDependencies:
'@oxfmt/binding-android-arm-eabi': 0.44.0
'@oxfmt/binding-android-arm64': 0.44.0
'@oxfmt/binding-darwin-arm64': 0.44.0
'@oxfmt/binding-darwin-x64': 0.44.0
'@oxfmt/binding-freebsd-x64': 0.44.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.44.0
'@oxfmt/binding-linux-arm-musleabihf': 0.44.0
'@oxfmt/binding-linux-arm64-gnu': 0.44.0
'@oxfmt/binding-linux-arm64-musl': 0.44.0
'@oxfmt/binding-linux-ppc64-gnu': 0.44.0
'@oxfmt/binding-linux-riscv64-gnu': 0.44.0
'@oxfmt/binding-linux-riscv64-musl': 0.44.0
'@oxfmt/binding-linux-s390x-gnu': 0.44.0
'@oxfmt/binding-linux-x64-gnu': 0.44.0
'@oxfmt/binding-linux-x64-musl': 0.44.0
'@oxfmt/binding-openharmony-arm64': 0.44.0
'@oxfmt/binding-win32-arm64-msvc': 0.44.0
'@oxfmt/binding-win32-ia32-msvc': 0.44.0
'@oxfmt/binding-win32-x64-msvc': 0.44.0
'@oxfmt/binding-android-arm-eabi': 0.54.0
'@oxfmt/binding-android-arm64': 0.54.0
'@oxfmt/binding-darwin-arm64': 0.54.0
'@oxfmt/binding-darwin-x64': 0.54.0
'@oxfmt/binding-freebsd-x64': 0.54.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.54.0
'@oxfmt/binding-linux-arm-musleabihf': 0.54.0
'@oxfmt/binding-linux-arm64-gnu': 0.54.0
'@oxfmt/binding-linux-arm64-musl': 0.54.0
'@oxfmt/binding-linux-ppc64-gnu': 0.54.0
'@oxfmt/binding-linux-riscv64-gnu': 0.54.0
'@oxfmt/binding-linux-riscv64-musl': 0.54.0
'@oxfmt/binding-linux-s390x-gnu': 0.54.0
'@oxfmt/binding-linux-x64-gnu': 0.54.0
'@oxfmt/binding-linux-x64-musl': 0.54.0
'@oxfmt/binding-openharmony-arm64': 0.54.0
'@oxfmt/binding-win32-arm64-msvc': 0.54.0
'@oxfmt/binding-win32-ia32-msvc': 0.54.0
'@oxfmt/binding-win32-x64-msvc': 0.54.0
oxlint-tsgolint@0.20.0:
oxlint-tsgolint@0.23.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.20.0
'@oxlint-tsgolint/darwin-x64': 0.20.0
'@oxlint-tsgolint/linux-arm64': 0.20.0
'@oxlint-tsgolint/linux-x64': 0.20.0
'@oxlint-tsgolint/win32-arm64': 0.20.0
'@oxlint-tsgolint/win32-x64': 0.20.0
'@oxlint-tsgolint/darwin-arm64': 0.23.0
'@oxlint-tsgolint/darwin-x64': 0.23.0
'@oxlint-tsgolint/linux-arm64': 0.23.0
'@oxlint-tsgolint/linux-x64': 0.23.0
'@oxlint-tsgolint/win32-arm64': 0.23.0
'@oxlint-tsgolint/win32-x64': 0.23.0
oxlint@1.59.0(oxlint-tsgolint@0.20.0):
oxlint@1.69.0(oxlint-tsgolint@0.23.0):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.59.0
'@oxlint/binding-android-arm64': 1.59.0
'@oxlint/binding-darwin-arm64': 1.59.0
'@oxlint/binding-darwin-x64': 1.59.0
'@oxlint/binding-freebsd-x64': 1.59.0
'@oxlint/binding-linux-arm-gnueabihf': 1.59.0
'@oxlint/binding-linux-arm-musleabihf': 1.59.0
'@oxlint/binding-linux-arm64-gnu': 1.59.0
'@oxlint/binding-linux-arm64-musl': 1.59.0
'@oxlint/binding-linux-ppc64-gnu': 1.59.0
'@oxlint/binding-linux-riscv64-gnu': 1.59.0
'@oxlint/binding-linux-riscv64-musl': 1.59.0
'@oxlint/binding-linux-s390x-gnu': 1.59.0
'@oxlint/binding-linux-x64-gnu': 1.59.0
'@oxlint/binding-linux-x64-musl': 1.59.0
'@oxlint/binding-openharmony-arm64': 1.59.0
'@oxlint/binding-win32-arm64-msvc': 1.59.0
'@oxlint/binding-win32-ia32-msvc': 1.59.0
'@oxlint/binding-win32-x64-msvc': 1.59.0
oxlint-tsgolint: 0.20.0
'@oxlint/binding-android-arm-eabi': 1.69.0
'@oxlint/binding-android-arm64': 1.69.0
'@oxlint/binding-darwin-arm64': 1.69.0
'@oxlint/binding-darwin-x64': 1.69.0
'@oxlint/binding-freebsd-x64': 1.69.0
'@oxlint/binding-linux-arm-gnueabihf': 1.69.0
'@oxlint/binding-linux-arm-musleabihf': 1.69.0
'@oxlint/binding-linux-arm64-gnu': 1.69.0
'@oxlint/binding-linux-arm64-musl': 1.69.0
'@oxlint/binding-linux-ppc64-gnu': 1.69.0
'@oxlint/binding-linux-riscv64-gnu': 1.69.0
'@oxlint/binding-linux-riscv64-musl': 1.69.0
'@oxlint/binding-linux-s390x-gnu': 1.69.0
'@oxlint/binding-linux-x64-gnu': 1.69.0
'@oxlint/binding-linux-x64-musl': 1.69.0
'@oxlint/binding-openharmony-arm64': 1.69.0
'@oxlint/binding-win32-arm64-msvc': 1.69.0
'@oxlint/binding-win32-ia32-msvc': 1.69.0
'@oxlint/binding-win32-x64-msvc': 1.69.0
oxlint-tsgolint: 0.23.0
p-limit@3.1.0:
dependencies:
@@ -17458,7 +17469,7 @@ snapshots:
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.3: {}
vue-component-type-helpers@3.3.4: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:

View File

@@ -79,7 +79,7 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.2
eslint-plugin-oxlint: 1.59.0
eslint-plugin-oxlint: 1.69.0
eslint-plugin-playwright: ^2.10.1
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
@@ -101,9 +101,9 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
monocart-coverage-reports: ^2.12.9
oxfmt: ^0.44.0
oxlint: ^1.59.0
oxlint-tsgolint: ^0.20.0
oxfmt: ^0.54.0
oxlint: ^1.69.0
oxlint-tsgolint: ^0.23.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -87,6 +87,14 @@ vi.mock('@/scripts/app', () => ({
}
}))
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: mockTrackUiButtonClicked
})
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
@@ -110,6 +118,9 @@ function createWrapper({
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
}
},
rightSidePanel: {
togglePanel: 'Toggle properties panel'
}
}
}
@@ -266,6 +277,19 @@ describe('TopMenuSection', () => {
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
})
it('tracks right side panel opens', async () => {
const { user } = createWrapper()
await user.click(
screen.getByRole('button', { name: 'Toggle properties panel' })
)
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)

View File

@@ -78,7 +78,7 @@
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
@@ -148,6 +148,7 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
@@ -282,6 +283,14 @@ const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
function openRightSidePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
rightSidePanelStore.togglePanel()
}
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)

View File

@@ -222,7 +222,8 @@ watch(visible, async (newVisible) => {
*/
useEventListener(dragHandleRef, 'mousedown', () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'actionbar_run_handle_drag_start'
button_id: 'actionbar_run_handle_drag_start',
element_group: 'actionbar'
})
})

View File

@@ -131,7 +131,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.onChangeTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_on_change_selected'
button_id: 'queue_mode_option_run_on_change_selected',
element_group: 'queue'
})
queueMode.value = 'change'
}
@@ -145,7 +146,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected'
button_id: 'queue_mode_option_run_instant_selected',
element_group: 'queue'
})
queueMode.value = 'instant-idle'
}
@@ -237,7 +239,8 @@ const queuePrompt = async (e: Event) => {
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted'
button_id: 'queue_run_multiple_batches_submitted',
element_group: 'queue'
})
}

View File

@@ -88,7 +88,8 @@ const home = computed(() => ({
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected'
button_id: 'breadcrumb_subgraph_root_selected',
element_group: 'breadcrumb'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -103,7 +104,8 @@ const items = computed(() => {
key: `subgraph-${subgraph.id}`,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_item_selected'
button_id: 'breadcrumb_subgraph_item_selected',
element_group: 'breadcrumb'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')

View File

@@ -33,7 +33,6 @@ const {
items,
gridStyle,
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemHeight = 200,
defaultItemWidth = 200,
@@ -42,7 +41,6 @@ const {
items: (T & { key: string })[]
gridStyle: CSSProperties
bufferRows?: number
scrollThrottle?: number
resizeDebounce?: number
defaultItemHeight?: number
defaultItemWidth?: number
@@ -61,7 +59,6 @@ const itemWidth = ref(defaultItemWidth)
const container = ref<HTMLElement | null>(null)
const { width, height } = useElementSize(container)
const { y: scrollY } = useScroll(container, {
throttle: scrollThrottle,
eventListenerOptions: { passive: true }
})

View File

@@ -40,7 +40,8 @@ function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source
button_id: source,
element_group: 'workflow_actions'
})
}
}

View File

@@ -101,7 +101,8 @@ const reportOpen = ref(false)
*/
const showReport = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_show_report_clicked'
button_id: 'error_dialog_show_report_clicked',
element_group: 'error_dialog'
})
reportOpen.value = true
}

View File

@@ -25,7 +25,8 @@ const queryString = computed(() => props.errorMessage + ' is:issue')
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
button_id: 'error_dialog_find_existing_issues_clicked',
element_group: 'error_dialog'
})
const query = encodeURIComponent(queryString.value)
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`

View File

@@ -218,7 +218,8 @@ onMounted(() => {
*/
const onMinimapToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_minimap_toggle_clicked'
button_id: 'graph_menu_minimap_toggle_clicked',
element_group: 'graph_menu'
})
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
}
@@ -228,7 +229,8 @@ const onMinimapToggleClick = () => {
*/
const onLinkVisibilityToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_hide_links_toggle_clicked'
button_id: 'graph_menu_hide_links_toggle_clicked',
element_group: 'graph_menu'
})
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
}

View File

@@ -101,6 +101,7 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const {
hasAnySelection,
hasGroupedNodesSelection,
hasMultipleSelection,
isSingleNode,
isSingleSubgraph,
@@ -118,7 +119,10 @@ const showSubgraphButtons = computed(() => isSingleSubgraph.value)
const showBypass = computed(
() =>
isSingleNode.value || isSingleSubgraph.value || hasMultipleSelection.value
isSingleNode.value ||
isSingleSubgraph.value ||
hasMultipleSelection.value ||
hasGroupedNodesSelection.value
)
const showLoad3DViewer = computed(() => hasAny3DNodeSelected.value)
const showMaskEditor = computed(() => isSingleImageNode.value)

View File

@@ -65,7 +65,8 @@ describe('InfoButton', () => {
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
button_id: 'selection_toolbox_node_info_opened'
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
})
})

View File

@@ -24,7 +24,8 @@ const onInfoClick = () => {
if (!openNodeInfo()) return
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened'
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
})
}
</script>

View File

@@ -23,8 +23,11 @@
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:can-use-hdri="canUseHdri"
:can-use-background-image="canUseBackgroundImage"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
:source-format="sourceFormat"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
@update-hdri-file="handleHDRIFileUpdate"
@@ -86,7 +89,7 @@
/>
<RecordingControls
v-if="!isPreview"
v-if="canUseRecording && !isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -117,9 +120,18 @@ import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
const {
widget,
nodeId,
canUseRecording = true,
canUseHdri = true,
canUseBackgroundImage = true
} = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
canUseRecording?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
}>()
function isComponentWidget(
@@ -130,11 +142,11 @@ function isComponentWidget(
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
onMounted(() => {
node.value = resolveNode(props.nodeId!) ?? null
node.value = resolveNode(nodeId) ?? null
})
}
@@ -155,6 +167,7 @@ const {
canExport,
materialModes,
hasSkeleton,
sourceFormat,
hasRecording,
recordingDuration,
animations,

View File

@@ -0,0 +1,47 @@
import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
const lastProps = ref<Record<string, unknown> | null>(null)
vi.mock('@/components/load3d/Load3D.vue', () => ({
default: defineComponent({
name: 'Load3D',
props: {
widget: { type: null, required: false, default: undefined },
nodeId: { type: null, required: false, default: undefined },
canUseRecording: { type: Boolean, default: true },
canUseHdri: { type: Boolean, default: true },
canUseBackgroundImage: { type: Boolean, default: true }
},
setup(props: Record<string, unknown>) {
lastProps.value = { ...props }
return () => h('div', { 'data-testid': 'load3d-stub' })
}
})
}))
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
describe('Load3DAdvanced', () => {
it('renders the inner Load3D with all expressive features disabled', () => {
const MOCK_NODE = { id: 'node', type: 'Load3DAdvanced' }
render(Load3DAdvanced, {
props: {
widget: { node: MOCK_NODE } as never
}
})
expect(lastProps.value).toMatchObject({
canUseRecording: false,
canUseHdri: false,
canUseBackgroundImage: false
})
})
it('forwards widget and nodeId to the inner Load3D', () => {
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
expect(lastProps.value?.widget).toEqual(widget)
expect(lastProps.value?.nodeId).toBe('a')
})
})

View File

@@ -0,0 +1,21 @@
<template>
<Load3D
:widget="widget"
:node-id="nodeId"
:can-use-recording="false"
:can-use-hdri="false"
:can-use-background-image="false"
/>
</template>
<script setup lang="ts">
import Load3D from '@/components/load3d/Load3D.vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
</script>

View File

@@ -52,6 +52,7 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:show-background-image="canUseBackgroundImage"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@@ -81,6 +82,7 @@
/>
<HDRIControls
v-if="canUseHdri"
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
@@ -89,6 +91,7 @@
<ExportControls
v-if="showExportControls"
:source-format="sourceFormat"
@export-model="handleExportModel"
/>
@@ -129,14 +132,20 @@ const {
canUseGizmo = true,
canUseLighting = true,
canExport = true,
canUseHdri = true,
canUseBackgroundImage = true,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false
hasSkeleton = false,
sourceFormat = null
} = defineProps<{
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
sourceFormat?: string | null
}>()
const sceneConfig = defineModel<SceneConfig>('sceneConfig')

View File

@@ -59,6 +59,7 @@ function buildViewerStub() {
canUseGizmo: ref(true),
canUseLighting: ref(true),
canExport: ref(true),
sourceFormat: ref<string | null>(null),
materialModes: ref(['original', 'normal', 'wireframe']),
animations: ref<Array<{ name: string; index: number }>>([]),
playing: ref(false),

View File

@@ -82,7 +82,10 @@
</div>
<div v-if="viewer.canExport.value" class="space-y-4 p-2">
<ExportControls @export-model="viewer.exportModel" />
<ExportControls
:source-format="viewer.sourceFormat.value"
@export-model="viewer.exportModel"
/>
</div>
</div>
</div>

View File

@@ -13,9 +13,12 @@ const i18n = createI18n({
}
})
function renderComponent(onExportModel?: (format: string) => void) {
function renderComponent(
onExportModel?: (format: string) => void,
sourceFormat: string | null = null
) {
const utils = render(ExportControls, {
props: { onExportModel },
props: { onExportModel, sourceFormat },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
@@ -63,6 +66,23 @@ describe('ExportControls', () => {
).not.toBeInTheDocument()
})
it('offers only the source format for direct-export files (e.g. ply)', async () => {
const onExportModel = vi.fn()
const { user } = renderComponent(onExportModel, 'ply')
await user.click(screen.getByRole('button', { name: 'Export model' }))
expect(screen.getByRole('button', { name: 'PLY' })).toBeVisible()
for (const label of ['GLB', 'OBJ', 'STL', 'FBX']) {
expect(
screen.queryByRole('button', { name: label })
).not.toBeInTheDocument()
}
await user.click(screen.getByRole('button', { name: 'PLY' }))
expect(onExportModel).toHaveBeenCalledWith('ply')
})
it('hides the popup when a click happens outside the trigger', async () => {
const { user } = renderComponent()

View File

@@ -35,9 +35,14 @@
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
const { sourceFormat = null } = defineProps<{
sourceFormat?: string | null
}>()
const emit = defineEmits<{
(e: 'exportModel', format: string): void
@@ -45,12 +50,7 @@ const emit = defineEmits<{
const showExportFormats = ref(false)
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' },
{ label: 'FBX', value: 'fbx' }
]
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
function toggleExportFormats() {
showExportFormats.value = !showExportFormats.value

View File

@@ -37,7 +37,7 @@
</Button>
</div>
<div v-if="!hasBackgroundImage">
<div v-if="showBackgroundImage && !hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
@@ -61,7 +61,7 @@
</div>
</template>
<div v-if="hasBackgroundImage">
<div v-if="showBackgroundImage && hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.panoramaMode'),
@@ -83,12 +83,16 @@
</div>
<PopupSlider
v-if="hasBackgroundImage && backgroundRenderMode === 'panorama'"
v-if="
showBackgroundImage &&
hasBackgroundImage &&
backgroundRenderMode === 'panorama'
"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<div v-if="hasBackgroundImage">
<div v-if="showBackgroundImage && hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.removeBackgroundImage'),
@@ -114,8 +118,9 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const { hdriActive = false } = defineProps<{
const { hdriActive = false, showBackgroundImage = true } = defineProps<{
hdriActive?: boolean
showBackgroundImage?: boolean
}>()
const emit = defineEmits<{

View File

@@ -72,9 +72,12 @@ const i18n = createI18n({
messages: { en: { load3d: { export: 'Export' } } }
})
function renderComponent(onExportModel?: (format: string) => void) {
function renderComponent(
onExportModel?: (format: string) => void,
sourceFormat: string | null = null
) {
const utils = render(ViewerExportControls, {
props: { onExportModel },
props: { onExportModel, sourceFormat },
global: { plugins: [i18n] }
})
return { ...utils, user: userEvent.setup() }
@@ -114,4 +117,32 @@ describe('ViewerExportControls', () => {
expect(onExportModel).toHaveBeenCalledWith('glb')
})
it('offers only the source format for direct-export files (e.g. spz)', async () => {
const onExportModel = vi.fn()
const { user } = renderComponent(onExportModel, 'spz')
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(Array.from(select.options).map((o) => o.value)).toEqual(['spz'])
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(onExportModel).toHaveBeenCalledWith('spz')
})
it('repairs the selected format when sourceFormat switches to a direct-export type', async () => {
const onExportModel = vi.fn()
const { user, rerender } = renderComponent(onExportModel, null)
const select = screen.getByRole('combobox') as HTMLSelectElement
expect(select.value).toBe('obj')
await rerender({ onExportModel, sourceFormat: 'ply' })
expect(Array.from(select.options).map((o) => o.value)).toEqual(['ply'])
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(onExportModel).toHaveBeenCalledWith('ply')
})
})

View File

@@ -26,7 +26,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
@@ -34,20 +34,30 @@ import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
const { sourceFormat = null } = defineProps<{
sourceFormat?: string | null
}>()
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' },
{ label: 'FBX', value: 'fbx' }
]
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
const exportFormat = ref('obj')
watch(
exportFormats,
(formats) => {
if (!formats.some((fmt) => fmt.value === exportFormat.value)) {
exportFormat.value = formats[0]?.value ?? ''
}
},
{ immediate: true }
)
const exportModel = (format: string) => {
emit('exportModel', format)
}

View File

@@ -14,6 +14,7 @@ import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
@@ -106,6 +107,10 @@ const isSingleSubgraphNode = computed(() => {
})
function closePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_closed',
element_group: 'right_side_panel'
})
rightSidePanelStore.closePanel()
}

View File

@@ -530,14 +530,16 @@ describe('TabErrors.vue', () => {
expect(
screen.getByText('Some nodes can be replaced with alternatives')
).toBeInTheDocument()
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'OldSampler' })
).toBeInTheDocument()
expect(screen.getByText('KSampler')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Replace Node/ })
).toBeInTheDocument()
})
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
it('renders missing model Refresh in the header and Download all in the card when models are downloadable', () => {
const missingModel = {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
@@ -555,11 +557,8 @@ describe('TabErrors.vue', () => {
}
})
expect(
screen.queryByTestId('missing-model-header-refresh')
).not.toBeInTheDocument()
expect(screen.getByTestId('missing-model-header-refresh')).toBeVisible()
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
})
})

View File

@@ -94,9 +94,10 @@
showMissingModelHeaderRefresh
"
data-testid="missing-model-header-refresh"
variant="secondary"
size="sm"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
variant="muted-textonly"
size="icon"
class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingModels.refresh')"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click.stop="handleMissingModelRefresh"
@@ -112,7 +113,6 @@
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
{{ t('rightSidePanel.missingModels.refresh') }}
</Button>
<span
v-if="
@@ -157,7 +157,6 @@
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
@@ -247,7 +246,6 @@
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateAssetNode"
/>
@@ -302,11 +300,9 @@ import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
@@ -320,7 +316,6 @@ import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCar
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -348,7 +343,6 @@ const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
@@ -372,12 +366,6 @@ function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
}
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
NodeBadgeMode.None
)
function isExecutionItemListGroup(group: ErrorGroup) {
return (
group.type === 'execution' &&
@@ -464,20 +452,13 @@ const {
swapNodeGroups
} = useErrorGroups(searchQuery)
const missingModelDownloadableModels = computed(() => {
if (isCloud) return []
return getDownloadableModels(missingModelGroups.value)
})
const showMissingModelHeaderRefresh = computed(
() =>
!isCloud &&
missingModelGroups.value.length > 0 &&
missingModelDownloadableModels.value.length === 0
() => !isCloud && missingModelGroups.value.length > 0
)
function handleMissingModelRefresh() {
if (missingModelStore.isRefreshingMissingModels) return
void missingModelStore.refreshMissingModels()
}

View File

@@ -58,7 +58,8 @@ describe('useErrorActions', () => {
openGitHubIssues()
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_github_issues_clicked'
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
})
expect(windowOpenSpy).toHaveBeenCalledWith(
mocks.staticUrls.githubIssues,
@@ -123,7 +124,8 @@ describe('useErrorActions', () => {
findOnGitHub('CUDA out of memory')
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_find_existing_issues_clicked'
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
})
const expectedQuery = encodeURIComponent('CUDA out of memory is:issue')
expect(windowOpenSpy).toHaveBeenCalledWith(

View File

@@ -9,7 +9,8 @@ export function useErrorActions() {
function openGitHubIssues() {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
@@ -25,7 +26,8 @@ export function useErrorActions() {
function findOnGitHub(errorMessage: string) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
})
const query = encodeURIComponent(errorMessage + ' is:issue')
window.open(

View File

@@ -5,12 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
@@ -74,8 +71,7 @@ describe('NodeSearchBoxPopover', () => {
const NodeSearchContentStub = defineComponent({
name: 'NodeSearchContent',
props: {
filters: { type: Array, default: () => [] },
defaultRootFilter: { type: String, default: null }
filters: { type: Array, default: () => [] }
},
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
setup(_, { emit }) {
@@ -83,8 +79,7 @@ describe('NodeSearchBoxPopover', () => {
emit('addNode', nodeDef, dragEvent)
return {}
},
template:
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
template: '<div data-testid="search-content-v2"></div>'
})
const pinia = createTestingPinia({
@@ -281,75 +276,4 @@ describe('NodeSearchBoxPopover', () => {
)
})
})
describe('defaultRootFilter on dialog open', () => {
function setGraphNodes(nodes: unknown[]) {
const canvasStore = useCanvasStore()
canvasStore.canvas = {
graph: { nodes },
allow_searchbox: false,
setDirty: vi.fn(),
linkConnector: {
events: new EventTarget(),
reset: vi.fn(),
disconnectLinks: vi.fn()
}
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
}
async function openSearch() {
useSearchBoxStore().visible = true
await nextTick()
}
it('defaults to Essentials when the graph is empty', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to Essentials when the canvas is not yet available', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to null when the graph has nodes', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
it('re-evaluates each time the dialog opens', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
useSearchBoxStore().visible = false
await nextTick()
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
})
})

View File

@@ -27,7 +27,6 @@
<div v-if="useSearchBoxV2" role="search" class="relative">
<NodeSearchContent
:filters="nodeFilters"
:default-root-filter="defaultRootFilter"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@@ -78,8 +77,6 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import NodeSearchContent from './v2/NodeSearchContent.vue'
import NodeSearchBox from './NodeSearchBox.vue'
@@ -91,7 +88,6 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const litegraphService = useLitegraphService()
const canvasStore = useCanvasStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
@@ -107,13 +103,6 @@ const enableNodePreview = computed(
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
)
const defaultRootFilter = ref<RootCategoryId | null>(null)
watch(visible, (isVisible) => {
if (!isVisible) return
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
? RootCategory.Essentials
: null
})
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
@@ -138,6 +127,7 @@ function clearFilters() {
function closeDialog() {
visible.value = false
}
const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')

View File

@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import {
createMockNodeDef,
setViewport,
@@ -231,48 +230,6 @@ describe('NodeSearchContent', () => {
})
})
it('should apply defaultRootFilter when provided and category is available', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Essential Node')
})
})
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'FrequentNode',
display_name: 'Frequent Node'
})
])
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['FrequentNode']
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Frequent Node')
})
})
it('should show only API nodes when Partner Nodes filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({

View File

@@ -142,9 +142,8 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
[RootCategory.Custom]: isCustomNode
}
const { filters, defaultRootFilter = null } = defineProps<{
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
defaultRootFilter?: RootCategoryId | null
}>()
const emit = defineEmits<{
@@ -195,12 +194,8 @@ function onSearchFocus() {
if (isMobile.value) isSidebarOpen.value = false
}
const rootFilter = ref<RootCategoryId | null>(
defaultRootFilter === RootCategory.Essentials &&
!nodeAvailability.value.essential
? null
: defaultRootFilter
)
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<RootCategoryId | null>(null)
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {

View File

@@ -150,7 +150,8 @@ const telemetry = useTelemetry()
function onLogoMenuClick(event: MouseEvent) {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_comfy_menu_opened'
button_id: 'sidebar_comfy_menu_opened',
element_group: 'sidebar'
})
menuRef.value?.toggle(event)
}
@@ -217,7 +218,8 @@ const extraMenuItems = computed(() => [
icon: 'icon-[lucide--settings]',
command: () => {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_settings_menu_opened'
button_id: 'sidebar_settings_menu_opened',
element_group: 'sidebar'
})
showSettings()
}
@@ -329,7 +331,8 @@ const handleNodes2ToggleClick = () => {
const onNodes2ToggleChange = async (value: boolean) => {
await settingStore.set('Comfy.VueNodes.Enabled', value)
telemetry?.trackUiButtonClicked({
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`,
element_group: 'sidebar'
})
}
</script>

View File

@@ -138,19 +138,23 @@ const onTabClick = async (item: SidebarTabExtension) => {
if (isNodeLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_node_library_selected'
button_id: 'sidebar_tab_node_library_selected',
element_group: 'sidebar'
})
else if (isModelLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_model_library_selected'
button_id: 'sidebar_tab_model_library_selected',
element_group: 'sidebar'
})
else if (isWorkflowsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_workflows_selected'
button_id: 'sidebar_tab_workflows_selected',
element_group: 'sidebar'
})
else if (isAssetsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_assets_media_selected'
button_id: 'sidebar_tab_assets_media_selected',
element_group: 'sidebar'
})
await commandStore.commands

View File

@@ -21,7 +21,8 @@ const bottomPanelStore = useBottomPanelStore()
*/
const toggleConsole = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_bottom_panel_console_toggled'
button_id: 'sidebar_bottom_panel_console_toggled',
element_group: 'sidebar'
})
bottomPanelStore.toggleBottomPanel()
}

View File

@@ -30,7 +30,8 @@ const tooltipText = computed(
const showSettingsDialog = () => {
command.function()
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked'
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
}
</script>

View File

@@ -37,7 +37,8 @@ const tooltipText = computed(
*/
const toggleShortcutsPanel = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_shortcuts_panel_toggled'
button_id: 'sidebar_shortcuts_panel_toggled',
element_group: 'sidebar'
})
bottomPanelStore.togglePanel('shortcuts')
}

View File

@@ -29,7 +29,8 @@ const isSmall = computed(
*/
const openTemplates = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_templates_dialog_opened'
button_id: 'sidebar_templates_dialog_opened',
element_group: 'sidebar'
})
useWorkflowTemplateSelectorDialog().show('sidebar')
}

View File

@@ -118,7 +118,8 @@ const toggleBookmark = async () => {
const onHelpClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'node_library_help_button'
button_id: 'node_library_help_button',
element_group: 'node_library'
})
props.openNodeHelp(nodeDef.value)
}

View File

@@ -2,6 +2,9 @@ import type { ComputedRef, Ref } from 'vue'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
BillingStatus,
BillingSubscriptionStatus,
CreateTopupResponse,
Plan,
PreviewSubscribeResponse,
SubscribeResponse,
@@ -16,7 +19,9 @@ export interface SubscriptionInfo {
tier: SubscriptionTier | null
duration: SubscriptionDuration | null
planSlug: string | null
/** ISO 8601 */
renewalDate: string | null
/** ISO 8601 */
endDate: string | null
isCancelled: boolean
hasFunds: boolean
@@ -44,6 +49,9 @@ export interface BillingActions {
) => Promise<PreviewSubscribeResponse | null>
manageSubscription: () => Promise<void>
cancelSubscription: () => Promise<void>
resubscribe: () => Promise<void>
/** `amountCents` must be a whole-dollar multiple of 100. */
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
fetchPlans: () => Promise<void>
/**
* Ensures billing is initialized and subscription is active.
@@ -65,16 +73,12 @@ export interface BillingState {
currentPlanSlug: ComputedRef<string | null>
isLoading: Ref<boolean>
error: Ref<string | null>
/**
* Convenience computed for checking if subscription is active.
* Equivalent to `subscription.value?.isActive ?? false`
*/
isActiveSubscription: ComputedRef<boolean>
/**
* Whether the current billing context has a FREE tier subscription.
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
*/
isFreeTier: ComputedRef<boolean>
billingStatus: ComputedRef<BillingStatus | null>
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
tier: ComputedRef<SubscriptionTier | null>
renewalDate: ComputedRef<string | null>
}
export interface BillingContext extends BillingState, BillingActions {

View File

@@ -5,13 +5,17 @@ import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext'
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] }
})
)
const {
mockTeamWorkspacesEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn()
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const original = await importOriginal()
@@ -50,8 +54,9 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
isActiveSubscription: { value: true },
subscriptionTier: { value: 'PRO' },
subscriptionDuration: { value: 'MONTHLY' },
formattedRenewalDate: { value: 'Jan 1, 2025' },
formattedEndDate: { value: '' },
subscriptionStatus: {
value: { renewal_date: '2025-01-01T00:00:00Z', end_date: null }
},
isCancelled: { value: false },
fetchStatus: vi.fn().mockResolvedValue(undefined),
manageSubscription: vi.fn().mockResolvedValue(undefined),
@@ -70,6 +75,12 @@ vi.mock(
})
)
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
purchaseCredits: mockPurchaseCredits
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
balance: { amount_micros: 5000000 },
@@ -129,7 +140,7 @@ describe('useBillingContext', () => {
tier: 'PRO',
duration: 'MONTHLY',
planSlug: null,
renewalDate: 'Jan 1, 2025',
renewalDate: '2025-01-01T00:00:00Z',
endDate: null,
isCancelled: false,
hasFunds: true
@@ -173,6 +184,13 @@ describe('useBillingContext', () => {
await expect(manageSubscription()).resolves.toBeUndefined()
})
it('converts topup cents to whole dollars for the legacy credit endpoint', async () => {
const { topup } = useBillingContext()
await topup(500)
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
})
it('provides isActiveSubscription convenience computed', () => {
const { isActiveSubscription } = useBillingContext()
expect(isActiveSubscription.value).toBe(true)

View File

@@ -122,6 +122,15 @@ function useBillingContextInternal(): BillingContext {
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
const billingStatus = computed(() =>
toValue(activeContext.value.billingStatus)
)
const subscriptionStatus = computed(() =>
toValue(activeContext.value.subscriptionStatus)
)
const tier = computed(() => toValue(activeContext.value.tier))
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
@@ -218,6 +227,14 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.cancelSubscription()
}
async function resubscribe() {
return activeContext.value.resubscribe()
}
async function topup(amountCents: number) {
return activeContext.value.topup(amountCents)
}
async function fetchPlans() {
return activeContext.value.fetchPlans()
}
@@ -241,6 +258,10 @@ function useBillingContextInternal(): BillingContext {
error,
isActiveSubscription,
isFreeTier,
billingStatus,
subscriptionStatus,
tier,
renewalDate,
getMaxSeats,
initialize,
@@ -250,6 +271,8 @@ function useBillingContextInternal(): BillingContext {
previewSubscribe,
manageSubscription,
cancelSubscription,
resubscribe,
topup,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog

View File

@@ -1,7 +1,10 @@
import { computed, ref } from 'vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
BillingStatus,
BillingSubscriptionStatus,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
@@ -24,8 +27,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
isActiveSubscription: legacyIsActiveSubscription,
subscriptionTier,
subscriptionDuration,
formattedRenewalDate,
formattedEndDate,
subscriptionStatus: legacySubscriptionStatus,
isCancelled,
fetchStatus: legacyFetchStatus,
manageSubscription: legacyManageSubscription,
@@ -34,6 +36,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
} = useSubscription()
const authStore = useAuthStore()
const authActions = useAuthActions()
const isInitialized = ref(false)
const isLoading = ref(false)
@@ -52,8 +55,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
tier: subscriptionTier.value,
duration: subscriptionDuration.value,
planSlug: null, // Legacy doesn't use plan slugs
renewalDate: formattedRenewalDate.value || null,
endDate: formattedEndDate.value || null,
renewalDate: legacySubscriptionStatus.value?.renewal_date ?? null,
endDate: legacySubscriptionStatus.value?.end_date ?? null,
isCancelled: isCancelled.value,
hasFunds: (authStore.balance?.amount_micros ?? 0) > 0
}
@@ -75,6 +78,18 @@ export function useLegacyBilling(): BillingState & BillingActions {
}
})
// Legacy has no coarse billing_status concept (workspace-only).
const billingStatus = computed<BillingStatus | null>(() => null)
const subscriptionStatus = computed<BillingSubscriptionStatus | null>(() => {
if (isCancelled.value) return 'canceled'
if (legacyIsActiveSubscription.value) return 'active'
return null
})
const tier = computed(() => subscriptionTier.value)
const renewalDate = computed(
() => legacySubscriptionStatus.value?.renewal_date ?? null
)
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
const currentPlanSlug = computed(() => null)
@@ -152,6 +167,16 @@ export function useLegacyBilling(): BillingState & BillingActions {
await legacyManageSubscription()
}
async function resubscribe(): Promise<void> {
// Legacy has no resubscribe endpoint; resubscribing is a fresh checkout.
await legacySubscribe()
}
async function topup(amountCents: number): Promise<void> {
// Facade standardizes on cents; legacy /customers/credit takes dollars.
await authActions.purchaseCredits(amountCents / 100)
}
async function fetchPlans(): Promise<void> {
// Legacy billing doesn't have workspace-style plans
// Plans are hardcoded in the UI for legacy subscriptions
@@ -179,6 +204,10 @@ export function useLegacyBilling(): BillingState & BillingActions {
error,
isActiveSubscription,
isFreeTier,
billingStatus,
subscriptionStatus,
tier,
renewalDate,
// Actions
initialize,
@@ -188,6 +217,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
previewSubscribe,
manageSubscription,
cancelSubscription,
resubscribe,
topup,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog

View File

@@ -1,8 +1,10 @@
import { uniq } from 'es-toolkit'
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { collectFromNodes } from '@/utils/graphTraversalUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
/**
* Composable for handling selected LiteGraph items filtering and operations.
@@ -71,7 +73,13 @@ export function useSelectedLiteGraphItems() {
* the prior null-tolerance for callers wired to early-firing commands.
*/
const getSelectedNodesShallow = (): LGraphNode[] =>
Array.from(canvasStore.canvas?.selectedItems ?? []).filter(isLGraphNode)
uniq(
[...(canvasStore.canvas?.selectedItems ?? [])].flatMap((item) => {
if (isLGraphNode(item)) return [item]
if (isLGraphGroup(item)) return [...item.children].filter(isLGraphNode)
return []
})
)
/**
* Get only the selected nodes (LGraphNode instances) from the canvas.

View File

@@ -7,7 +7,12 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
import {
isImageNode,
isLGraphGroup,
isLGraphNode,
isLoad3dNode
} from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
export interface NodeSelectionState {
@@ -41,6 +46,11 @@ export function useSelectionState() {
const hasAnySelection = computed(() => selectedItems.value.length > 0)
const hasSingleSelection = computed(() => selectedItems.value.length === 1)
const hasMultipleSelection = computed(() => selectedItems.value.length > 1)
const hasGroupedNodesSelection = computed(() =>
selectedItems.value.some(
(item) => isLGraphGroup(item) && [...item.children].some(isLGraphNode)
)
)
const isSingleNode = computed(
() => hasSingleSelection.value && isLGraphNode(selectedItems.value[0])
@@ -112,6 +122,7 @@ export function useSelectionState() {
openNodeInfo,
hasAny3DNodeSelected,
hasAnySelection,
hasGroupedNodesSelection,
hasSingleSelection,
hasMultipleSelection,
isSingleNode,

View File

@@ -9,16 +9,26 @@ export type AppMode =
| 'builder:outputs'
| 'builder:arrange'
type WorkflowModeSource = {
activeMode: AppMode | null
initialMode: AppMode | null | undefined
}
export function getWorkflowMode(
workflow: WorkflowModeSource | null | undefined
): AppMode {
return workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
}
export function isAppModeValue(mode: AppMode): boolean {
return mode === 'app' || mode === 'builder:arrange'
}
const enableAppBuilder = ref(true)
export function useAppMode() {
const workflowStore = useWorkflowStore()
const mode = computed(
() =>
workflowStore.activeWorkflow?.activeMode ??
workflowStore.activeWorkflow?.initialMode ??
'graph'
)
const mode = computed(() => getWorkflowMode(workflowStore.activeWorkflow))
const isBuilderMode = computed(
() => isSelectMode.value || isArrangeMode.value
@@ -29,9 +39,7 @@ export function useAppMode() {
() => isSelectInputsMode.value || isSelectOutputsMode.value
)
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isAppMode = computed(() => isAppModeValue(mode.value))
const isGraphMode = computed(
() => mode.value === 'graph' || isSelectMode.value
)

View File

@@ -38,7 +38,8 @@ export function useHelpCenter() {
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_help_center_toggled'
button_id: 'sidebar_help_center_toggled',
element_group: 'sidebar'
})
helpCenterStore.toggle()
}

View File

@@ -169,6 +169,7 @@ describe('useLoad3d', () => {
exportModel: vi.fn().mockResolvedValue(undefined),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getSourceFormat: vi.fn().mockReturnValue(null),
getCurrentModelCapabilities: vi.fn().mockReturnValue({
fitToViewer: true,
requiresMaterialRebuild: false,

View File

@@ -169,6 +169,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const isPreview = ref(false)
const isSplatModel = ref(false)
const isPlyModel = ref(false)
const sourceFormat = ref<string | null>(null)
const canFitToViewer = ref(true)
const canCenterCameraOnModel = ref(false)
const canUseGizmo = ref(true)
@@ -905,6 +906,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
loading.value = false
isSplatModel.value = load3d?.isSplatModel() ?? false
isPlyModel.value = load3d?.isPlyModel() ?? false
sourceFormat.value = load3d?.getSourceFormat() ?? null
canCenterCameraOnModel.value = isSplatModel.value || isPlyModel.value
const caps = load3d?.getCurrentModelCapabilities()
canFitToViewer.value = caps?.fitToViewer ?? true
@@ -1070,6 +1072,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isPreview,
isSplatModel,
isPlyModel,
sourceFormat,
canFitToViewer,
canCenterCameraOnModel,
canUseGizmo,

View File

@@ -130,6 +130,7 @@ describe('useLoad3dViewer', () => {
hasAnimations: vi.fn().mockReturnValue(false),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
getSourceFormat: vi.fn().mockReturnValue(null),
getCurrentModelCapabilities: vi.fn().mockReturnValue({
fitToViewer: true,
requiresMaterialRebuild: false,
@@ -618,7 +619,7 @@ describe('useLoad3dViewer', () => {
requiresMaterialRebuild: false,
gizmoTransform: true,
lighting: false,
exportable: false,
exportable: true,
materialModes: [],
fitTargetSize: 20
})
@@ -630,7 +631,7 @@ describe('useLoad3dViewer', () => {
expect.stringContaining('dropped.splat')
)
expect(viewer.canUseLighting.value).toBe(false)
expect(viewer.canExport.value).toBe(false)
expect(viewer.canExport.value).toBe(true)
expect(viewer.isSplatModel.value).toBe(true)
expect([...viewer.materialModes.value]).toEqual([])
})

View File

@@ -83,6 +83,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const isStandaloneMode = ref(false)
const isSplatModel = ref(false)
const isPlyModel = ref(false)
const sourceFormat = ref<string | null>(null)
const canFitToViewer = ref(true)
const canUseGizmo = ref(true)
const canUseLighting = ref(true)
@@ -96,6 +97,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const captureAdapterFlags = (source: Load3d) => {
isSplatModel.value = source.isSplatModel()
isPlyModel.value = source.isPlyModel()
sourceFormat.value = source.getSourceFormat()
const caps = source.getCurrentModelCapabilities()
canFitToViewer.value = caps.fitToViewer
canUseGizmo.value = caps.gizmoTransform
@@ -839,6 +841,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isStandaloneMode,
isSplatModel,
isPlyModel,
sourceFormat,
canFitToViewer,
canUseGizmo,
canUseLighting,

View File

@@ -22,6 +22,7 @@ import {
LOAD3D_NONE_MODEL,
SUPPORTED_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -413,16 +414,10 @@ useExtensionService().registerExtension({
if (cached) return cached
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
currentLoad3d.stopRecording()
const { camera_info, model_3d_info } = snapshotLoad3dState(
node,
currentLoad3d
)
const {
scene: imageData,
@@ -441,16 +436,11 @@ useExtensionService().registerExtension({
currentLoad3d.handleResize()
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal: Load3dCachedOutput = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
camera_info,
recording: '',
model_3d_info
}

View File

@@ -12,13 +12,17 @@ const {
exportGLBMock,
exportOBJMock,
exportSTLMock,
exportFBXMock
exportFBXMock,
exportDirectMock,
detectFormatFromURLMock
} = vi.hoisted(() => ({
cloneSkinnedMock: vi.fn(),
exportGLBMock: vi.fn(),
exportOBJMock: vi.fn(),
exportSTLMock: vi.fn(),
exportFBXMock: vi.fn()
exportFBXMock: vi.fn(),
exportDirectMock: vi.fn(),
detectFormatFromURLMock: vi.fn()
}))
vi.mock('three/examples/jsm/utils/SkeletonUtils.js', () => ({
@@ -30,7 +34,9 @@ vi.mock('@/extensions/core/load3d/ModelExporter', () => ({
exportGLB: exportGLBMock,
exportOBJ: exportOBJMock,
exportSTL: exportSTLMock,
exportFBX: exportFBXMock
exportFBX: exportFBXMock,
exportDirect: exportDirectMock,
detectFormatFromURL: detectFormatFromURLMock
}
}))
@@ -1172,5 +1178,56 @@ describe('Load3d', () => {
'Unsupported export format: xyz'
)
})
it('downloads the source file directly for direct-export formats', async () => {
exportDirectMock.mockReset()
detectFormatFromURLMock.mockReturnValue('ply')
const model = new THREE.Object3D()
setupForExport({
currentModel: model,
originalFileName: 'cloud',
originalURL: 'http://example.com/api/view?filename=cloud.ply'
})
await ctx.load3d.exportModel('ply')
expect(exportDirectMock).toHaveBeenCalledWith(
'http://example.com/api/view?filename=cloud.ply',
'cloud.ply',
'ply'
)
expect(exportGLBMock).not.toHaveBeenCalled()
expect(exportOBJMock).not.toHaveBeenCalled()
expect(cloneSkinnedMock).not.toHaveBeenCalled()
})
it('refuses a direct export when the requested format differs from the source', async () => {
exportDirectMock.mockReset()
detectFormatFromURLMock.mockReturnValue('spz')
vi.spyOn(console, 'error').mockImplementation(() => {})
setupForExport({
currentModel: new THREE.Object3D(),
originalFileName: 'scene',
originalURL: 'http://example.com/api/view?filename=scene.spz'
})
await expect(ctx.load3d.exportModel('ply')).rejects.toThrow(
'Cannot export ply without converting from the loaded spz source'
)
expect(exportDirectMock).not.toHaveBeenCalled()
})
it('getSourceFormat derives the extension from the original URL', () => {
detectFormatFromURLMock.mockReturnValue('spz')
setupForExport({
currentModel: new THREE.Object3D(),
originalURL: 'http://example.com/api/view?filename=scene.spz'
})
expect(ctx.load3d.getSourceFormat()).toBe('spz')
expect(detectFormatFromURLMock).toHaveBeenCalledWith(
'http://example.com/api/view?filename=scene.spz'
)
})
})
})

View File

@@ -9,6 +9,7 @@ import type { GizmoManager } from './GizmoManager'
import type { HDRIManager } from './HDRIManager'
import type { LightingManager } from './LightingManager'
import type { LoaderManager } from './LoaderManager'
import { DIRECT_EXPORT_FORMATS } from './constants'
import { ModelExporter } from './ModelExporter'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { AdapterRef, ModelAdapterCapabilities } from './ModelAdapter'
@@ -359,6 +360,27 @@ class Load3d {
const exportMessage = `Exporting as ${format.toUpperCase()}...`
this.eventManager.emitEvent('exportLoadingStart', exportMessage)
const originalFileName = this.modelManager.originalFileName || 'model'
const filename = `${originalFileName}.${format}`
const originalURL = this.modelManager.originalURL
if (DIRECT_EXPORT_FORMATS.has(format)) {
try {
if (this.getSourceFormat() !== format) {
throw new Error(
`Cannot export ${format} without converting from the loaded ${this.getSourceFormat() ?? 'unknown'} source`
)
}
await ModelExporter.exportDirect(originalURL, filename, format)
} catch (error) {
console.error(`Error exporting model as ${format}:`, error)
throw error
} finally {
this.eventManager.emitEvent('exportLoadingEnd', null)
}
return
}
const source = this.modelManager.currentModel
const savedPos = source.position.clone()
const savedRot = source.rotation.clone()
@@ -384,11 +406,6 @@ class Load3d {
? Object.assign(cloneSkinned(source), { animations: clips })
: source.clone()
const originalFileName = this.modelManager.originalFileName || 'model'
const filename = `${originalFileName}.${format}`
const originalURL = this.modelManager.originalURL
await new Promise((resolve) => setTimeout(resolve, 10))
switch (format) {
@@ -399,7 +416,7 @@ class Load3d {
await ModelExporter.exportOBJ(model, filename, originalURL)
break
case 'stl':
;(await ModelExporter.exportSTL(model, filename), originalURL)
await ModelExporter.exportSTL(model, filename, originalURL)
break
case 'fbx':
await ModelExporter.exportFBX(model, filename, originalURL)
@@ -421,6 +438,12 @@ class Load3d {
}
}
getSourceFormat(): string | null {
const url = this.modelManager.originalURL
if (!url) return null
return ModelExporter.detectFormatFromURL(url)
}
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)

View File

@@ -23,6 +23,7 @@ const mtlLoaderStub = {
const objLoaderStub = {
setWorkerUrl: vi.fn(),
setMaterials: vi.fn(),
setBaseObject3d: vi.fn(),
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
}
@@ -58,6 +59,7 @@ vi.mock('wwobjloader2', () => ({
OBJLoader2Parallel: class {
setWorkerUrl = objLoaderStub.setWorkerUrl
setMaterials = objLoaderStub.setMaterials
setBaseObject3d = objLoaderStub.setBaseObject3d
loadAsync = objLoaderStub.loadAsync
},
MtlObjBridge: {
@@ -247,6 +249,24 @@ describe('MeshModelAdapter', () => {
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
})
it('resets baseObject3d on every load so meshes do not accumulate across calls', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const ctx = makeContext('wireframe')
await adapter.load(ctx, '/api/view/', 'first.obj')
await adapter.load(ctx, '/api/view/', 'second.obj')
expect(objLoaderStub.setBaseObject3d).toHaveBeenCalledTimes(2)
const bases = objLoaderStub.setBaseObject3d.mock.calls.map(
([base]) => base
)
expect(bases[0]).toBeInstanceOf(THREE.Object3D)
expect(bases[1]).toBeInstanceOf(THREE.Object3D)
// Each call should hand the loader a fresh container, not the same one.
expect(bases[0]).not.toBe(bases[1])
})
})
describe('GLTF loader path', () => {

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