Compare commits

..

32 Commits

Author SHA1 Message Date
uytieu
5cf647d71e chore: add Vercel prebuilt deploy script for Node 25 workaround
Vercel does not support Node 25 yet; build locally and deploy with
Build Output API routes so API proxies to cloud.comfy.org still work.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 13:33:03 -04:00
uytieu
f8b5780623 chore: add Vercel config for cloud feature-branch previews
Proxy API routes to cloud.comfy.org so external testers can load node
definitions from a branch deployment without a local dev server.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 13:25:10 -04:00
github-actions
56f846f7aa [automated] Update test expectations 2026-06-16 13:21:07 -04:00
GitHub Action
9c9ff0882c [automated] Apply ESLint and Oxfmt fixes 2026-06-16 15:52:44 +00:00
uytieu
61a08bc3cc fix: resolve lint errors in MiddleTruncate and autoPan tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:47:15 -04:00
Uy Tieu
bff7669e17 test: target new link-release menu in e2e specs
The context-menu link-release action now shows the Vue LinkReleaseContextMenu
instead of litegraph's native menu. Add a stable test id + ContextMenu page
object locators and update the link-release specs to assert the new menu.

Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
2026-06-16 15:22:42 +00:00
Uy Tieu
ad83b8ee7a test: cover link-release menu keyboard, selection, and truncation
Add behavioral unit tests for the new searchbox components to raise patch
coverage: submenu/menu search keyboard (Enter selects, ArrowDown focuses
first item, Escape passthrough), suggestion/reroute selection emits, and
MiddleTruncate reveal hide/keep-on-pointer-move paths.

Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
2026-06-16 15:02:28 +00:00
Uy Tieu
5b9c773475 fix: implement default root filter and repair autoPan enum mock
- Default node search to Essentials on an empty graph; NodeSearchContent
  seeds its root filter from the new defaultRootFilter prop
- Preserve real globalEnums exports (RenderShape) in the autoPan test mock
  so searchBoxStore's transitive schema imports resolve

Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
2026-06-16 10:46:01 -04:00
Uy Tieu
b9e943ef78 refactor: finalize new node position before adding to graph
Resolve overlap during node construction instead of mutating node.pos
after graph.add, keeping placement out of the post-add mutation path.

Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
2026-06-16 10:46:01 -04:00
Uy Tieu
a5dbf06fe7 test: harden link-release menu tests and clamp menu placement
- Reset hoisted groups state between LinkReleaseContextMenu tests
- Make submenu stub respect open state to catch open-state regressions
- Assert cancelLinkRelease runs before drag start in takeover test
- Clamp context menu top/x to viewport margin on both sides

Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
2026-06-16 10:46:01 -04:00
Uy Tieu
c7b1e51361 fix: correct invalid p-.5 Tailwind class to p-0.5
Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
2026-06-16 10:46:00 -04:00
Uy Tieu
1bd296c48b fix: dedup contextMenu Extensions key and label submenu search input
Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
2026-06-16 10:46:00 -04:00
uytieu
2d61fd9dc7 fix: add missing imports in NodeSearchBoxPopover test 2026-06-16 10:46:00 -04:00
uytieu
5dfd975e82 add horizontal divider on context menu search results 2026-06-16 10:46:00 -04:00
uytieu
c32218af38 fixed submenu position at bottom of page and view 2026-06-16 10:46:00 -04:00
uytieu
52d4adbafd top align submenu to context menu search field 2026-06-16 10:46:00 -04:00
uytieu
379c2a2ed9 update padding to be uniform with rest of item list 2026-06-16 10:46:00 -04:00
uytieu
8a0dd485be add connection dot to context menu for association. adjusted padding and gap for alignment of icons and text in menu. 2026-06-16 10:46:00 -04:00
uytieu
b1b4e88d84 removed horizontal overflow on context menu. fixed bug that prevented another edge from being selected if context menu was open from release link 2026-06-16 10:46:00 -04:00
uytieu
bad34123f2 Style update
• adjust padding to be uniform in menu and sub menus
2026-06-16 10:46:00 -04:00
uytieu
d4e9d2f306 Add Truncate and overflow
• Truncate text at end
• Hover to reveal full text above truncated text
• Fixed with on sub menus
2026-06-16 10:46:00 -04:00
uytieu
c9ef980ad1 Design update
• Update borders for header and footer
• Removed separator between list groups
• Truncation on flat lists on search
2026-06-16 10:46:00 -04:00
uytieu
f2b4b3e2fd Make LinkReleaseCategoryKey non-exported
Remove the export modifier from LinkReleaseCategoryKey so the type is internal to the module. This tightens encapsulation for the searchbox link release model and prevents the type from being relied on externally.
2026-06-16 10:46:00 -04:00
uytieu
8bdafffb00 Use Reka dropdowns for link-release menu
Merged with asset manager linear style flat search
2026-06-16 10:45:59 -04:00
uytieu
76136708ec Update to lite graph link release context menu
• Match current context menu styles
• Added inline search filtering of nodes
• Replaced click action with hover to see node submenus
• Moved most relevant nodes group under search and added group title for context
• Moved reroute action to button of menu
• fix: narrow fromSlot type before connectFloatingReroute
2026-06-16 10:45:59 -04:00
jaeone94
0df2b05790 fix: encode large copy payload metadata in chunks (#12847)
## Summary

Fix Ctrl+C copy for large subgraphs by encoding clipboard metadata in
bounded byte chunks instead of spreading the full serialized payload
into a single `String.fromCharCode(...)` call.

## Root Cause

<img width="648" height="33" alt="스크린샷 2026-06-15 오후 4 46 52"
src="https://github.com/user-attachments/assets/09aec159-fd10-4979-bfb2-51aec9b51a63"
/>

Ctrl+C uses the native `copy` event path in `useCopy.ts` so ComfyUI can
write serialized node metadata into the system clipboard as `text/html`.
That metadata supports the cross-app / cross-window copy-paste path.

For Unicode safety, the current code first converts the serialized node
JSON to UTF-8 bytes with `TextEncoder`, then converts those bytes into a
binary string for `btoa`. The bug was in this conversion step:

```ts
String.fromCharCode(...Array.from(new TextEncoder().encode(serializedData)))
```

When a selected subgraph is large enough, the UTF-8 byte array becomes
too large to spread as function arguments. The browser throws
`RangeError: Maximum call stack size exceeded` before clipboard metadata
is written, so Ctrl+C appears to fail for large subgraphs.

The right-click / menu copy path was not affected in the same way
because it uses LiteGraph's internal `copyToClipboard()` path directly
and does not go through this system clipboard metadata encoding step.

## Changes

- **What**: Convert UTF-8 bytes to a binary string in `0x8000` byte
chunks before passing the result to `btoa`.
- **Why**: This preserves the existing UTF-8 safe cross-app clipboard
metadata format while avoiding the JavaScript argument-count limit that
caused the stack overflow.
- **Fallback**: Wrap system clipboard metadata encoding/writing in
`try/catch` so the internal `canvas.copyToClipboard()` result is still
produced even if the metadata bridge fails unexpectedly.
- **Dependencies**: None

## Review Focus

- Chunking is only used while building the binary string for base64
encoding. The clipboard payload format remains unchanged.
- Multi-byte UTF-8 data remains safe because chunking happens at the
byte-string construction layer; paste still reassembles the full byte
stream before `TextDecoder` decodes it.
- The unit test exercises the actual `useCopy` copy handler with a large
serialized payload, Unicode metadata, and a partial final chunk.

## Test Plan

- `vitest run src/composables/useCopy.test.ts`
- pre-commit hook: `oxfmt`, `oxlint`, `eslint`, `typecheck`
- pre-push hook: `pnpm knip`

No E2E was added because this regression is isolated to deterministic
clipboard metadata encoding in `useCopy`. The unit test exercises the
actual `copy` event handler with a large serialized payload and Unicode
metadata, avoiding a large workflow fixture and slower browser coverage
for behavior that does not require canvas rendering or end-to-end UI
orchestration.

Linear:
[FE-858](https://linear.app/comfyorg/issue/FE-858/bug-ctrlc-copy-keyboard-shortcut-does-not-work-on-large-subgraphs)
2026-06-16 14:12:08 +00:00
jaeone94
c36da042d0 Redesign error tab cards with summary hero and unified sections (#12828)
## Summary

Redesigns the Errors tab cards to match the new Figma error-panel spec
(file `Czv0JcCfcUiizeEZURevpq`): every error group is now wrapped in a
single bordered card led by an error-count summary hero, with each
category rendered as a collapsible section whose count lives in a
circular badge rather than a parenthetical title suffix.

> Rebased onto `main` after #12793 was merged. This PR now contains only
the error-card redesign slice.

## Changes

- **What**:
- **New `ErrorCardSection.vue`** — the shared section shell used by
every error type. Renders a 32px header (circular count badge + neutral
title + `actions` slot + collapse chevron) and a `TransitionCollapse`
body. Replaces the per-group `PropertiesAccordionItem`, dropping the old
octagon-alert icon + red title + `(n)` suffix and the sticky-header
behavior.
- **`TabErrors.vue`** — wraps all groups in one `rounded-lg` card
bordered with `secondary-background`. Adds a summary **hero** (large
severity-colored total count, vertical divider, "N Errors detected /
Resolve before running the workflow"). Moves the per-group action
buttons (Install All / Replace All / missing-model Refresh) into the
section's `actions` slot. Adds `getGroupCount()` / `totalErrorCount` and
switches content background to `interface-panel-surface`. Most of the
line count here is re-indentation from the template restructure, not
behavior change.
- **`missingErrorResolver.ts`** — drops the `formatCountTitle` helper so
display titles are `"Missing Models"` instead of `"Missing Models (4)"`;
the badge now carries the count. Toast titles/messages are untouched.
- **`ErrorNodeCard.vue`** — restyles the runtime/validation error-log
box to the Figma spec: borderless `base-foreground/5` surface, `ERROR
LOG` header, 12px non-mono body at 50% opacity, inset footer divider
with Get Help / Find on GitHub links.
- **Row components** (`MissingModelRow`, `MissingPackGroupRow`,
`SwapNodeGroupRow`, `MissingMediaCard`, `MissingNodeCard`,
`MissingModelCard`) — align spacing, fonts, badges, and button sizes
with Figma: 12px row labels, `size="sm"` (24px) action buttons, 16px
count badges (`rounded-sm`, `secondary-background-hover`, 9px), 32px
reference-row heights, `px-3` card padding. Model-name wrapping is kept
independent of its count badge and link button so they never reflow into
the metadata sub-label.
- **i18n** — adds `errorsDetected` (pluralized), `resolveBeforeRun`,
`expand`, `collapse` to `en/main.json`.
- **Breaking**: None. No store, composable, action, or data-flow changes
— all handlers and emitted events are preserved. The only user-visible
copy change is the removal of the `(n)` count suffix from section
titles.

## Review Focus

- **Title copy change**: `"Missing Models (4)"` → `"Missing Models"`.
Search-filter matching against the old `(n)` string no longer applies,
but the count is shown by the badge and the hero total.
- **Sticky header removed**: section headers no longer pin to the top on
scroll (intentional per the new design).
- **Collapse click target**: the old single-button header (which nested
action buttons inside a `<button>` — invalid HTML) is split into a
separate title button and chevron button. Behavior is unchanged and
accessibility improves; the empty space beside an action button no
longer toggles collapse.
- All semantic colors map to existing design-system tokens (no `dark:`
variants, no hardcoded hex). Verified the artifact hex values match the
tokens (e.g. `#262729` = `secondary-background`, `#e04e48` =
`destructive-background-hover`, `#171718` = `interface-panel-surface`).

## Follow-up

This PR intentionally keeps the error-count ownership cleanup out of the
current diff so the card redesign remains reviewable. A follow-up PR
will centralize error counting around a single source of truth so the
Errors tab summary hero, section badges, and any overlay surfaces cannot
drift from one another.

That follow-up will also address the current count mismatch in the
ErrorOverlay and continue the ErrorOverlay redesign there, instead of
expanding this PR after review.

## Screenshots (if applicable)
After
<img width="603" height="703" alt="스크린샷 2026-06-13 오후 1 00 02"
src="https://github.com/user-attachments/assets/065d7c19-9748-4e99-9b43-675a31e92949"
/>
<img width="601" height="197" alt="스크린샷 2026-06-13 오후 1 01 07"
src="https://github.com/user-attachments/assets/0fa1fbda-9091-4a45-9eca-e99c43089c0e"
/>
<img width="617" height="612" alt="스크린샷 2026-06-13 오후 1 02 43"
src="https://github.com/user-attachments/assets/3d67a057-bf65-4e51-bcf5-70ecce851826"
/>
<img width="495" height="723" alt="스크린샷 2026-06-13 오후 1 03 28"
src="https://github.com/user-attachments/assets/6dcc4021-0fc3-4955-a68b-c0533c66a3cf"
/>

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-16 13:32:10 +00:00
Dante
75553fc214 fix(settings): widen the Settings dialog to 1280 (#12849)
## Summary

The redesigned Settings dialog (Figma DES `3253-16079`) is **1280px**
wide, but it rendered at **960px**.

Root cause — the width was capped at 960 in **two** layers:
1. `useSettingsDialog.ts` → `SETTINGS_CONTENT_CLASS` (`max-w-[960px]`)
sizes the Reka dialog shell.
2. `SettingDialog.vue` → `<BaseModalLayout size="sm">` (`SIZE_CLASSES.sm
= max-w-[960px]`) sizes the modal content.

Widening only the shell leaves the inner `BaseModalLayout` at 960 (empty
space on the right). This sets both to **1280px** and lets
`BaseModalLayout` fill the shell (`size="full"`).

The dialog size is **not** a workspace-specific concern, so it applies
to all Settings (OSS + cloud) — no feature-flag gate.

Found during FE-768 designer QA.

## Verification

- Live: dialog measures 1280px, content area 1006px (was 960 / 688).
- `useSettingsDialog.test.ts`: `contentClass` is 1280px (`size:
'full'`).
- `pnpm typecheck` / `lint` / `format` / unit tests green.

## Test Plan

- [x] Settings dialog renders at 1280px with the content filling the
dialog
- [x] Unit test asserts the 1280px sizing

## Screenshots

Settings ▸ Plan & Credits at **1280px** (content fills the dialog; was
960px shell / 688px content area):

**Personal — Pro:**

<img width="720" alt="Settings dialog at 1280px — personal Pro"
src="https://github.com/user-attachments/assets/adc2fd9f-d249-469f-b947-1ec8f674cbb0"
/>

**Team:**

<img width="720" alt="Settings dialog at 1280px — team"
src="https://github.com/user-attachments/assets/e7378067-11a2-411b-b37b-98c8aecb82b1"
/>

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-06-16 13:03:02 +00:00
Alexander Brown
7438f004c1 test: add mask editor load/save round-trip browser tests (#11369)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds `browser_tests/tests/maskEditorLoadSave.spec.ts` covering the
untested image loading, save round-trip, canvas dimension verification,
and error handling paths in the mask editor.

### Coverage gaps filled
- `useImageLoader.ts` — image loads onto canvas with correct dimensions
- `useMaskEditorSaver.ts` — save uploads non-empty mask data, round-trip
preserves state
- `useMaskEditorLoader.ts` — editor initialization, canvas dimension
matching
- Error handling — partial upload failure keeps dialog open

### Test cases (5 tests, 2 groups)
| Group | Tests | Behavior |
|---|---|---|
| Save round-trip | 3 | Save with drawn mask uploads non-empty data,
save-and-reopen preserves mask state, canvas dimensions match loaded
image |
| Load and error handling | 2 | Opening editor loads image onto canvas,
partial upload failure keeps dialog open |

### References
- Reuses patterns from existing `maskEditor.spec.ts` (`loadImageOnNode`,
`openMaskEditorDialog`, `getMaskCanvasPixelData`,
`drawStrokeOnPointerZone`, route mocking for upload endpoints)
- Follows `browser_tests/AGENTS.md` directory structure
- Follows `browser_tests/FLAKE_PREVENTION_RULES.md` assertion patterns

### Verification
- TypeScript: clean
- ESLint: clean
- oxlint: clean
- oxfmt: formatted

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11369-test-add-mask-editor-load-save-round-trip-browser-tests-3466d73d3650818b8245c0b355011136)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-06-16 01:41:18 +00:00
Terry Jia
06dda1fb38 feat: Load3DAdvanced uploads to input/3d (#12851)
## Summary
As discussed with team, we should keep upload folder as /input/3d folder
in new Load 3D node
2026-06-15 21:44:27 -04:00
AustinMroz
cdde1248d4 Resolve errant executionIds on workflow restore (#12659)
Node previews are stored by `locatorId`, but sent from the server by
`executionId`. Normally, this difference is reconciled when the event is
received, but this step is skipped when the workflow is backgrounded.
Upon reloading the workflow, these backlogged `executionId`s were
incorrectly mapped directly onto node outputs. Any outputs located
inside a subgraph would then fail to display because `executionId`s are
now `locatorId`s.

This is solved by resolving any `executionId`s at time of output
restoration. Because `executionId`s can only leak into the outputs of
backgrounded workflows, it is safe for resolved `executionId`s to
overwrite any pre-existing `locatorId`s.

It might wind up cleaner to instead properly enforce that the
nodeOutputs cached by change tracker resolve a `locatorId` at time of
receipt. This would follow naturally for properly branded id types, but
would then require resolving `locatorId` from suspended workflows which
is a good bit more involved.
2026-06-15 21:29:07 +00:00
Alexander Brown
5535e93ef3 Restrict Node.js engine version to <26 (#12858)
## Summary

We have a few dependencies that have conflicts with Node 26 still.
2026-06-15 18:15:25 +00:00
99 changed files with 3749 additions and 1204 deletions

View File

@@ -1,142 +0,0 @@
name: Publish Desktop Bridge Types
on:
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g., 0.1.2)'
required: true
type: string
dist_tag:
description: 'npm dist-tag to use'
required: true
default: latest
type: string
ref:
description: 'Git ref to checkout (commit SHA, tag, or branch)'
required: false
type: string
workflow_call:
inputs:
version:
required: true
type: string
dist_tag:
required: false
type: string
default: latest
ref:
required: false
type: string
secrets:
NPM_TOKEN:
required: true
concurrency:
group: publish-desktop-bridge-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }}
cancel-in-progress: false
jobs:
publish_desktop_bridge_types:
name: Publish @comfyorg/comfyui-desktop-bridge-types
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate inputs
env:
VERSION: ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2
exit 1
fi
- name: Determine ref to checkout
id: resolve_ref
env:
REF: ${{ inputs.ref }}
DEFAULT_REF: ${{ github.ref_name }}
shell: bash
run: |
set -euo pipefail
if [ -z "$REF" ]; then
REF="$DEFAULT_REF"
fi
if ! git check-ref-format --allow-onelevel "$REF"; then
echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2
exit 1
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- name: Verify package
id: pkg
env:
INPUT_VERSION: ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
PACKAGE_JSON=packages/comfyui-desktop-bridge-types/package.json
NAME=$(node -p "require('./${PACKAGE_JSON}').name")
VERSION=$(node -p "require('./${PACKAGE_JSON}').version")
if [ "$VERSION" != "$INPUT_VERSION" ]; then
echo "::error title=Version mismatch::${PACKAGE_JSON} version $VERSION does not match input $INPUT_VERSION" >&2
exit 1
fi
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if version already on npm
id: check_npm
env:
NAME: ${{ steps.pkg.outputs.name }}
VER: ${{ steps.pkg.outputs.version }}
shell: bash
run: |
set -euo pipefail
STATUS=0
OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish."
else
if echo "$OUTPUT" | grep -q "E404"; then
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "::error title=Registry lookup failed::$OUTPUT" >&2
exit "$STATUS"
fi
fi
- name: Publish package
if: steps.check_npm.outputs.exists == 'false'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
DIST_TAG: ${{ inputs.dist_tag }}
run: pnpm publish --access public --tag "$DIST_TAG" --no-git-checks --ignore-scripts
working-directory: packages/comfyui-desktop-bridge-types

View File

@@ -1,10 +1,14 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
export class ContextMenu {
public readonly primeVueMenu: Locator
public readonly litegraphMenu: Locator
public readonly litegraphContextMenu: Locator
public readonly linkReleaseMenu: Locator
public readonly linkReleaseMenuSearch: Locator
public readonly menuItems: Locator
protected readonly anyMenu: Locator
@@ -12,6 +16,10 @@ export class ContextMenu {
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
this.litegraphMenu = page.locator('.litemenu')
this.litegraphContextMenu = page.locator('.litecontextmenu')
this.linkReleaseMenu = page.getByTestId(TestIds.linkReleaseMenu.root)
this.linkReleaseMenuSearch = page.getByTestId(
TestIds.linkReleaseMenu.search
)
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
this.anyMenu = this.primeVueMenu
.or(this.litegraphMenu)

View File

@@ -4,6 +4,10 @@
*/
export const TestIds = {
linkReleaseMenu: {
root: 'link-release-context-menu',
search: 'link-release-search'
},
sidebar: {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -21,7 +21,7 @@ test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
await expect(comfyPage.searchBoxV2.input).toBeVisible()
})
test('"context menu" opens litegraph connection menu on link release', async ({
test('"context menu" opens the link-release context menu on link release', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
@@ -29,7 +29,7 @@ test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
'context menu'
)
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
})
test('"no action" suppresses both search box and context menu', async ({
@@ -41,7 +41,7 @@ test.describe('Link & node interaction settings', { tag: '@canvas' }, () => {
)
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.searchBoxV2.input).toBeHidden()
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeHidden()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 325 KiB

View File

@@ -0,0 +1,103 @@
import { expect } from '@playwright/test'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
interface UploadResponse {
name: string
subfolder: string
type: 'input' | 'output' | 'temp'
}
const IMAGE_CANVAS_INDEX = 0
const MASK_CANVAS_INDEX = 2
const successResponse = (name: string): UploadResponse => ({
name,
subfolder: 'clipspace',
type: 'input'
})
const fulfillJson = (body: UploadResponse) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
test('Save with drawn mask uploads non-empty mask data', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)
let observedContentType = ''
let observedBodyLength = 0
await comfyPage.page.route('**/upload/mask', async (route) => {
const request = route.request()
observedContentType = (await request.headerValue('content-type')) ?? ''
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
await route.fulfill(
fulfillJson(successResponse('clipspace-mask-123.png'))
)
})
await comfyPage.page.route('**/upload/image', (route) =>
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
)
await dialog.getByRole('button', { name: 'Save' }).click()
await expect(dialog).toBeHidden()
expect(observedContentType).toContain('multipart/form-data')
expect(observedBodyLength).toBeGreaterThan(256)
})
test('Canvas dimensions match the loaded image', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
const imageDimensions =
await maskEditor.getCanvasPixelData(IMAGE_CANVAS_INDEX)
const maskDimensions =
await maskEditor.getCanvasPixelData(MASK_CANVAS_INDEX)
expect(imageDimensions).not.toBeNull()
expect(maskDimensions).not.toBeNull()
expect(imageDimensions?.totalPixels).toBe(64 * 64)
expect(maskDimensions?.totalPixels).toBe(64 * 64)
await expect(dialog).toBeVisible()
})
test('Save failure on partial upload keeps dialog open', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)
// The saver uploads sequentially: mask layer first, then image layers.
// Let the mask upload succeed and the image upload fail to exercise both
// endpoints and verify the dialog stays open after a partial failure.
let maskUploadHit = false
let imageUploadHit = false
await comfyPage.page.route('**/upload/mask', (route) => {
maskUploadHit = true
return route.fulfill(
fulfillJson(successResponse('clipspace-mask-999.png'))
)
})
await comfyPage.page.route('**/upload/image', (route) => {
imageUploadHit = true
return route.fulfill({ status: 500 })
})
const saveButton = dialog.getByRole('button', { name: 'Save' })
await saveButton.click()
await expect.poll(() => maskUploadHit).toBe(true)
await expect.poll(() => imageUploadHit).toBe(true)
await expect(dialog).toBeVisible()
await expect(saveButton).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -296,12 +296,7 @@ test.describe('Release context menu', { tag: '@node' }, () => {
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.canvasOps.disconnectEdge()
const contextMenu = comfyPage.page.locator('.litecontextmenu')
// Wait for context menu with correct title (slot name | slot type)
// The title shows the output slot name and type from the disconnected link
await expect(contextMenu.locator('.litemenu-title')).toContainText(
'CLIP | CLIP'
)
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
await comfyPage.page.mouse.move(10, 10)
await comfyPage.expectScreenshot(
comfyPage.canvas,
@@ -313,14 +308,20 @@ test.describe('Release context menu', { tag: '@node' }, () => {
test(
'Can search and add node from context menu',
{ tag: '@screenshot' },
async ({ comfyPage, comfyMouse }) => {
async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.disconnectEdge()
await comfyMouse.move({ x: 10, y: 10 })
await comfyPage.contextMenu.clickMenuItem('Search')
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode('CLIP Prompt')
await waitForSearchInsertion(comfyPage, initialNodeCount)
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
await comfyPage.contextMenu.linkReleaseMenuSearch.fill('CLIP Prompt')
await expect(
comfyPage.contextMenu.linkReleaseMenu.getByRole('menuitem').first()
).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialNodeCount + 1)
await expect(comfyPage.canvas).toHaveScreenshot(
'link-context-menu-search.png'
)
@@ -343,8 +344,7 @@ test.describe('Release context menu', { tag: '@node' }, () => {
await comfyPage.canvasOps.disconnectEdge()
// Context menu should appear, search box should not
await expect(comfyPage.searchBox.input).toHaveCount(0)
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
})
test('Explicit setting overrides versioned defaults', async ({
@@ -366,7 +366,6 @@ test.describe('Release context menu', { tag: '@node' }, () => {
await comfyPage.canvasOps.disconnectEdge()
// Context menu should appear due to explicit setting, not search box
await expect(comfyPage.searchBox.input).toHaveCount(0)
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
await expect(comfyPage.contextMenu.linkReleaseMenu).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -8,6 +8,7 @@ import {
getPromotedWidgetNames,
getPromotedWidgetCountByName
} from '@e2e/fixtures/utils/promotedWidgets'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
@@ -139,6 +140,46 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
)
}
)
wstest(
'Displays previews inside subgraphs received while workflow inactive',
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
const previewLocator = comfyPage.vueNodes.getNodeByTitle('Preview Image')
const previewImage = new VueNodeFixture(previewLocator)
const subgraphLocator = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
const subgraphNode = new VueNodeFixture(subgraphLocator)
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
await expect(previewImage.root).toBeVisible()
})
await test.step('Create subgraph', async () => {
await previewImage.title.click()
await comfyPage.page.keyboard.press('Control+Shift+e')
await expect(subgraphNode.root).toBeVisible()
})
await test.step('Inject Previews from different tab', async () => {
const jobId = await execution.run()
await comfyPage.menu.topbar.getTab(0).click()
await comfyPage.vueNodes.waitForNodes(7)
const images = [{ filename: 'example.png', type: 'input' }]
execution.executed(jobId, '2:1', { images })
await comfyPage.nextFrame()
await comfyPage.menu.topbar.getTab(1).click()
await comfyPage.vueNodes.waitForNodes(1)
})
await expect(subgraphNode.imagePreview.locator('img')).toHaveCount(1)
}
)
})
async function countColumns(locator: Locator) {

View File

@@ -20,10 +20,13 @@
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
"dev:cloud:prod": "cross-env DEV_SERVER_COMFYUI_URL='https://cloud.comfy.org/' vite --config vite.config.mts",
"vercel:prebuilt": "node scripts/vercel-prebuilt.mjs",
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
"dev": "vite --config vite.config.mts",
"dev:backend": "bash scripts/start-comfyui-backend.sh",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
@@ -46,6 +49,7 @@
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec playwright test",
"playwright:install": "pnpm exec playwright install chromium --with-deps",
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
@@ -59,7 +63,6 @@
"dependencies": {
"@alloc/quick-lru": "catalog:",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-desktop-bridge-types": "workspace:*",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/fbx-exporter-three": "^1.0.1",
@@ -207,7 +210,7 @@
"zod-to-json-schema": "catalog:"
},
"engines": {
"node": ">=25",
"node": ">=25 <26",
"pnpm": ">=11.3"
},
"packageManager": "pnpm@11.3.0"

View File

@@ -1,91 +0,0 @@
export interface ComfyDownloadProgress {
url: string
filename: string
directory?: string
progress: number
receivedBytes?: number
totalBytes?: number
speedBytesPerSec?: number
etaSeconds?: number
status:
| 'pending'
| 'downloading'
| 'paused'
| 'completed'
| 'error'
| 'cancelled'
error?: string
isImage?: boolean
}
export interface TerminalRestore {
buffer: string[]
size: { cols: number; rows: number }
exited: boolean
}
export interface LogsRestore {
installationId: string
buffer: string[]
}
export interface LogsOutputMsg {
installationId: string
text: string
}
export type ComfyDesktop2TelemetryValue = string | number | boolean | null
export type ComfyDesktop2TelemetryProperties = Record<
string,
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
>
export interface ComfyDesktop2TerminalBridge {
subscribe(installationId?: string): Promise<TerminalRestore>
unsubscribe(installationId?: string): Promise<void>
write(data: string, installationId?: string): Promise<void>
resize(cols: number, rows: number, installationId?: string): Promise<void>
restart(installationId?: string): Promise<TerminalRestore>
openPopout(): Promise<void>
onOutput(callback: (data: string) => void): () => void
onExited(callback: () => void): () => void
}
export interface ComfyDesktop2LogsBridge {
subscribe(installationId?: string): Promise<LogsRestore>
unsubscribe(installationId?: string): Promise<void>
openPopout(): Promise<void>
onOutput(callback: (msg: LogsOutputMsg) => void): () => void
}
export interface ComfyDesktop2TelemetryBridge {
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
}
export interface ComfyDesktop2Bridge {
isRemote(): boolean
downloadModel?: (
url: string,
filename: string,
directory: string
) => Promise<boolean>
downloadAsset?: (
url: string,
filename: string,
authToken?: string
) => Promise<boolean>
pauseDownload?: (url: string) => Promise<boolean>
resumeDownload?: (url: string) => Promise<boolean>
cancelDownload?: (url: string) => Promise<boolean>
onDownloadProgress?: (
callback: (data: ComfyDownloadProgress) => void
) => () => void
reportTheme?: (bg: string, text: string) => void
Terminal?: ComfyDesktop2TerminalBridge
Logs?: ComfyDesktop2LogsBridge
Telemetry?: ComfyDesktop2TelemetryBridge
}
export type ComfyDesktop2BridgeImplementation = {
[K in keyof ComfyDesktop2Bridge]-?: NonNullable<ComfyDesktop2Bridge[K]>
}

View File

@@ -1 +0,0 @@
export * from './comfyDesktopBridge.js'

View File

@@ -1 +0,0 @@
export {}

View File

@@ -1,27 +0,0 @@
{
"name": "@comfyorg/comfyui-desktop-bridge-types",
"version": "0.1.2",
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
"homepage": "https://comfy.org",
"license": "MIT",
"author": {
"name": "Comfy Org",
"email": "support@comfy.org",
"url": "https://www.comfy.org"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Comfy-Org/ComfyUI_frontend.git"
},
"files": [
"comfyDesktopBridge.d.ts",
"index.d.ts",
"index.js"
],
"type": "module",
"main": "./index.js",
"types": "./index.d.ts",
"publishConfig": {
"access": "public"
}
}

13
pnpm-lock.yaml generated
View File

@@ -426,9 +426,6 @@ importers:
'@atlaskit/pragmatic-drag-and-drop':
specifier: ^1.3.1
version: 1.3.1
'@comfyorg/comfyui-desktop-bridge-types':
specifier: workspace:*
version: link:packages/comfyui-desktop-bridge-types
'@comfyorg/comfyui-electron-types':
specifier: 'catalog:'
version: 0.6.2
@@ -1000,8 +997,6 @@ importers:
specifier: 'catalog:'
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/coverage-v8@4.0.16(vitest@4.1.8))(@vitest/ui@4.0.16(vitest@4.1.8))(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.13(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
packages/comfyui-desktop-bridge-types: {}
packages/design-system:
dependencies:
'@iconify-json/lucide':
@@ -8645,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.5:
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
vue-component-type-helpers@3.3.4:
resolution: {integrity: sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -11328,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.5
vue-component-type-helpers: 3.3.4
'@swc/helpers@0.5.21':
dependencies:
@@ -17474,7 +17469,7 @@ snapshots:
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.5: {}
vue-component-type-helpers@3.3.4: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:

View File

@@ -164,7 +164,7 @@ overrides:
vite: 'catalog:'
'@tiptap/pm': 2.27.2
'@types/eslint': '-'
# Security overrides
#Security overrides
lodash: ^4.18.0
yaml: ^2.8.3
minimatch@^9.0.0: ^9.0.7

View File

@@ -2,12 +2,6 @@ import fs from 'fs'
import path from 'path'
const mainPackage = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
const desktopBridgeTypesPackage = JSON.parse(
fs.readFileSync(
'./packages/comfyui-desktop-bridge-types/package.json',
'utf8'
)
)
// Create the types-only package.json
const typesPackage = {
@@ -22,9 +16,7 @@ const typesPackage = {
homepage: mainPackage.homepage,
description: `TypeScript definitions for ${mainPackage.name}`,
license: mainPackage.license,
dependencies: {
'@comfyorg/comfyui-desktop-bridge-types': desktopBridgeTypesPackage.version
},
dependencies: {},
peerDependencies: {
vue: mainPackage.dependencies.vue,
zod: mainPackage.dependencies.zod
@@ -42,3 +34,5 @@ fs.writeFileSync(
path.join(distDir, 'package.json'),
JSON.stringify(typesPackage, null, 2)
)
console.log('Types package.json have been prepared in the dist directory')

View File

@@ -0,0 +1,67 @@
import { cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { execSync } from 'node:child_process'
import { join } from 'node:path'
const root = new URL('..', import.meta.url).pathname
const outputDir = join(root, '.vercel/output')
const staticDir = join(outputDir, 'static')
const cloudOrigin = 'https://cloud.comfy.org'
rmSync(outputDir, { recursive: true, force: true })
mkdirSync(staticDir, { recursive: true })
execSync('pnpm build:cloud', {
cwd: root,
stdio: 'inherit',
env: {
...process.env,
DISTRIBUTION: 'cloud',
USE_PROD_CONFIG: 'true',
ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID ?? '4E0RO38HS8',
ALGOLIA_API_KEY:
process.env.ALGOLIA_API_KEY ?? '684d998c36b67a9a9fce8fc2d8860579'
}
})
cpSync(join(root, 'dist'), staticDir, { recursive: true })
const config = {
version: 3,
routes: [
{
src: '/api/(.*)',
dest: `${cloudOrigin}/api/$1`
},
{
src: '/internal/(.*)',
dest: `${cloudOrigin}/internal/$1`
},
{
src: '/extensions/(.*)',
dest: `${cloudOrigin}/extensions/$1`
},
{
src: '/workflow_templates/(.*)',
dest: `${cloudOrigin}/workflow_templates/$1`
},
{
src: '/oauth/(.*)',
dest: `${cloudOrigin}/oauth/$1`
},
{
handle: 'filesystem'
},
{
src: '/(.*)',
dest: '/index.html'
}
]
}
writeFileSync(
join(outputDir, 'config.json'),
`${JSON.stringify(config, null, 2)}\n`
)
console.log('Prebuilt output ready at .vercel/output')

View File

@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { AppMode } from '@/utils/appMode'
import type { AppMode } from '@/composables/useAppMode'
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'

View File

@@ -0,0 +1,71 @@
<template>
<section :class="cn('group flex min-w-0 flex-col py-2', className)">
<div class="flex min-h-8 w-full items-center gap-2 px-3">
<button
type="button"
class="focus-visible:ring-ring flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-1"
:aria-expanded="!collapse"
:aria-controls="bodyId"
@click="collapse = !collapse"
>
<span
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-destructive-background-hover px-1 text-2xs/none font-semibold text-white tabular-nums"
>
{{ count }}
</span>
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
{{ title }}
</span>
</button>
<slot name="actions" />
<button
type="button"
class="focus-visible:ring-ring flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-1"
:aria-expanded="!collapse"
:aria-controls="bodyId"
:aria-label="
collapse ? t('rightSidePanel.expand') : t('rightSidePanel.collapse')
"
@click="collapse = !collapse"
>
<i
aria-hidden="true"
:class="
cn(
'icon-[lucide--chevron-up] size-4 text-muted-foreground transition-transform group-hover:text-base-foreground',
collapse && '-rotate-180'
)
"
/>
</button>
</div>
<TransitionCollapse>
<div v-if="!collapse" :id="bodyId">
<slot />
</div>
</TransitionCollapse>
</section>
</template>
<script setup lang="ts">
import { useId } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
const {
title,
count,
class: className
} = defineProps<{
title: string
count: number
class?: string
}>()
const collapse = defineModel<boolean>('collapse', { default: false })
const bodyId = useId()
const { t } = useI18n()
</script>

View File

@@ -1,29 +1,31 @@
<template>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
<div
v-if="card.nodeId && !compact"
class="flex flex-wrap items-center gap-2 py-2"
class="flex min-h-8 flex-wrap items-center gap-2"
>
<button
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
type="button"
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode"
>
{{ card.nodeTitle || card.title }}
</button>
<span
v-else-if="card.nodeTitle || card.title"
class="flex-1 truncate text-sm font-medium text-muted-foreground"
>
{{ card.nodeTitle || card.title }}
<span class="flex min-w-0 flex-1">
<button
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
type="button"
class="focus-visible:ring-ring m-0 max-w-full min-w-0 cursor-pointer appearance-none truncate rounded-sm border-0 bg-transparent p-0 text-left text-xs font-normal text-base-foreground outline-none focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="handleLocateNode"
>
{{ card.nodeTitle || card.title }}
</button>
<span
v-else-if="card.nodeTitle || card.title"
class="max-w-full min-w-0 truncate text-xs font-normal text-base-foreground"
>
{{ card.nodeTitle || card.title }}
</span>
</span>
<div class="flex shrink-0 items-center">
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
class="shrink-0 focus-visible:ring-inset"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
@@ -34,7 +36,7 @@
size="icon-sm"
:class="
cn(
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
runtimeDetailsExpanded &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
@@ -49,7 +51,7 @@
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
>
@@ -59,29 +61,29 @@
</div>
<div
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
class="flex min-h-0 flex-1 flex-col space-y-2 divide-y divide-interface-stroke/20"
>
<div
v-for="(error, idx) in card.errors"
:key="idx"
class="flex min-h-0 flex-col gap-3"
class="flex min-h-0 flex-col gap-1"
>
<p
v-if="getInlineMessage(error)"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-xs/relaxed wrap-break-word whitespace-pre-wrap"
>
{{ getInlineMessage(error) }}
</p>
<ul
v-if="getInlineItemLabel(error)"
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
class="m-0 list-disc space-y-1 pl-5 text-xs/relaxed text-muted-foreground marker:text-muted-foreground"
>
<li class="min-w-0 wrap-break-word">
<button
v-if="card.nodeId"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="handleLocateNode"
>
{{ getInlineItemLabel(error) }}
@@ -96,13 +98,13 @@
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
:class="
cn(
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
'overflow-y-auto rounded-lg bg-base-foreground/5 p-3',
'max-h-[6lh]'
)
"
>
<p
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
{{ getInlineDetails(error, idx) }}
</p>
@@ -115,60 +117,61 @@
role="region"
data-testid="runtime-error-panel"
:aria-label="t('rightSidePanel.errorLog')"
class="flex min-h-0 flex-col gap-3"
class="flex min-h-0 flex-col gap-1"
>
<div
v-if="getInlineDetails(error, idx)"
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
class="flex flex-col gap-3 rounded-lg bg-base-foreground/5 p-3"
>
<div
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
>
<span
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
>
{{ t('rightSidePanel.errorLog') }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('g.copy')"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
<p
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ getInlineDetails(error, idx) }}
</p>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-1 py-1">
<span
class="text-xs font-semibold text-base-foreground uppercase"
>
{{ t('rightSidePanel.errorLog') }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="t('g.copy')"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div class="max-h-[15lh] overflow-y-auto">
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
{{ getInlineDetails(error, idx) }}
</p>
</div>
</div>
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
<div class="mx-3 flex items-center justify-between gap-2 py-2">
<div aria-hidden="true" class="h-px w-full bg-interface-stroke" />
<div class="flex items-center justify-between gap-2">
<Button
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
variant="textonly"
size="sm"
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
class="justify-start gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
@click="handleGetHelp"
>
<i class="icon-[lucide--external-link] size-3.5" />
<i class="icon-[lucide--external-link] size-4" />
{{ t('g.getHelpAction') }}
</Button>
<Button
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
variant="textonly"
size="sm"
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
class="justify-end gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
<i class="icon-[lucide--github] size-3.5" />
<i class="icon-[lucide--github] size-4" />
{{ t('g.findOnGithub') }}
</Button>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div data-testid="missing-node-card" class="px-4 pb-2">
<div data-testid="missing-node-card" class="px-3">
<!-- Core node version warning (OSS only) -->
<div
v-if="!isCloud && hasMissingCoreNodes"
@@ -56,7 +56,7 @@
>
</template>
</i18n-t>
<div class="flex flex-col gap-1 overflow-hidden py-2">
<div class="flex flex-col gap-1 overflow-hidden">
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
@@ -75,7 +75,7 @@
variant="secondary"
size="sm"
:disabled="isRestarting"
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
class="mt-2 h-8 w-full min-w-0 rounded-md text-xs"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />

View File

@@ -12,17 +12,17 @@
: t('rightSidePanel.missingNodePacks.expand')
"
:aria-expanded="expanded"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
@click="toggleExpand"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
:class="
cn(
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90'
)
"
/>
</Button>
<i
@@ -64,7 +64,7 @@
</button>
<span
v-else
class="min-w-0 truncate text-sm/relaxed font-normal"
class="min-w-0 truncate text-xs/relaxed font-normal"
:class="
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
"
@@ -80,7 +80,7 @@
v-if="showInfoButton && group.packId !== null"
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
@@ -89,7 +89,7 @@
<span
v-if="showNodeCount"
data-testid="missing-node-pack-count"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
>
{{ group.nodeTypes.length }}
</span>
@@ -99,7 +99,7 @@
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
class="shrink-0 focus-visible:ring-inset"
:disabled="isPackInstalled || isInstalling"
@click="handlePackInstallClick"
>
@@ -122,10 +122,10 @@
</div>
<div
v-else-if="showLoadingAction"
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
class="ml-auto flex h-6 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-sm bg-secondary-background px-2 py-1 text-xs opacity-60 select-none"
>
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
<span class="text-foreground min-w-0 truncate text-xs">
{{ t('g.loading') }}
</span>
</div>
@@ -133,7 +133,7 @@
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
class="shrink-0 focus-visible:ring-inset"
@click="
openManager({
initialTab: ManagerTab.All,
@@ -150,7 +150,7 @@
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(primaryLocatableNodeType)"
>
@@ -163,7 +163,7 @@
v-if="showNodeTypeList"
:class="
cn(
'm-0 list-none space-y-1 p-0',
'm-0 list-none p-0',
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
)
"
@@ -190,7 +190,7 @@
</button>
<span
v-else
class="text-sm/relaxed wrap-break-word text-muted-foreground"
class="text-xs/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
@@ -199,7 +199,7 @@
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
@@ -241,7 +241,7 @@ const { t } = useI18n()
const expandedOverride = ref<boolean | null>(null)
const packTextButtonClass =
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word outline-none focus:outline-none rounded-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset focus-visible:outline-none'
const { missingNodePacks, isLoading } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()

View File

@@ -78,6 +78,10 @@ describe('TabErrors.vue', () => {
rightSidePanel: {
noErrors: 'No errors',
noneSearchDesc: 'No results found',
errorsDetected: 'Error detected | Errors detected',
resolveBeforeRun: 'Resolve before running the workflow',
expand: 'Expand',
collapse: 'Collapse',
errorHelp: 'Error help',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues',
@@ -118,9 +122,6 @@ describe('TabErrors.vue', () => {
template:
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
},
PropertiesAccordionItem: {
template: '<div><slot name="label" /><slot /></div>'
},
Button: {
template: '<button v-bind="$attrs"><slot /></button>'
}
@@ -211,7 +212,13 @@ describe('TabErrors.vue', () => {
})
expect(screen.getByText('Missing connection')).toBeInTheDocument()
expect(screen.getByText('(3)')).toBeInTheDocument()
expect(
within(screen.getByTestId('error-group-execution')).getByText('3')
).toBeInTheDocument()
expect(
within(screen.getByTestId('errors-summary-hero')).getByText('3')
).toBeInTheDocument()
expect(screen.getByText('Errors detected')).toBeInTheDocument()
expect(
screen.getAllByText(
'Required input slots have no connection feeding them.'
@@ -404,7 +411,7 @@ describe('TabErrors.vue', () => {
})
const missingModelStore = useMissingModelStore()
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
expect(screen.getByText('Missing Models')).toBeInTheDocument()
expect(
screen.queryByTestId('missing-model-actions')
).not.toBeInTheDocument()
@@ -414,6 +421,40 @@ describe('TabErrors.vue', () => {
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
})
it('counts missing models per file when several share one directory', () => {
renderComponent({
missingModel: {
missingModelCandidates: [
{
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-a.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
},
{
nodeId: '2',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-b.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
}
] satisfies MissingModelCandidate[]
}
})
expect(
within(screen.getByTestId('error-group-missing-model')).getByText('2')
).toBeInTheDocument()
expect(
within(screen.getByTestId('errors-summary-hero')).getByText('2')
).toBeInTheDocument()
})
it('renders missing model display message below the section title', () => {
const missingModel = {
nodeId: '1',
@@ -431,7 +472,7 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
expect(screen.getByText('Missing Models')).toBeInTheDocument()
expect(
screen.getByText('Download a model, or open the node to replace it.')
).toBeInTheDocument()
@@ -453,7 +494,7 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
expect(screen.getByText('Missing Inputs')).toBeInTheDocument()
expect(
screen.getByText('A required media input has no file selected.')
).toBeInTheDocument()
@@ -495,6 +536,12 @@ describe('TabErrors.vue', () => {
})
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
expect(
within(screen.getByTestId('error-group-missing-media')).getByText('2')
).toBeInTheDocument()
expect(
within(screen.getByTestId('errors-summary-hero')).getByText('2')
).toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: 'Second Loader - image' })
@@ -526,7 +573,7 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
expect(screen.getByText('Swap Nodes')).toBeInTheDocument()
expect(
screen.getByText('Some nodes can be replaced with alternatives')
).toBeInTheDocument()

View File

@@ -11,49 +11,62 @@
/>
</div>
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<TransitionGroup tag="div" name="list-scale" class="relative">
<div
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
aria-live="polite"
>
<div
v-if="filteredGroups.length === 0"
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<div
v-else
class="overflow-hidden rounded-lg border border-secondary-background"
>
<!-- Errors summary hero -->
<div
v-if="filteredGroups.length === 0"
key="empty"
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
data-testid="errors-summary-hero"
class="flex items-center gap-2 bg-base-foreground/5 p-2"
>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
<span
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
>
{{ totalErrorCount }}
</span>
<span
aria-hidden="true"
class="h-9 w-px shrink-0 bg-interface-stroke"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
<span class="text-xs/tight font-semibold text-base-foreground">
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
</span>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.resolveBeforeRun') }}
</span>
</div>
</div>
<!-- Group by Class Type -->
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.groupKey"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
class="border-b border-interface-stroke"
:size="getGroupSize(group)"
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
>
<template #label>
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-2">
<i
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
/>
<span class="truncate text-destructive-background-hover">
{{ group.displayTitle }}
</span>
<span
v-if="
group.type === 'execution' &&
getExecutionGroupCount(group) > 1
"
class="text-destructive-background-hover"
>
({{ getExecutionGroupCount(group) }})
</span>
</span>
<TransitionGroup tag="div" name="list-scale" class="relative">
<ErrorCardSection
v-for="group in filteredGroups"
:key="group.groupKey"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:title="group.displayTitle"
:count="getGroupCount(group)"
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
class="border-t border-secondary-background first:border-t-0"
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
>
<template #actions>
<Button
v-if="
group.type === 'missing_node' &&
@@ -62,7 +75,7 @@
"
variant="secondary"
size="sm"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
class="shrink-0"
:disabled="isInstallingAll"
@click.stop="installAll"
>
@@ -83,7 +96,7 @@
"
variant="secondary"
size="sm"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
class="shrink-0"
@click.stop="handleReplaceAll()"
>
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
@@ -96,7 +109,7 @@
data-testid="missing-model-header-refresh"
variant="muted-textonly"
size="icon"
class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
class="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"
@@ -129,140 +142,142 @@
: ''
}}
</span>
</div>
</template>
</template>
<div
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-4 pt-1 pb-3"
>
<p
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
<div
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-3 py-1"
>
{{ group.displayMessage }}
</p>
</div>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Swap Nodes -->
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Execution Errors -->
<div v-if="isExecutionItemListGroup(group)" class="px-4">
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="item in getExecutionItemList(group)"
:key="item.key"
class="min-w-0"
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-tooltip.top="{
value: item.displayDetails || undefined,
showDelay: 300
}"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode(item.nodeId)"
>
{{ item.label }}
</button>
{{ group.displayMessage }}
</p>
</div>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Swap Nodes -->
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Execution Errors -->
<div v-if="isExecutionItemListGroup(group)" class="px-3">
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="item in getExecutionItemList(group)"
:key="item.key"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-tooltip.top="{
value: item.displayDetails || undefined,
showDelay: 300
}"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="handleLocateNode(item.nodeId)"
>
{{ item.label }}
</button>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
isExecutionItemDetailExpanded(item.key) &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="
t('rightSidePanel.infoFor', { item: item.label })
"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
@click.stop="toggleExecutionItemDetail(item.key)"
>
<i class="icon-[lucide--info] size-3.5" />
</Button>
</span>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
isExecutionItemDetailExpanded(item.key) &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="
t('rightSidePanel.infoFor', { item: item.label })
t('rightSidePanel.locateNodeFor', { item: item.label })
"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
@click.stop="toggleExecutionItemDetail(item.key)"
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--info] size-3.5" />
<i class="icon-[lucide--locate] size-4" />
</Button>
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="
t('rightSidePanel.locateNodeFor', { item: item.label })
"
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<p
v-if="
item.displayDetails &&
isExecutionItemDetailExpanded(item.key)
"
:id="getExecutionItemDetailId(item.key)"
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ item.displayDetails }}
</p>
</TransitionCollapse>
</li>
</ul>
</div>
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
</div>
<TransitionCollapse>
<p
v-if="
item.displayDetails &&
isExecutionItemDetailExpanded(item.key)
"
:id="getExecutionItemDetailId(item.key)"
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ item.displayDetails }}
</p>
</TransitionCollapse>
</li>
</ul>
</div>
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Models -->
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
@locate-model="handleLocateAssetNode"
/>
</div>
<!-- Missing Models -->
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
@locate-model="handleLocateAssetNode"
/>
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
@locate-node="handleLocateAssetNode"
/>
</PropertiesAccordionItem>
</TransitionGroup>
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
@locate-node="handleLocateAssetNode"
/>
</ErrorCardSection>
</TransitionGroup>
</div>
</div>
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
<!-- Fixed Footer: Help Links -->
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
<div
class="min-w-0 shrink-0 border-t border-interface-stroke bg-interface-panel-surface p-4"
>
<i18n-t
keypath="rightSidePanel.errorHelp"
tag="p"
@@ -304,15 +319,16 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import ErrorCardSection from './ErrorCardSection.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
@@ -356,16 +372,6 @@ const searchQuery = ref('')
const expandedExecutionItemDetailKeys = ref(new Set<string>())
const isSearching = computed(() => searchQuery.value.trim() !== '')
const fullSizeGroupTypes = new Set([
'missing_node',
'swap_nodes',
'missing_model',
'missing_media'
])
function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
}
function isExecutionItemListGroup(group: ErrorGroup) {
return (
group.type === 'execution' &&
@@ -452,6 +458,28 @@ const {
swapNodeGroups
} = useErrorGroups(searchQuery)
function getGroupCount(group: ErrorGroup): number {
switch (group.type) {
case 'execution':
return getExecutionGroupCount(group)
case 'missing_node':
return missingPackGroups.value.length
case 'swap_nodes':
return swapNodeGroups.value.length
case 'missing_model':
return missingModelGroups.value.reduce(
(total, modelGroup) => total + modelGroup.models.length,
0
)
case 'missing_media':
return countMissingMediaReferences(missingMediaGroups.value)
}
}
const totalErrorCount = computed(() =>
filteredGroups.value.reduce((sum, group) => sum + getGroupCount(group), 0)
)
const showMissingModelHeaderRefresh = computed(
() => !isCloud && missingModelGroups.value.length > 0
)

View File

@@ -334,7 +334,7 @@ describe('useErrorGroups', () => {
)
expect(missingGroup).toBeDefined()
expect(missingGroup?.groupKey).toBe('missing_node')
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
expect(missingGroup?.displayTitle).toBe('Missing Node Packs')
expect(missingGroup?.displayMessage).toBe(
'Install missing packs to use this workflow.'
)
@@ -982,7 +982,7 @@ describe('useErrorGroups', () => {
)
expect(modelGroup).toBeDefined()
expect(modelGroup?.groupKey).toBe('missing_model')
expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
expect(modelGroup?.displayTitle).toBe('Missing Models')
})
})
@@ -1098,7 +1098,7 @@ describe('useErrorGroups', () => {
const missingMediaGroup = groups.allErrorGroups.value.find(
(group) => group.type === 'missing_media'
)
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs')
})
})

View File

@@ -0,0 +1,180 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import LinkReleaseContextMenu from './LinkReleaseContextMenu.vue'
import type {
LinkReleaseNodeCategory,
LinkReleaseSearchResultGroup
} from './linkReleaseMenuModel'
const { groups } = vi.hoisted(() => ({
groups: {
suggestions: [] as ComfyNodeDefImpl[],
categories: [] as LinkReleaseNodeCategory[],
searchResultGroups: [] as LinkReleaseSearchResultGroup[]
}
}))
vi.mock('./linkReleaseMenuModel', () => ({
getLinkReleaseHeaderLabel: () => '',
getLinkReleaseSuggestions: () => groups.suggestions,
buildLinkReleaseNodeCategories: () => groups.categories,
groupLinkReleaseSearchResults: () => groups.searchResultGroups,
searchLinkReleaseNodes: () =>
groups.searchResultGroups.flatMap((group) =>
group.nodes.map((node) => ({ category: group.category, node }))
),
filterNodesByName: () => []
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
const stubs = {
DropdownMenuRoot: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div role="menu"><slot /></div>' },
DropdownMenuLabel: { template: '<div><slot /></div>' },
DropdownMenuItem: {
emits: ['select'],
template:
'<div role="menuitem" tabindex="-1" @click="$emit(\'select\')"><slot /></div>'
},
DropdownMenuSeparator: { template: '<hr data-testid="menu-separator" />' },
LinkReleaseNodeSubmenu: { template: '<div data-testid="submenu" />' },
MiddleTruncate: { template: '<span>{{ text }}</span>', props: ['text'] }
}
function suggestion(name: string): ComfyNodeDefImpl {
return { name, display_name: name } as ComfyNodeDefImpl
}
function nodeCategory(
key: 'comfy' | 'extensions' | 'partner',
labelKey: string = key
): LinkReleaseNodeCategory {
return { key, labelKey, icon: '', nodes: [suggestion('Node')] }
}
function renderMenu() {
return render(LinkReleaseContextMenu, {
props: { context: null },
global: { plugins: [i18n, createTestingPinia()], stubs }
})
}
describe('LinkReleaseContextMenu group divider', () => {
beforeEach(() => {
groups.suggestions = []
groups.categories = []
groups.searchResultGroups = []
})
it('renders a divider between the suggestions and categories groups', () => {
groups.suggestions = [suggestion('KSampler')]
groups.categories = [nodeCategory('comfy')]
renderMenu()
expect(screen.getAllByTestId('menu-separator')).toHaveLength(3)
})
it('omits the group divider when only one group is present', () => {
groups.suggestions = []
groups.categories = [nodeCategory('comfy')]
renderMenu()
expect(screen.getAllByTestId('menu-separator')).toHaveLength(2)
})
it('renders a divider between search result groups', async () => {
groups.suggestions = []
groups.categories = []
groups.searchResultGroups = [
{
category: nodeCategory('extensions', 'contextMenu.Extensions'),
nodes: [suggestion('Ext Node')]
},
{
category: nodeCategory('partner', 'contextMenu.Partner Nodes'),
nodes: [suggestion('Partner Node')]
}
]
renderMenu()
await userEvent.type(screen.getByRole('textbox'), 'na')
expect(screen.getAllByTestId('menu-separator')).toHaveLength(2)
})
})
describe('LinkReleaseContextMenu selection', () => {
beforeEach(() => {
groups.suggestions = []
groups.categories = []
groups.searchResultGroups = []
})
function renderMenuWith(handlers: Record<string, unknown>) {
return render(LinkReleaseContextMenu, {
props: { context: null, ...handlers },
global: { plugins: [i18n, createTestingPinia()], stubs }
})
}
it('emits selectNode when a suggestion is chosen', async () => {
groups.suggestions = [suggestion('KSampler')]
const onSelectNode = vi.fn()
renderMenuWith({ onSelectNode })
await userEvent.click(screen.getByText('KSampler'))
expect(onSelectNode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'KSampler' })
)
})
it('emits addReroute when the reroute item is chosen', async () => {
groups.suggestions = [suggestion('KSampler')]
const onAddReroute = vi.fn()
renderMenuWith({ onAddReroute })
await userEvent.click(screen.getByText('contextMenu.Add Reroute'))
expect(onAddReroute).toHaveBeenCalled()
})
it('selects the first search result on Enter in the search field', async () => {
groups.searchResultGroups = [
{
category: nodeCategory('comfy', 'contextMenu.Comfy Nodes'),
nodes: [suggestion('Found Node')]
}
]
const onSelectNode = vi.fn()
renderMenuWith({ onSelectNode })
const search = screen.getByRole('textbox')
await userEvent.type(search, 'fo')
await userEvent.keyboard('{Enter}')
expect(onSelectNode).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Found Node' })
)
})
it('moves focus to the first item on ArrowDown from the search', async () => {
groups.suggestions = [suggestion('KSampler')]
renderMenu()
const search = screen.getByRole('textbox')
search.focus()
await userEvent.keyboard('{ArrowDown}')
expect(screen.getAllByRole('menuitem')[0]).toHaveFocus()
})
})

View File

@@ -0,0 +1,384 @@
<template>
<DropdownMenuRoot :open="open" :modal="false" @update:open="onOpenChange">
<DropdownMenuTrigger as-child>
<div
aria-hidden="true"
class="pointer-events-none fixed size-0"
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
/>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="bottom"
align="start"
:side-offset="SIDE_OFFSET"
:collision-padding="VIEWPORT_MARGIN"
:avoid-collisions="false"
:class="contentClass"
:style="menuMaxHeight ? { maxHeight: `${menuMaxHeight}px` } : undefined"
data-testid="link-release-context-menu"
@open-auto-focus.prevent="focusSearch"
@close-auto-focus.prevent
@entry-focus="onEntryFocus"
@keydown.capture="redirectTypingToSearch"
>
<DropdownMenuLabel
v-if="headerLabel"
class="flex shrink-0 items-center gap-2 p-2 text-xs font-medium text-muted-foreground uppercase"
>
<span class="flex size-4 shrink-0 items-center justify-center">
<span
class="size-4 rounded-full"
:style="{ backgroundColor: slotColor }"
/>
</span>
<span class="truncate">{{ headerLabel }}</span>
</DropdownMenuLabel>
<div data-search-field class="shrink-0 p-0.5">
<div
class="flex h-9 items-center gap-2 rounded-lg bg-secondary-background px-2"
>
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
/>
<input
ref="searchInput"
v-model="query"
type="text"
data-testid="link-release-search"
:placeholder="t('contextMenu.Search')"
class="size-full min-w-0 appearance-none border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
@keydown="onRootSearchKeydown"
/>
</div>
</div>
<DropdownMenuSeparator
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<div :class="scrollClass">
<template v-if="trimmedQuery">
<template
v-for="(group, groupIndex) in searchResultGroups"
:key="group.category.key"
>
<DropdownMenuSeparator
v-if="groupIndex > 0"
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<DropdownMenuItem
v-for="node in group.nodes"
:key="node.name"
:class="itemClass"
@select="selectNode(node)"
>
<i
:class="cn(group.category.icon, 'size-4 shrink-0 opacity-80')"
/>
<span class="flex min-w-0 flex-1 items-center gap-1">
<span class="shrink-0 text-muted-foreground">
{{ t(group.category.labelKey) }}:
</span>
<MiddleTruncate
:text="node.display_name"
class="min-w-0 flex-1"
/>
</span>
</DropdownMenuItem>
</template>
<div
v-if="searchResults.length === 0"
class="p-2 text-sm text-muted-foreground"
>
{{ t('g.noResults') }}
</div>
</template>
<template v-else>
<template v-if="suggestions.length">
<DropdownMenuLabel
class="block truncate p-2 text-xs font-medium text-muted-foreground uppercase"
>
{{ t('contextMenu.Most Relevant') }}
</DropdownMenuLabel>
<DropdownMenuItem
v-for="nodeDef in suggestions"
:key="nodeDef.name"
:class="itemClass"
@select="selectNode(nodeDef)"
>
<MiddleTruncate
:text="nodeDef.display_name"
class="min-w-0 flex-1"
/>
</DropdownMenuItem>
</template>
<DropdownMenuSeparator
v-if="suggestions.length && categories.length"
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<template v-if="categories.length">
<DropdownMenuLabel
class="block truncate p-2 text-xs font-medium text-muted-foreground uppercase"
>
{{ t('contextMenu.Compatible Nodes') }}
</DropdownMenuLabel>
<LinkReleaseNodeSubmenu
v-for="category in categories"
:key="category.key"
:category
:item-class="itemClass"
:content-class="submenuContentClass"
:scroll-class="submenuScrollClass"
@select="selectNode"
/>
</template>
</template>
</div>
<template v-if="!trimmedQuery">
<DropdownMenuSeparator
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<DropdownMenuItem
:class="cn(itemClass, 'shrink-0')"
@select="addReroute"
>
<i class="icon-[lucide--git-fork] size-4 shrink-0 opacity-80" />
<span class="flex-1 truncate">
{{ t('contextMenu.Add Reroute') }}
</span>
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getSlotColor } from '@/constants/slotColors'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
import MiddleTruncate from './MiddleTruncate.vue'
import {
buildLinkReleaseNodeCategories,
computeContextMenuTop,
estimateLinkReleaseMenuHeight,
getLinkReleaseHeaderLabel,
getLinkReleaseSuggestions,
groupLinkReleaseSearchResults,
searchLinkReleaseNodes
} from './linkReleaseMenuModel'
import type {
LinkReleaseContext,
LinkReleaseNodeMatch
} from './linkReleaseMenuModel'
const { context } = defineProps<{ context: LinkReleaseContext | null }>()
const emit = defineEmits<{
selectNode: [nodeDef: ComfyNodeDefImpl]
addReroute: []
dismiss: []
}>()
const { t } = useI18n()
const nodeDefStore = useNodeDefStore()
const open = ref(false)
const position = ref({ x: 0, y: 0 })
const searchInput = ref<HTMLInputElement>()
const query = ref('')
const menuMaxHeight = ref<number>()
let actionTaken = false
const VIEWPORT_MARGIN = 8
const SIDE_OFFSET = 4
const MENU_WIDTH = 384
const contentClass =
'z-1700 flex min-w-[260px] max-w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
const scrollClass =
'flex-1 min-h-0 overflow-y-auto overflow-x-hidden scrollbar-custom'
const submenuContentClass =
'z-1700 flex w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
const submenuScrollClass =
'flex-1 min-h-0 overflow-y-auto overflow-x-hidden scrollbar-custom'
const itemClass =
'flex cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-sm text-base-foreground outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-interface-menu-component-surface-hovered'
const headerLabel = computed(() =>
context ? getLinkReleaseHeaderLabel(context) : ''
)
const slotColor = computed(() => getSlotColor(context?.dataType?.split(',')[0]))
const trimmedQuery = computed(() => query.value.trim())
const typeFilter = computed(() => {
if (!context) return null
const svc = nodeDefStore.nodeSearchService
return {
filterDef: context.isFromOutput
? svc.inputTypeFilter
: svc.outputTypeFilter,
value: context.dataType
}
})
const compatibleNodes = computed<ComfyNodeDefImpl[]>(() => {
if (!typeFilter.value) return []
return nodeDefStore.nodeSearchService.searchNode('', [typeFilter.value], {
limit: 500
})
})
const defaultNodeDefs = computed<ComfyNodeDefImpl[]>(() => {
if (!context?.dataType) return []
const table = context.isFromOutput
? LiteGraph.slot_types_default_out
: LiteGraph.slot_types_default_in
const types = table?.[context.dataType] ?? []
return types
.map((type) => nodeDefStore.allNodeDefsByName[type])
.filter((nodeDef): nodeDef is ComfyNodeDefImpl => Boolean(nodeDef))
})
const suggestions = computed(() =>
getLinkReleaseSuggestions(defaultNodeDefs.value)
)
const categories = computed(() =>
buildLinkReleaseNodeCategories(compatibleNodes.value)
)
const searchResultGroups = computed(() =>
groupLinkReleaseSearchResults(categories.value, trimmedQuery.value)
)
const searchResults = computed<LinkReleaseNodeMatch[]>(() =>
searchLinkReleaseNodes(categories.value, trimmedQuery.value)
)
function selectNode(nodeDef: ComfyNodeDefImpl) {
actionTaken = true
emit('selectNode', nodeDef)
hide()
}
function addReroute() {
actionTaken = true
emit('addReroute')
hide()
}
function focusSearch() {
searchInput.value?.focus()
}
function isPrintableKey(event: KeyboardEvent) {
return (
event.key.length === 1 &&
event.key !== ' ' &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
)
}
// When the keyboard focus is on a menu item, funnel printable keystrokes into
// the search field instead of letting Reka run its item type-ahead.
function redirectTypingToSearch(event: KeyboardEvent) {
if (event.target === searchInput.value || !isPrintableKey(event)) return
event.preventDefault()
event.stopPropagation()
query.value += event.key
focusSearch()
}
// Reka refocuses the first item (scrolling the list to the top) whenever the
// menu regains focus, which fires as the pointer leaves an item while scrolling.
function onEntryFocus(event: Event) {
event.preventDefault()
}
function focusFirstItem(target: HTMLElement) {
const menu = target.closest<HTMLElement>('[role="menu"]')
menu
?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
?.focus()
}
function onRootSearchKeydown(event: KeyboardEvent) {
// Let Reka close the menu natively on Escape.
if (event.key === 'Escape') return
event.stopPropagation()
if (event.key === 'ArrowDown') {
event.preventDefault()
focusFirstItem(event.currentTarget as HTMLElement)
} else if (event.key === 'Enter' && trimmedQuery.value) {
const first = searchResults.value[0]
if (first) selectNode(first.node)
}
}
function show(event: MouseEvent) {
actionTaken = false
query.value = ''
const menuHeight = estimateLinkReleaseMenuHeight({
hasHeader: Boolean(headerLabel.value),
suggestionCount: suggestions.value.length,
categoryCount: categories.value.length,
searchResultCount: 0,
showReroute: true
})
const menuTop = computeContextMenuTop({
cursorY: event.clientY,
menuHeight,
viewportHeight: window.innerHeight,
margin: VIEWPORT_MARGIN,
sideOffset: SIDE_OFFSET
})
menuMaxHeight.value = window.innerHeight - menuTop - VIEWPORT_MARGIN
const maxX = Math.max(
VIEWPORT_MARGIN,
window.innerWidth - MENU_WIDTH - VIEWPORT_MARGIN
)
position.value = {
x: Math.min(maxX, Math.max(VIEWPORT_MARGIN, event.clientX)),
y: menuTop - SIDE_OFFSET
}
void nextTick(() => {
open.value = true
})
}
function hide() {
open.value = false
}
function onOpenChange(value: boolean) {
open.value = value
if (value) return
if (!actionTaken) emit('dismiss')
actionTaken = false
}
defineExpose({ show, hide })
</script>

View File

@@ -0,0 +1,120 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import {
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
const contentClass =
'z-1700 flex max-h-[min(80vh,var(--reka-dropdown-menu-content-available-height))] min-w-[260px] max-w-sm flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
const submenuContentClass =
'z-1700 flex w-sm max-h-[min(80vh,var(--reka-dropdown-menu-content-available-height))] flex-col overflow-hidden rounded-lg border border-interface-menu-stroke bg-interface-menu-surface p-1 shadow-interface'
const submenuScrollClass =
'overflow-y-auto scrollbar-custom max-h-[min(calc(var(--reka-dropdown-menu-content-available-height)-3.5rem),80vh)]'
const itemClass =
'flex cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-base-foreground outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-interface-menu-component-surface-hovered'
function node(name: string, display_name = name): ComfyNodeDefImpl {
return { name, display_name } as ComfyNodeDefImpl
}
const category: LinkReleaseNodeCategory = {
key: 'comfy',
labelKey: 'contextMenu.Comfy Nodes',
icon: 'icon-[lucide--box]',
nodes: [
node('KSampler'),
node('VAEDecode', 'VAE Decode'),
node('VAEEncode', 'VAE Encode'),
node('CLIPTextEncode', 'CLIP Text Encode'),
node('LoadImage', 'Load Image'),
node('SaveImage', 'Save Image'),
node('EmptyLatentImage', 'Empty Latent Image'),
node(
'StableCascade_StageB_Conditioning',
'StableCascade_StageB_Conditioning'
)
]
}
const meta: Meta<typeof LinkReleaseNodeSubmenu> = {
title: 'Components/Searchbox/LinkReleaseNodeSubmenu',
component: LinkReleaseNodeSubmenu
}
export default meta
type Story = StoryObj<typeof meta>
function renderAnchored(side: 'left' | 'right'): Story['render'] {
return () => ({
components: {
DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuContent,
DropdownMenuLabel,
LinkReleaseNodeSubmenu
},
setup() {
const anchorStyle =
side === 'right'
? 'position: fixed; top: 64px; right: 16px;'
: 'position: fixed; top: 64px; left: 16px;'
return {
anchorStyle,
contentClass,
submenuContentClass,
submenuScrollClass,
itemClass,
category,
side
}
},
template: `
<div style="height: 480px;">
<DropdownMenuRoot default-open>
<DropdownMenuTrigger as-child>
<button :style="anchorStyle" class="rounded-md border border-interface-menu-stroke bg-interface-menu-surface px-3 py-1.5 text-sm text-base-foreground">
Compatible Nodes
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:class="contentClass"
:side="side === 'right' ? 'bottom' : 'bottom'"
:align="side === 'right' ? 'end' : 'start'"
:side-offset="4"
>
<DropdownMenuLabel class="block truncate px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase">
Compatible Nodes
</DropdownMenuLabel>
<LinkReleaseNodeSubmenu
:category="category"
:item-class="itemClass"
:content-class="submenuContentClass"
:scroll-class="submenuScrollClass"
/>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
`
})
}
/** Anchored near the LEFT edge: the submenu opens to the RIGHT (normal). */
export const OpensRight: Story = { render: renderAnchored('left') }
/**
* Anchored near the RIGHT edge: with no room on the right, Floating UI flips the
* submenu to the LEFT, landing flush against the parent menu's left edge.
*/
export const FlipsLeft: Story = { render: renderAnchored('right') }

View File

@@ -0,0 +1,138 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { Slots } from 'vue'
import { computed, h, inject, nextTick, provide } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import LinkReleaseNodeSubmenu from './LinkReleaseNodeSubmenu.vue'
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
const category: LinkReleaseNodeCategory = {
key: 'comfy',
labelKey: 'Comfy Nodes',
icon: 'icon-[lucide--box]',
nodes: [{ name: 'KSampler', display_name: 'KSampler' } as ComfyNodeDefImpl]
}
const SUB_OPEN = Symbol('subOpen')
const stubs = {
DropdownMenuSub: {
props: ['open'],
setup(props: { open?: boolean }, { slots }: { slots: Slots }) {
provide(
SUB_OPEN,
computed(() => props.open ?? false)
)
return () => h('div', slots.default?.())
}
},
DropdownMenuSubTrigger: {
template: '<button data-testid="sub-trigger"><slot /></button>'
},
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuSubContent: {
setup(_: unknown, { slots }: { slots: Slots }) {
const open = inject<{ value: boolean }>(SUB_OPEN)
return () =>
open?.value ? h('div', { role: 'menu' }, slots.default?.()) : null
}
},
DropdownMenuSeparator: { template: '<hr />' },
DropdownMenuItem: {
template: '<div role="menuitem" tabindex="-1"><slot /></div>'
},
MiddleTruncate: { template: '<span>{{ text }}</span>', props: ['text'] }
}
function renderSubmenu() {
return render(LinkReleaseNodeSubmenu, {
props: { category, itemClass: '', contentClass: '', scrollClass: '' },
global: { plugins: [i18n], stubs }
})
}
describe('LinkReleaseNodeSubmenu keyboard handling', () => {
it('steps into the submenu search on ArrowRight', async () => {
renderSubmenu()
await userEvent.click(screen.getByTestId('sub-trigger'))
await userEvent.keyboard('{ArrowRight}')
await nextTick()
expect(screen.getByRole('textbox')).toHaveFocus()
})
it('steps into the submenu search on Enter', async () => {
renderSubmenu()
await userEvent.click(screen.getByTestId('sub-trigger'))
await userEvent.keyboard('{Enter}')
await nextTick()
expect(screen.getByRole('textbox')).toHaveFocus()
})
it('does not move focus to the search on other keys', async () => {
renderSubmenu()
await userEvent.click(screen.getByTestId('sub-trigger'))
await userEvent.keyboard('a')
await nextTick()
expect(screen.getByRole('textbox')).not.toHaveFocus()
})
async function stepIntoSearch() {
await userEvent.click(screen.getByTestId('sub-trigger'))
await userEvent.keyboard('{ArrowRight}')
await nextTick()
}
it('selects the first filtered node on Enter in the search', async () => {
const onSelect = vi.fn()
render(LinkReleaseNodeSubmenu, {
props: {
category,
itemClass: '',
contentClass: '',
scrollClass: '',
onSelect
},
global: { plugins: [i18n], stubs }
})
await stepIntoSearch()
expect(screen.getByRole('textbox')).toHaveFocus()
await userEvent.keyboard('{Enter}')
expect(onSelect).toHaveBeenCalledWith(category.nodes[0])
})
it('does not select on Escape in the search', async () => {
const onSelect = vi.fn()
render(LinkReleaseNodeSubmenu, {
props: {
category,
itemClass: '',
contentClass: '',
scrollClass: '',
onSelect
},
global: { plugins: [i18n], stubs }
})
await stepIntoSearch()
await userEvent.keyboard('{Escape}')
expect(onSelect).not.toHaveBeenCalled()
})
it('moves focus to the first node on ArrowDown from the search', async () => {
renderSubmenu()
await stepIntoSearch()
await userEvent.keyboard('{ArrowDown}')
expect(screen.getByRole('menuitem')).toHaveFocus()
})
})

View File

@@ -0,0 +1,242 @@
<template>
<DropdownMenuSub v-model:open="open">
<DropdownMenuSubTrigger
ref="triggerRef"
:class="triggerClass"
@focus="open = true"
@keydown="onTriggerKeydown"
@blur="onTriggerBlur"
>
<i :class="cn(category.icon, 'size-4 shrink-0 opacity-80')" />
<span class="flex-1 truncate">{{ t(category.labelKey) }}</span>
<span
class="rounded-full bg-interface-menu-keybind-surface-default px-1.5 text-xs text-muted-foreground"
>
{{ category.nodes.length }}
</span>
<i class="icon-[lucide--chevron-right] size-4 shrink-0 opacity-60" />
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<!--
Opens to the right of the trigger; when there's no room, Floating UI
flips it to the LEFT. align-offset is computed per-open
(alignToContextMenu) so the submenu's search field lines up with the
root search field instead of the hovered trigger row. The height is also
pinned per-open: maxHeight grows into the viewport space below the
submenu top but never drops under the context menu height, so the panel
scrolls internally instead of letting Floating UI shift it upward.
-->
<DropdownMenuSubContent
:class="contentClass"
:style="maxHeight ? { maxHeight: `${maxHeight}px` } : undefined"
side="right"
align="start"
:side-offset="-2"
:align-offset="alignOffset"
:collision-padding="8"
update-position-strategy="optimized"
@open-auto-focus.prevent
@entry-focus="onEntryFocus"
@keydown.capture="redirectTypingToSearch"
>
<div class="shrink-0 p-0.5">
<div
class="flex h-9 items-center gap-2 rounded-lg bg-secondary-background px-2"
>
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
/>
<input
ref="searchInput"
v-model="query"
type="text"
:aria-label="
t('g.searchPlaceholder', { subject: t(category.labelKey) })
"
:placeholder="
t('g.searchPlaceholder', { subject: t(category.labelKey) })
"
class="size-full min-w-0 appearance-none border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
@keydown="onSearchKeydown"
/>
</div>
</div>
<DropdownMenuSeparator
class="-mx-1 my-1 h-px shrink-0 bg-border-subtle"
/>
<div :class="scrollClass">
<DropdownMenuItem
v-for="nodeDef in filteredNodes"
:key="nodeDef.name"
:class="itemClass"
@select="emit('select', nodeDef)"
>
<MiddleTruncate
:text="nodeDef.display_name"
class="min-w-0 flex-1 self-stretch"
/>
</DropdownMenuItem>
<div
v-if="filteredNodes.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
{{ t('g.noResults') }}
</div>
</div>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from 'reka-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import MiddleTruncate from './MiddleTruncate.vue'
import {
computeSubmenuAlignOffset,
computeSubmenuMaxHeight,
filterNodesByName
} from './linkReleaseMenuModel'
import type { LinkReleaseNodeCategory } from './linkReleaseMenuModel'
const { category, itemClass, contentClass, scrollClass } = defineProps<{
category: LinkReleaseNodeCategory
itemClass: string
contentClass: string
scrollClass: string
}>()
const emit = defineEmits<{
select: [nodeDef: ComfyNodeDefImpl]
}>()
const { t } = useI18n()
const open = ref(false)
const query = ref('')
const searchInput = ref<HTMLInputElement>()
const triggerRef = ref<InstanceType<typeof DropdownMenuSubTrigger>>()
// Pin the submenu's search field to the root search field rather than to the
// hovered trigger row; both recomputed each time the submenu opens.
const alignOffset = ref(-5)
const maxHeight = ref<number>()
const VIEWPORT_MARGIN = 8
const triggerClass = computed(() =>
cn(itemClass, 'data-[state=open]:bg-interface-menu-component-surface-hovered')
)
const filteredNodes = computed(() =>
filterNodesByName(category.nodes, query.value)
)
function alignToContextMenu() {
const triggerEl = triggerRef.value?.$el as HTMLElement | undefined
const rootMenu = triggerEl?.closest<HTMLElement>('[role="menu"]')
const rootSearch = rootMenu?.querySelector<HTMLElement>('[data-search-field]')
if (!triggerEl || !rootMenu || !rootSearch) return
const triggerTop = triggerEl.getBoundingClientRect().top
const rootRect = rootMenu.getBoundingClientRect()
const rootSearchTop = rootSearch.getBoundingClientRect().top
const contentPaddingTop = parseFloat(getComputedStyle(rootMenu).paddingTop)
alignOffset.value = computeSubmenuAlignOffset({
triggerTop,
rootSearchTop,
contentPaddingTop
})
maxHeight.value = computeSubmenuMaxHeight({
submenuTop: rootSearchTop - contentPaddingTop,
contextMenuHeight: rootRect.height,
viewportHeight: window.innerHeight,
margin: VIEWPORT_MARGIN
})
}
watch(open, (isOpen) => {
if (isOpen) alignToContextMenu()
else query.value = ''
})
function focusSearch() {
searchInput.value?.focus()
}
function submenuContent() {
return searchInput.value?.closest<HTMLElement>('[role="menu"]') ?? null
}
// Step into the open submenu, landing on its search field.
function onTriggerKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowRight' && event.key !== 'Enter') return
event.preventDefault()
open.value = true
void nextTick(focusSearch)
}
// Close the preview when focus leaves the trigger to a sibling item rather
// than into the submenu content.
function onTriggerBlur(event: FocusEvent) {
const next = event.relatedTarget
if (next instanceof Node && submenuContent()?.contains(next)) return
open.value = false
}
function isPrintableKey(event: KeyboardEvent) {
return (
event.key.length === 1 &&
event.key !== ' ' &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
)
}
// When the keyboard focus is on a submenu item, funnel printable keystrokes
// into this submenu's search field instead of Reka's item type-ahead.
function redirectTypingToSearch(event: KeyboardEvent) {
if (event.target === searchInput.value || !isPrintableKey(event)) return
event.preventDefault()
event.stopPropagation()
query.value += event.key
focusSearch()
}
// Reka refocuses the first item (scrolling the list to the top) whenever the
// menu regains focus, which fires as the pointer leaves an item while scrolling.
function onEntryFocus(event: Event) {
event.preventDefault()
}
function focusFirstNode(target: HTMLElement) {
const panel = target.closest<HTMLElement>('[role="menu"]')
panel
?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
?.focus()
}
function onSearchKeydown(event: KeyboardEvent) {
// Let Reka handle submenu/menu navigation keys natively.
if (event.key === 'Escape' || event.key === 'ArrowLeft') return
event.stopPropagation()
if (event.key === 'ArrowDown') {
event.preventDefault()
focusFirstNode(event.currentTarget as HTMLElement)
} else if (event.key === 'Enter') {
const first = filteredNodes.value[0]
if (first) emit('select', first)
}
}
</script>

View File

@@ -0,0 +1,182 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MiddleTruncate from './MiddleTruncate.vue'
import * as overflow from './isTextOverflowing'
function stubRect(el: HTMLElement, rect: Partial<DOMRect>) {
el.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({}),
...rect
}) as DOMRect
}
describe('MiddleTruncate', () => {
beforeEach(() => {
Object.defineProperty(document.documentElement, 'clientWidth', {
configurable: true,
value: 1024
})
})
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(document.documentElement, 'clientWidth')
})
it('renders the full text inline', () => {
render(MiddleTruncate, { props: { text: 'KSampler' } })
expect(screen.getByText('KSampler')).toBeInTheDocument()
})
it('does not reveal a tooltip when the text fits', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(0)
render(MiddleTruncate, { props: { text: 'KSampler' } })
await userEvent.hover(screen.getByText('KSampler'))
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('reveals the full text on hover when truncated', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
const longName = 'ONNX Detector (SEGS/legacy) - use BBOXDetector'
render(MiddleTruncate, { props: { text: longName } })
const el = screen.getByText(longName)
stubRect(el, { left: 10, top: 20, width: 100, height: 20 })
await userEvent.hover(el)
expect(screen.getByRole('tooltip')).toHaveTextContent(longName)
})
it('reveals when hovering anywhere on the parent menu item', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
const longName = 'ONNX Detector (SEGS/legacy) - use BBOXDetector'
render({
components: { MiddleTruncate },
template: `<div role="menuitem"><MiddleTruncate text="${longName}" /></div>`
})
stubRect(screen.getByText(longName), {
left: 10,
top: 20,
width: 120,
height: 20
})
await userEvent.hover(screen.getByRole('menuitem'))
expect(screen.getByRole('tooltip')).toHaveTextContent(longName)
})
it('sizes the reveal to the parent menu item height', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
const nodeName = 'A long truncated node name'
render({
components: { MiddleTruncate },
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /></div>`
})
stubRect(screen.getByText(nodeName), {
left: 10,
top: 20,
width: 100,
height: 20
})
stubRect(screen.getByRole('menuitem'), {
left: 0,
top: 10,
right: 200,
width: 200,
height: 36
})
await userEvent.hover(screen.getByText(nodeName))
expect(screen.getByRole('tooltip')).toHaveStyle({ height: '36px' })
})
it('anchors the reveal to the left when it fits to the right', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(50)
const nodeName = 'Fits To The Right'
render({
components: { MiddleTruncate },
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /></div>`
})
stubRect(screen.getByText(nodeName), {
left: 10,
top: 20,
width: 100,
height: 20
})
stubRect(screen.getByRole('menuitem'), {
left: 0,
top: 10,
right: 200,
width: 200,
height: 36
})
await userEvent.hover(screen.getByText(nodeName))
expect(screen.getByRole('tooltip')).toHaveStyle({ left: '10px' })
})
it('flips to a right anchor when revealing rightward would overflow', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(600)
const nodeName = 'A very long node name near the right edge'
render({
components: { MiddleTruncate },
template: `<div role="menuitem" style="padding-right: 16px"><MiddleTruncate text="${nodeName}" /></div>`
})
stubRect(screen.getByText(nodeName), {
left: 850,
top: 20,
width: 150,
height: 20
})
stubRect(screen.getByRole('menuitem'), {
left: 840,
top: 10,
right: 1000,
width: 160,
height: 36
})
await userEvent.hover(screen.getByText(nodeName))
const tooltip = screen.getByRole('tooltip')
// Anchored to the item's right edge (1024 - 1000), independent of its padding.
expect(tooltip).toHaveStyle({ right: '24px' })
expect(tooltip).not.toHaveStyle({ left: '850px' })
})
it('hides the reveal when the pointer leaves the menu item', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
const nodeName = 'A long truncated node name'
render({
components: { MiddleTruncate },
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /></div>`
})
const el = screen.getByText(nodeName)
stubRect(el, { left: 10, top: 20, width: 100, height: 20 })
await userEvent.hover(el)
expect(screen.getByRole('tooltip')).toBeInTheDocument()
await userEvent.unhover(el)
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('keeps the reveal while the pointer moves within the menu item', async () => {
vi.spyOn(overflow, 'measureTextWidth').mockReturnValue(500)
const nodeName = 'A long truncated node name'
render({
components: { MiddleTruncate },
template: `<div role="menuitem"><MiddleTruncate text="${nodeName}" /><span data-testid="sibling">x</span></div>`
})
const el = screen.getByText(nodeName)
stubRect(el, { left: 10, top: 20, width: 100, height: 20 })
await userEvent.hover(el)
expect(screen.getByRole('tooltip')).toBeInTheDocument()
await userEvent.pointer({ target: screen.getByTestId('sibling') })
expect(screen.getByRole('tooltip')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,156 @@
<template>
<span
ref="elRef"
v-bind="$attrs"
:class="cn('block min-w-0 truncate', revealed && 'text-transparent')"
@pointerenter="reveal"
@pointermove="reveal"
@pointerleave="onPointerLeave"
@focusin="reveal"
@focusout="hide"
>
{{ text }}
</span>
<Teleport to="body">
<span
v-if="revealed && revealStyle"
role="tooltip"
:class="
cn(
'pointer-events-none fixed z-99999 inline-flex items-center rounded-lg bg-interface-menu-component-surface-hovered pr-3 text-sm whitespace-nowrap text-base-foreground shadow-interface',
revealRect?.anchor === 'right' && 'pl-3'
)
"
:style="revealStyle"
>
{{ text }}
</span>
</Teleport>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref } from 'vue'
import { measureTextWidth } from './isTextOverflowing'
defineOptions({ inheritAttrs: false })
const { text } = defineProps<{ text: string }>()
// Gap kept between the reveal and the viewport edge (mirrors the menu's
// collision-padding) and the reveal's own far-side padding (`pl-3`/`pr-3`).
const VIEWPORT_MARGIN = 8
const REVEAL_PADDING = 12
type RevealRect = {
top: number
height: number
minWidth: number
maxWidth: number
anchor: 'left' | 'right'
offset: number
}
const elRef = ref<HTMLElement>()
const revealed = ref(false)
const revealRect = ref<RevealRect>()
const revealStyle = computed(() => {
const rect = revealRect.value
if (!rect) return undefined
return {
top: `${rect.top}px`,
height: `${rect.height}px`,
minWidth: `${rect.minWidth}px`,
maxWidth: `${rect.maxWidth}px`,
width: 'max-content',
[rect.anchor]: `${rect.offset}px`
}
})
const menuItem = computed(
() =>
elRef.value?.closest<HTMLElement>('[role="menuitem"]') ??
elRef.value?.parentElement ??
null
)
function getRevealRect(el: HTMLElement, textWidth: number): RevealRect {
const textRect = el.getBoundingClientRect()
const item = menuItem.value
const itemRect = item?.getBoundingClientRect()
const paddingRight = item
? Number.parseFloat(getComputedStyle(item).paddingRight) || 0
: 0
const rightInset = itemRect ? itemRect.right - paddingRight : textRect.right
const itemRight = itemRect ? itemRect.right : textRect.right
const viewportWidth = document.documentElement.clientWidth
const top = itemRect?.top ?? textRect.top
const height = itemRect?.height ?? textRect.height
const minWidth = Math.max(textRect.width, rightInset - textRect.left)
const neededWidth = Math.max(minWidth, textWidth + REVEAL_PADDING)
const fitsRight =
textRect.left + neededWidth <= viewportWidth - VIEWPORT_MARGIN
if (fitsRight) {
return {
top,
height,
minWidth,
maxWidth: viewportWidth - VIEWPORT_MARGIN - textRect.left,
anchor: 'left',
offset: textRect.left
}
}
return {
top,
height,
minWidth,
maxWidth: itemRight - VIEWPORT_MARGIN,
anchor: 'right',
offset: Math.max(VIEWPORT_MARGIN, viewportWidth - itemRight)
}
}
function reveal() {
const el = elRef.value
if (!el) {
revealed.value = false
return
}
const textWidth = measureTextWidth(el)
if (textWidth <= el.clientWidth + 0.5) {
revealed.value = false
return
}
revealRect.value = getRevealRect(el, textWidth)
revealed.value = true
}
function hide() {
revealed.value = false
}
function isStillOverMenuItem(related: EventTarget | null) {
const item = menuItem.value
return (
related instanceof Node &&
item != null &&
(item === related || item.contains(related))
)
}
function onPointerLeave(event: PointerEvent) {
if (isStillOverMenuItem(event.relatedTarget)) return
hide()
}
useEventListener(menuItem, 'pointerenter', reveal)
useEventListener(menuItem, 'pointermove', reveal)
useEventListener(menuItem, 'pointerleave', (event: PointerEvent) => {
if (isStillOverMenuItem(event.relatedTarget)) return
hide()
})
</script>

View File

@@ -6,10 +6,14 @@ import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
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 { RootCategory } from '@/components/searchbox/v2/rootCategories'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
const coreSettingsById = Object.fromEntries(CORE_SETTINGS.map((s) => [s.id, s]))
@@ -51,6 +55,7 @@ describe('NodeSearchBoxPopover', () => {
let emitAddFilter: EmitAddFilter | null = null
let emitAddNodeV1: EmitAddNode | null = null
let emitAddNodeV2: EmitAddNode | null = null
let emitSelectNode: ((nodeDef: ComfyNodeDefImpl) => void) | null = null
const NodeSearchBoxStub = defineComponent({
name: 'NodeSearchBox',
@@ -82,6 +87,17 @@ describe('NodeSearchBoxPopover', () => {
template: '<div data-testid="search-content-v2"></div>'
})
const LinkReleaseContextMenuStub = defineComponent({
name: 'LinkReleaseContextMenu',
props: { context: { type: Object, default: null } },
emits: ['selectNode', 'addReroute', 'dismiss'],
setup(_, { emit }) {
emitSelectNode = (nodeDef) => emit('selectNode', nodeDef)
return {}
},
template: '<div data-testid="link-release-menu" />'
})
const pinia = createTestingPinia({
stubActions: false,
initialState: {
@@ -99,6 +115,7 @@ describe('NodeSearchBoxPopover', () => {
stubs: {
NodeSearchBox: NodeSearchBoxStub,
NodeSearchContent: NodeSearchContentStub,
LinkReleaseContextMenu: LinkReleaseContextMenuStub,
NodePreviewCard: true,
Dialog: {
template: '<div><slot name="container" /></div>',
@@ -122,6 +139,11 @@ describe('NodeSearchBoxPopover', () => {
if (!emitAddNodeV2)
throw new Error('NodeSearchContent stub did not mount')
return emitAddNodeV2
},
get emitSelectNode() {
if (!emitSelectNode)
throw new Error('LinkReleaseContextMenu stub did not mount')
return emitSelectNode
}
}
}
@@ -276,4 +298,122 @@ describe('NodeSearchBoxPopover', () => {
)
})
})
describe('selecting a node from the link-release menu', () => {
function setupCanvas() {
const selectNode = vi.fn()
const canvasStore = useCanvasStore()
canvasStore.canvas = {
graph: { nodes: [] },
allow_searchbox: false,
setDirty: vi.fn(),
selectNode,
linkConnector: {
events: new EventTarget(),
reset: vi.fn(),
disconnectLinks: vi.fn(),
connectToNode: vi.fn()
}
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
return { selectNode }
}
it('auto-selects the placed node on the canvas', async () => {
const node = { id: 7 }
const { emitSelectNode } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'default'
})
const { selectNode } = setupCanvas()
addNodeOnGraph.mockReturnValue(node)
emitSelectNode({ name: 'KSampler' } as ComfyNodeDefImpl)
await nextTick()
expect(selectNode).toHaveBeenCalledWith(node)
})
it('does not select when the node could not be created', async () => {
const { emitSelectNode } = renderComponent({
'Comfy.NodeSearchBoxImpl': 'default'
})
const { selectNode } = setupCanvas()
addNodeOnGraph.mockReturnValue(null)
emitSelectNode({ name: 'KSampler' } as ComfyNodeDefImpl)
await nextTick()
expect(selectNode).not.toHaveBeenCalled()
})
})
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,6 +27,8 @@
<div v-if="useSearchBoxV2" role="search" class="relative">
<NodeSearchContent
:filters="nodeFilters"
:default-root-filter="defaultRootFilter"
:data-default-root-filter="defaultRootFilter"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@@ -51,6 +53,13 @@
/>
</template>
</Dialog>
<LinkReleaseContextMenu
ref="linkReleaseMenu"
:context="linkReleaseContext"
@select-node="connectNodeFromMenu"
@add-reroute="addRerouteFromMenu"
@dismiss="reset"
/>
</div>
</template>
@@ -62,7 +71,11 @@ import { computed, ref, toRaw, watch, watchEffect } from 'vue'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
LGraphNode,
LiteGraph,
isNodeSlot
} from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
@@ -78,11 +91,14 @@ import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import LinkReleaseContextMenu from './LinkReleaseContextMenu.vue'
import type { LinkReleaseContext } from './linkReleaseMenuModel'
import NodeSearchContent from './v2/NodeSearchContent.vue'
import { RootCategory } from './v2/rootCategories'
import type { RootCategoryId } from './v2/rootCategories'
import NodeSearchBox from './NodeSearchBox.vue'
let triggerEvent: CanvasPointerEvent | null = null
let listenerController: AbortController | null = null
let disconnectOnReset = false
const settingStore = useSettingStore()
@@ -103,6 +119,8 @@ const enableNodePreview = computed(
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
)
const linkReleaseMenu = ref<InstanceType<typeof LinkReleaseContextMenu>>()
const linkReleaseContext = ref<LinkReleaseContext | null>(null)
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
@@ -129,16 +147,26 @@ function closeDialog() {
}
const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
// Pre-select the Essentials category when opening search on an empty graph,
// where new users benefit most from a curated starting set.
const defaultRootFilter = computed<RootCategoryId | null>(() => {
const graph = canvasStore.canvas?.graph
return graph && graph.nodes.length > 0 ? null : RootCategory.Essentials
})
function connectNewNode(
nodeDef: ComfyNodeDefImpl,
options: { ghost?: boolean; dragEvent?: MouseEvent } = {}
): LGraphNode | null {
const { ghost = false, dragEvent } = options
const node = withNodeAddSource('search_modal', () =>
litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
{ ghost, dragEvent }
)
)
if (!node) return
if (!node) return null
if (disconnectOnReset && triggerEvent) {
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
@@ -150,6 +178,16 @@ function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
// Notify changeTracker - new step should be added
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
return node
}
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
connectNewNode(nodeDef, {
ghost: useSearchBoxV2.value && followCursor,
dragEvent
})
window.requestAnimationFrame(closeDialog)
}
@@ -202,62 +240,46 @@ function showContextMenu(e: CanvasPointerEvent) {
const firstLink = getFirstLink()
if (!firstLink) return
const { node, fromSlot, toType } = firstLink
const commonOptions = {
e,
allow_searchbox: true,
showSearchBox: () => {
cancelResetOnContextClose()
showSearchBox(e)
}
const { fromSlot, toType } = firstLink
linkReleaseContext.value = {
dataType: fromSlot.type?.toString() ?? '',
slotName: fromSlot.name ?? '',
isFromOutput: toType === 'input'
}
const afterRerouteId = firstLink.fromReroute?.id
const connectionOptions =
toType === 'input'
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
const canvas = canvasStore.getCanvas()
const menu = canvas.showConnectionMenu({
...connectionOptions,
...commonOptions
})
if (!menu) {
console.warn('No menu was returned from showConnectionMenu')
return
}
triggerEvent = e
listenerController = new AbortController()
const { signal } = listenerController
const options = { once: true, signal }
// Connect the node after it is created via context menu
useEventListener(
canvas.canvas,
'connect-new-default-node',
(createEvent) => {
if (!(createEvent instanceof CustomEvent))
throw new Error('Invalid event')
// Hide the dangling link while the menu holds the connection open; the real
// edge reappears once a node is committed (reset clears this flag).
const canvas = canvasStore.getCanvas()
canvas.linkConnector.renderLinksHidden = true
canvas.setDirty(true, true)
const node: unknown = createEvent.detail?.node
if (!(node instanceof LGraphNode)) throw new Error('Invalid node')
linkReleaseMenu.value?.show(e)
}
disconnectOnReset = false
createEvent.preventDefault()
canvas.linkConnector.connectToNode(node, e)
},
options
)
function connectNodeFromMenu(nodeDef: ComfyNodeDefImpl) {
const node = connectNewNode(nodeDef)
if (node) canvasStore.getCanvas().selectNode(node)
reset()
}
// Reset when the context menu is closed
const cancelResetOnContextClose = useEventListener(
menu.controller.signal,
'abort',
reset,
options
)
function addRerouteFromMenu() {
const firstLink = getFirstLink()
const node = firstLink?.node
if (
firstLink &&
triggerEvent &&
node instanceof LGraphNode &&
isNodeSlot(firstLink.fromSlot)
) {
node.connectFloatingReroute(
[triggerEvent.canvasX, triggerEvent.canvasY],
firstLink.fromSlot,
firstLink.fromReroute?.id
)
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
}
reset()
}
// Disable litegraph's default behavior of release link and search box.
@@ -333,25 +355,32 @@ function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
// Resets litegraph state
function reset() {
listenerController?.abort()
listenerController = null
triggerEvent = null
const canvas = canvasStore.getCanvas()
canvas.linkConnector.events.removeEventListener('reset', preventDefault)
if (disconnectOnReset) canvas.linkConnector.disconnectLinks()
disconnectOnReset = false
canvas.linkConnector.reset()
canvas.setDirty(true, true)
}
// Tears down a held link-release session synchronously so a new link drag can
// take over without hitting LinkConnector's "Already dragging links" guard.
function cancelLinkRelease() {
linkReleaseMenu.value?.hide()
visible.value = false
reset()
}
// Reset connecting links when the search box is closed
watch(visible, () => {
if (!visible.value) reset()
})
useEventListener(document, 'litegraph:canvas', canvasEventHandler)
defineExpose({ showSearchBox })
defineExpose({ showSearchBox, cancelLinkRelease })
</script>
<style>

View File

@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isTextOverflowing } from './isTextOverflowing'
const CHAR_WIDTH = 10
function setup(text: string, contentWidth: number) {
const el = document.createElement('span')
el.textContent = text
Object.defineProperty(el, 'clientWidth', {
configurable: true,
value: contentWidth
})
vi.spyOn(window, 'getComputedStyle').mockReturnValue(
{} as CSSStyleDeclaration
)
vi.spyOn(
HTMLSpanElement.prototype,
'getBoundingClientRect'
).mockImplementation(function (this: HTMLSpanElement) {
return { width: (this.textContent?.length ?? 0) * CHAR_WIDTH } as DOMRect
})
return el
}
describe('isTextOverflowing', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('returns false when the text fits the content width', () => {
const el = setup('KSampler', 200)
expect(isTextOverflowing(el)).toBe(false)
})
it('returns true when the full text is wider than the content width', () => {
const el = setup('ONNX Detector (SEGS/legacy) - use BBOXDetector', 120)
expect(isTextOverflowing(el)).toBe(true)
})
it('returns false for a zero-width element', () => {
const el = setup('anything', 0)
expect(isTextOverflowing(el)).toBe(false)
})
})

View File

@@ -0,0 +1,46 @@
const FONT_PROPS = [
'fontStyle',
'fontVariant',
'fontWeight',
'fontStretch',
'fontSize',
'fontFamily',
'letterSpacing',
'textTransform',
'wordSpacing'
] as const
/**
* Measures the full, unclipped width of an element's text by rendering it in a
* hidden clone that copies the element's font metrics. `scrollWidth` is
* unreliable for `text-overflow: ellipsis` in Chrome (it often reports equal to
* `clientWidth`), so the clone is the source of truth.
*/
export function measureTextWidth(el: HTMLElement): number {
const style = getComputedStyle(el)
const clone = document.createElement('span')
clone.textContent = el.textContent ?? ''
clone.style.position = 'fixed'
clone.style.top = '-9999px'
clone.style.left = '-9999px'
clone.style.visibility = 'hidden'
clone.style.whiteSpace = 'nowrap'
for (const prop of FONT_PROPS) clone.style[prop] = style[prop]
document.body.appendChild(clone)
const textWidth = clone.getBoundingClientRect().width
clone.remove()
return textWidth
}
/**
* Detects whether a single-line, ellipsis-truncated element is actually
* clipping its text by comparing its full text width against the available
* content width.
*/
export function isTextOverflowing(el: HTMLElement): boolean {
const contentWidth = el.clientWidth
if (contentWidth <= 0) return false
return measureTextWidth(el) > contentWidth + 0.5
}

View File

@@ -0,0 +1,317 @@
import { describe, expect, it } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import {
buildLinkReleaseNodeCategories,
computeContextMenuTop,
computeSubmenuAlignOffset,
computeSubmenuMaxHeight,
estimateLinkReleaseMenuHeight,
filterNodesByName,
getLinkReleaseHeaderLabel,
getLinkReleaseSuggestions,
groupLinkReleaseSearchResults,
searchLinkReleaseNodes
} from './linkReleaseMenuModel'
import type { LinkReleaseContext } from './linkReleaseMenuModel'
function coreNode(name: string, display_name = name): ComfyNodeDefImpl {
return {
name,
display_name,
nodeSource: { type: NodeSourceType.Core },
api_node: false
} as ComfyNodeDefImpl
}
function customNode(name: string, display_name = name): ComfyNodeDefImpl {
return {
name,
display_name,
nodeSource: { type: NodeSourceType.CustomNodes },
api_node: false
} as ComfyNodeDefImpl
}
function partnerNode(name: string, display_name = name): ComfyNodeDefImpl {
return {
name,
display_name,
nodeSource: { type: NodeSourceType.Core },
api_node: true
} as ComfyNodeDefImpl
}
const ksampler = coreNode('KSampler')
const vaeDecode = coreNode('VAEDecode', 'VAE Decode')
const rerouteNode = coreNode('Reroute')
function createContext(
overrides: Partial<LinkReleaseContext> = {}
): LinkReleaseContext {
return {
dataType: 'MODEL',
slotName: 'model',
isFromOutput: true,
...overrides
}
}
describe('getLinkReleaseHeaderLabel', () => {
it('combines slot name and data type', () => {
const label = getLinkReleaseHeaderLabel(
createContext({ slotName: 'model', dataType: 'MODEL' })
)
expect(label).toBe('model | MODEL')
})
it('falls back to whichever value is present', () => {
const onlyType = getLinkReleaseHeaderLabel(
createContext({ slotName: '', dataType: 'IMAGE' })
)
const onlyName = getLinkReleaseHeaderLabel(
createContext({ slotName: 'clip', dataType: '' })
)
expect(onlyType).toBe('IMAGE')
expect(onlyName).toBe('clip')
})
})
describe('getLinkReleaseSuggestions', () => {
it('excludes the Reroute node', () => {
const suggestions = getLinkReleaseSuggestions([rerouteNode, vaeDecode])
expect(suggestions.map((n) => n.name)).toEqual(['VAEDecode'])
})
it('preserves the incoming order of remaining nodes', () => {
const suggestions = getLinkReleaseSuggestions([vaeDecode, ksampler])
expect(suggestions.map((n) => n.name)).toEqual(['VAEDecode', 'KSampler'])
})
})
describe('buildLinkReleaseNodeCategories', () => {
it('groups nodes by source into comfy, extensions and partner buckets', () => {
const ext = customNode('ExtNode', 'Ext Node')
const partner = partnerNode('PartnerNode', 'Partner Node')
const categories = buildLinkReleaseNodeCategories([ksampler, ext, partner])
const byKey = Object.fromEntries(categories.map((c) => [c.key, c]))
expect(byKey.comfy.nodes.map((n) => n.name)).toContain('KSampler')
expect(byKey.extensions.nodes.map((n) => n.name)).toContain('ExtNode')
expect(byKey.partner.nodes.map((n) => n.name)).toContain('PartnerNode')
})
it('omits empty buckets', () => {
const categories = buildLinkReleaseNodeCategories([ksampler])
expect(categories.map((c) => c.key)).toEqual(['comfy'])
})
it('orders buckets comfy, extensions, partner', () => {
const categories = buildLinkReleaseNodeCategories([
partnerNode('P'),
customNode('E'),
coreNode('C')
])
expect(categories.map((c) => c.key)).toEqual([
'comfy',
'extensions',
'partner'
])
})
it('sorts nodes alphabetically by display name within a bucket', () => {
const categories = buildLinkReleaseNodeCategories([
coreNode('B'),
coreNode('A')
])
expect(categories[0].nodes.map((n) => n.display_name)).toEqual(['A', 'B'])
})
it('classifies api-category nodes as partner', () => {
const apiNode = {
name: 'ApiThing',
display_name: 'Api Thing',
nodeSource: { type: NodeSourceType.Core },
api_node: false,
category: 'api node/openai'
} as ComfyNodeDefImpl
const categories = buildLinkReleaseNodeCategories([apiNode])
expect(categories.map((c) => c.key)).toEqual(['partner'])
})
})
describe('filterNodesByName', () => {
it('returns all nodes when query is blank', () => {
expect(filterNodesByName([ksampler, vaeDecode], ' ')).toHaveLength(2)
})
it('matches display name case-insensitively', () => {
const result = filterNodesByName([ksampler, vaeDecode], 'vae')
expect(result.map((n) => n.name)).toEqual(['VAEDecode'])
})
})
describe('groupLinkReleaseSearchResults', () => {
const categories = buildLinkReleaseNodeCategories([
coreNode('LoadImage', 'Load Image'),
customNode('ImageBlend', 'Image Blend'),
partnerNode('ImageGen', 'Image Gen'),
coreNode('KSampler')
])
it('returns no groups for a blank query', () => {
expect(groupLinkReleaseSearchResults(categories, ' ')).toEqual([])
})
it('groups matching nodes by category', () => {
const groups = groupLinkReleaseSearchResults(categories, 'image')
expect(groups.map((g) => g.category.key)).toEqual([
'comfy',
'extensions',
'partner'
])
expect(groups.map((g) => g.nodes.map((n) => n.name))).toEqual([
['LoadImage'],
['ImageBlend'],
['ImageGen']
])
})
it('omits categories with no matches', () => {
const groups = groupLinkReleaseSearchResults(categories, 'ksampler')
expect(groups.map((g) => g.category.key)).toEqual(['comfy'])
expect(groups[0].nodes.map((n) => n.name)).toEqual(['KSampler'])
})
})
describe('searchLinkReleaseNodes', () => {
const categories = buildLinkReleaseNodeCategories([
coreNode('LoadImage', 'Load Image'),
customNode('ImageBlend', 'Image Blend'),
partnerNode('ImageGen', 'Image Gen'),
coreNode('KSampler')
])
it('returns no matches for a blank query', () => {
expect(searchLinkReleaseNodes(categories, ' ')).toEqual([])
})
it('flattens matching nodes across categories, tagged with their category', () => {
const matches = searchLinkReleaseNodes(categories, 'image')
expect(matches.map((m) => m.node.name)).toEqual([
'LoadImage',
'ImageBlend',
'ImageGen'
])
expect(matches.map((m) => m.category.key)).toEqual([
'comfy',
'extensions',
'partner'
])
})
it('matches display name case-insensitively', () => {
const matches = searchLinkReleaseNodes(categories, 'ksampler')
expect(matches.map((m) => m.node.name)).toEqual(['KSampler'])
expect(matches[0].category.key).toBe('comfy')
})
it('returns an empty list when nothing matches', () => {
expect(searchLinkReleaseNodes(categories, 'zzz')).toEqual([])
})
})
describe('computeSubmenuAlignOffset', () => {
it('lifts the submenu up to the root search field for a trigger below it', () => {
const offset = computeSubmenuAlignOffset({
triggerTop: 200,
rootSearchTop: 48,
contentPaddingTop: 4
})
expect(offset).toBe(-156)
})
it('offsets only by the content padding when the trigger sits at the search field', () => {
const offset = computeSubmenuAlignOffset({
triggerTop: 48,
rootSearchTop: 48,
contentPaddingTop: 4
})
expect(offset).toBe(-4)
})
})
describe('computeSubmenuMaxHeight', () => {
it('grows to the space below when there is ample room', () => {
const height = computeSubmenuMaxHeight({
submenuTop: 100,
contextMenuHeight: 420,
viewportHeight: 1000,
margin: 8
})
expect(height).toBe(892)
})
it('floors at the context menu height when room below is smaller', () => {
const height = computeSubmenuMaxHeight({
submenuTop: 600,
contextMenuHeight: 420,
viewportHeight: 1000,
margin: 8
})
expect(height).toBe(420)
})
})
describe('estimateLinkReleaseMenuHeight', () => {
it('estimates a typical default layout with header, suggestions, categories and reroute', () => {
const height = estimateLinkReleaseMenuHeight({
hasHeader: true,
suggestionCount: 4,
categoryCount: 3,
searchResultCount: 0,
showReroute: true
})
expect(height).toBe(468)
})
it('estimates search results instead of the default sections', () => {
const height = estimateLinkReleaseMenuHeight({
hasHeader: true,
suggestionCount: 4,
categoryCount: 3,
searchResultCount: 5,
searchResultGroupCount: 2,
showReroute: false
})
expect(height).toBe(280)
})
})
describe('computeContextMenuTop', () => {
const base = {
menuHeight: 468,
viewportHeight: 1000,
margin: 8,
sideOffset: 4
}
it('bottom-anchors when the cursor is near the viewport bottom', () => {
const top = computeContextMenuTop({ ...base, cursorY: 900 })
expect(top).toBe(524)
})
it('opens at the cursor when there is room below', () => {
const top = computeContextMenuTop({ ...base, cursorY: 100 })
expect(top).toBe(104)
})
it('pins to the top margin when the cursor is above the viewport', () => {
const top = computeContextMenuTop({ ...base, cursorY: -10 })
expect(top).toBe(8)
})
})

View File

@@ -0,0 +1,264 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
export interface LinkReleaseContext {
/** The data type of the slot the link was dragged from (e.g. "MODEL"). */
dataType: string
/** The name of the slot the link was dragged from (e.g. "model"). */
slotName: string
/**
* Whether the released link originates from an output slot, meaning the new
* node will be connected to via one of its inputs.
*/
isFromOutput: boolean
}
type LinkReleaseCategoryKey = 'comfy' | 'extensions' | 'partner'
export interface LinkReleaseNodeCategory {
key: LinkReleaseCategoryKey
/** i18n key for the group heading. */
labelKey: string
/** Iconify class shown beside the group label. */
icon: string
/** Nodes in the group, sorted alphabetically by display name. */
nodes: ComfyNodeDefImpl[]
}
const CATEGORY_META: Record<
LinkReleaseCategoryKey,
{ labelKey: string; icon: string }
> = {
comfy: { labelKey: 'contextMenu.Comfy Nodes', icon: 'icon-[lucide--box]' },
extensions: {
labelKey: 'contextMenu.Extensions',
icon: 'icon-[lucide--puzzle]'
},
partner: {
labelKey: 'contextMenu.Partner Nodes',
icon: 'icon-[lucide--handshake]'
}
}
const CATEGORY_ORDER: LinkReleaseCategoryKey[] = [
'comfy',
'extensions',
'partner'
]
export function getLinkReleaseHeaderLabel(context: LinkReleaseContext): string {
const { slotName, dataType } = context
if (slotName && dataType) return `${slotName} | ${dataType}`
return slotName || dataType
}
function classifyNode(node: ComfyNodeDefImpl): LinkReleaseCategoryKey {
if (node.api_node || node.category?.startsWith('api node')) return 'partner'
if (
node.nodeSource.type === NodeSourceType.Core ||
node.nodeSource.type === NodeSourceType.Essentials
) {
return 'comfy'
}
return 'extensions'
}
function byDisplayName(a: ComfyNodeDefImpl, b: ComfyNodeDefImpl): number {
return a.display_name.localeCompare(b.display_name)
}
/**
* Group slot-compatible nodes into source buckets for the cascading menu.
* Empty buckets are omitted and each bucket's nodes are sorted by display name.
*/
export function buildLinkReleaseNodeCategories(
compatibleNodes: ComfyNodeDefImpl[]
): LinkReleaseNodeCategory[] {
const buckets: Record<LinkReleaseCategoryKey, ComfyNodeDefImpl[]> = {
comfy: [],
extensions: [],
partner: []
}
for (const node of compatibleNodes) {
buckets[classifyNode(node)].push(node)
}
return CATEGORY_ORDER.filter((key) => buckets[key].length > 0).map((key) => ({
key,
labelKey: CATEGORY_META[key].labelKey,
icon: CATEGORY_META[key].icon,
nodes: [...buckets[key]].sort(byDisplayName)
}))
}
/** Quick-add suggestions for the released slot, excluding the Reroute node. */
export function getLinkReleaseSuggestions(
defaultNodeDefs: ComfyNodeDefImpl[]
): ComfyNodeDefImpl[] {
return defaultNodeDefs.filter((nodeDef) => nodeDef.name !== 'Reroute')
}
/** Case-insensitive filter of a node list by display name. */
export function filterNodesByName(
nodes: ComfyNodeDefImpl[],
query: string
): ComfyNodeDefImpl[] {
const trimmed = query.trim().toLowerCase()
if (!trimmed) return nodes
return nodes.filter((nodeDef) =>
nodeDef.display_name.toLowerCase().includes(trimmed)
)
}
/** A node surfaced by the root flat-value search, tagged with its category. */
export interface LinkReleaseNodeMatch {
category: LinkReleaseNodeCategory
node: ComfyNodeDefImpl
}
export interface LinkReleaseSearchResultGroup {
category: LinkReleaseNodeCategory
nodes: ComfyNodeDefImpl[]
}
/**
* Group matching nodes by category for the root flat-value search. Empty
* categories are omitted; category order and per-category display-name order
* are preserved.
*/
export function groupLinkReleaseSearchResults(
categories: LinkReleaseNodeCategory[],
query: string
): LinkReleaseSearchResultGroup[] {
const trimmed = query.trim().toLowerCase()
if (!trimmed) return []
return categories
.map((category) => ({
category,
nodes: category.nodes.filter((node) =>
node.display_name.toLowerCase().includes(trimmed)
)
}))
.filter((group) => group.nodes.length > 0)
}
/**
* Flat-value search across every category submenu: when the root search has
* text we surface matching nodes inline (tagged with their category) so a node
* can be picked straight from the root without first drilling into a submenu.
* Results preserve category order, then per-category display-name order.
*/
export function searchLinkReleaseNodes(
categories: LinkReleaseNodeCategory[],
query: string
): LinkReleaseNodeMatch[] {
const matches: LinkReleaseNodeMatch[] = []
for (const group of groupLinkReleaseSearchResults(categories, query)) {
for (const node of group.nodes) {
matches.push({ category: group.category, node })
}
}
return matches
}
/**
* Vertical `alignOffset` (px) that makes a category submenu open level with the
* root menu rather than with the hovered trigger row. Positioning the submenu's
* top one content-padding above the root search field lines the submenu's own
* search field up with the root search field, since both menus share the same
* content padding and search-field markup.
*/
export function computeSubmenuAlignOffset(metrics: {
triggerTop: number
rootSearchTop: number
contentPaddingTop: number
}): number {
const { triggerTop, rootSearchTop, contentPaddingTop } = metrics
return rootSearchTop - contentPaddingTop - triggerTop
}
/**
* Max height (px) for a category submenu pinned level with the root menu. The
* panel grows into the viewport space below its top, but never shrinks below
* the root menu's height so it can always be at least as tall as the context
* menu even when there is little room beneath it.
*/
export function computeSubmenuMaxHeight(metrics: {
submenuTop: number
contextMenuHeight: number
viewportHeight: number
margin: number
}): number {
const { submenuTop, contextMenuHeight, viewportHeight, margin } = metrics
return Math.max(contextMenuHeight, viewportHeight - submenuTop - margin)
}
const CONTENT_PADDING_Y = 8
const HEADER_HEIGHT = 36
const SEARCH_HEIGHT = 40
const SEPARATOR_HEIGHT = 8
const SECTION_LABEL_HEIGHT = 36
const MENU_ITEM_HEIGHT = 36
/**
* Rough pixel height of the link-release context menu from its Tailwind layout.
* Used once on open to bottom-anchor the panel without relying on Reka's 80vh
* collision sizing.
*/
export function estimateLinkReleaseMenuHeight(layout: {
hasHeader: boolean
suggestionCount: number
categoryCount: number
searchResultCount: number
searchResultGroupCount?: number
showReroute: boolean
}): number {
const {
hasHeader,
suggestionCount,
categoryCount,
searchResultCount,
searchResultGroupCount = 0,
showReroute
} = layout
let height = CONTENT_PADDING_Y + SEARCH_HEIGHT + SEPARATOR_HEIGHT
if (hasHeader) height += HEADER_HEIGHT
if (searchResultCount > 0) {
height += searchResultCount * MENU_ITEM_HEIGHT
if (searchResultGroupCount > 1) {
height += (searchResultGroupCount - 1) * SEPARATOR_HEIGHT
}
return height
}
if (suggestionCount > 0) {
height += SECTION_LABEL_HEIGHT + suggestionCount * MENU_ITEM_HEIGHT
}
if (suggestionCount > 0 && categoryCount > 0) {
height += SEPARATOR_HEIGHT
}
if (categoryCount > 0) {
height += SECTION_LABEL_HEIGHT + categoryCount * MENU_ITEM_HEIGHT
}
if (showReroute) {
height += SEPARATOR_HEIGHT + MENU_ITEM_HEIGHT
}
return height
}
/** Bottom-anchor the context menu top edge within the viewport. */
export function computeContextMenuTop(metrics: {
cursorY: number
menuHeight: number
viewportHeight: number
margin: number
sideOffset: number
}): number {
const { cursorY, menuHeight, viewportHeight, margin, sideOffset } = metrics
const menuTopAtCursor = cursorY + sideOffset
const maxMenuTop = Math.max(margin, viewportHeight - margin - menuHeight)
return Math.min(Math.max(margin, menuTopAtCursor), maxMenuTop)
}

View File

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

View File

@@ -1,8 +1,28 @@
import { computed, ref } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { getWorkflowMode, isAppModeValue } from '@/utils/appMode'
import type { AppMode } from '@/utils/appMode'
export type AppMode =
| 'graph'
| 'app'
| 'builder:inputs'
| '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)

View File

@@ -1,105 +1,94 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCopy } from './useCopy'
/**
* Encodes a UTF-8 string to base64 (same logic as useCopy.ts)
*/
function encodeClipboardData(data: string): string {
return btoa(
String.fromCharCode(...Array.from(new TextEncoder().encode(data)))
const copyMocks = vi.hoisted(() => ({
copyHandler: undefined as ((event: ClipboardEvent) => unknown) | undefined,
canvas: {
selectedItems: new Set<object>([{}]),
copyToClipboard: vi.fn()
}
}))
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn(
(
_target: EventTarget,
event: string,
handler: (event: ClipboardEvent) => unknown
) => {
if (event === 'copy') copyMocks.copyHandler = handler
return vi.fn()
}
)
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: copyMocks.canvas
})
}))
vi.mock('@/workbench/eventHelpers', () => ({
shouldIgnoreCopyPaste: vi.fn(() => false)
}))
const multiChunkPayloadLength = 0x8000 * 6 + 123
function copySerializedData(serializedData: string): DataTransfer {
copyMocks.canvas.copyToClipboard.mockReturnValue(serializedData)
useCopy()
const dataTransfer = new DataTransfer()
const event = new ClipboardEvent('copy', {
clipboardData: dataTransfer
})
const copyHandler = copyMocks.copyHandler
expect(copyHandler).toBeDefined()
if (!copyHandler) throw new Error('Expected copy handler to be registered')
expect(() => copyHandler(event)).not.toThrow()
return dataTransfer
}
/**
* Decodes base64 to UTF-8 string (same logic as usePaste.ts)
*/
function decodeClipboardData(base64: string): string {
const binaryString = atob(base64)
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
function readSerializedClipboardMetadata(dataTransfer: DataTransfer): string {
const match = dataTransfer
.getData('text/html')
.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
expect(match).toBeDefined()
if (!match) throw new Error('Expected clipboard metadata to be written')
const binaryString = atob(match)
const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
describe('Clipboard UTF-8 base64 encoding/decoding', () => {
it('should handle ASCII-only strings', () => {
const original = '{"nodes":[{"id":1,"type":"LoadImage"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
describe('useCopy', () => {
beforeEach(() => {
copyMocks.copyHandler = undefined
copyMocks.canvas.copyToClipboard.mockReset()
})
it('should handle Chinese characters in localized_name', () => {
const original =
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"图像"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle Japanese characters', () => {
const original = '{"localized_name":"画像を読み込む"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle Korean characters', () => {
const original = '{"localized_name":"이미지 불러오기"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle mixed ASCII and Unicode characters', () => {
const original =
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"加载图像","label":"Load Image 图片"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle emoji characters', () => {
const original = '{"title":"Test Node 🎨🖼️"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle empty string', () => {
const original = ''
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle complex node data with multiple Unicode fields', () => {
const original = JSON.stringify({
it('should write large serialized node data to clipboard metadata', () => {
const serializedData = JSON.stringify({
nodes: [
{
id: 1,
type: 'LoadImage',
localized_name: '图像',
inputs: [{ localized_name: '图片', name: 'image' }],
outputs: [{ localized_name: '输出', name: 'output' }]
type: 'Subgraph',
title: 'Large Subgraph',
localized_name: '이미지 그룹 图像 🎨',
payload: 'x'.repeat(multiChunkPayloadLength)
}
],
groups: [{ title: '预处理组 🔧' }],
links: []
reroutes: [],
links: [],
subgraphs: []
})
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
expect(JSON.parse(decoded)).toEqual(JSON.parse(original))
})
it('should produce valid base64 output', () => {
const original = '{"localized_name":"中文测试"}'
const encoded = encodeClipboardData(original)
// Base64 should only contain valid characters
expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/)
})
const dataTransfer = copySerializedData(serializedData)
it('should fail with plain btoa for non-Latin1 characters', () => {
const original = '{"localized_name":"图像"}'
// This demonstrates why we need TextEncoder - plain btoa fails
expect(() => btoa(original)).toThrow()
expect(readSerializedClipboardMetadata(dataTransfer)).toBe(serializedData)
})
})

View File

@@ -7,6 +7,29 @@ const clipboardHTMLWrapper = [
'<meta charset="utf-8"><div><span data-metadata="',
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
]
const clipboardByteChunkSize = 0x8000
function bytesToBinaryString(bytes: Uint8Array): string {
const chunks: string[] = []
for (
let offset = 0;
offset < bytes.length;
offset += clipboardByteChunkSize
) {
chunks.push(
String.fromCharCode(
...bytes.subarray(offset, offset + clipboardByteChunkSize)
)
)
}
return chunks.join('')
}
function encodeClipboardData(data: string): string {
return btoa(bytesToBinaryString(new TextEncoder().encode(data)))
}
/**
* Adds a handler on copy that serializes selected nodes to JSON
@@ -23,17 +46,16 @@ export const useCopy = () => {
const canvas = canvasStore.canvas
if (canvas?.selectedItems) {
const serializedData = canvas.copyToClipboard()
// Use TextEncoder to handle Unicode characters properly
const base64Data = btoa(
String.fromCharCode(
...Array.from(new TextEncoder().encode(serializedData))
try {
const base64Data = encodeClipboardData(serializedData)
// clearData doesn't remove images from clipboard
e.clipboardData?.setData(
'text/html',
clipboardHTMLWrapper.join(base64Data)
)
)
// clearData doesn't remove images from clipboard
e.clipboardData?.setData(
'text/html',
clipboardHTMLWrapper.join(base64Data)
)
} catch (error) {
console.error(error)
}
e.preventDefault()
e.stopImmediatePropagation()
return false

View File

@@ -4,7 +4,6 @@ import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteG
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
import { useExternalLink } from '@/composables/useExternalLink'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
@@ -86,7 +85,6 @@ export function useCoreCommands(): ComfyCommand[] {
const executionStore = useExecutionStore()
const modelStore = useModelStore()
const telemetry = useTelemetry()
const { trackRunButton } = useRunButtonTelemetry()
const { staticUrls, buildDocsUrl } = useExternalLink()
const settingStore = useSettingStore()
@@ -501,7 +499,7 @@ export function useCoreCommands(): ComfyCommand[] {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}) => {
trackRunButton(metadata)
useTelemetry()?.trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return
@@ -524,7 +522,7 @@ export function useCoreCommands(): ComfyCommand[] {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}) => {
trackRunButton(metadata)
useTelemetry()?.trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return
@@ -546,7 +544,7 @@ export function useCoreCommands(): ComfyCommand[] {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}) => {
trackRunButton(metadata)
useTelemetry()?.trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return

View File

@@ -1,112 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
mode: { value: 'graph' },
isAppMode: { value: false },
telemetry: {
trackRunButton: vi.fn()
},
executionContext: {
is_template: false,
workflow_name: 'Desktop workflow',
custom_node_count: 2,
total_node_count: 4,
subgraph_count: 1,
has_api_nodes: true,
api_node_names: ['LoadImage'],
has_toolkit_nodes: false,
toolkit_node_names: []
},
executionContextError: null as Error | null
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: state.mode,
isAppMode: state.isAppMode
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => state.telemetry
}))
vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({
getExecutionContext: () => {
if (state.executionContextError) throw state.executionContextError
return state.executionContext
}
}))
import {
getRunButtonTelemetryProperties,
useRunButtonTelemetry
} from './useRunButtonTelemetry'
describe('useRunButtonTelemetry', () => {
beforeEach(() => {
localStorage.clear()
state.telemetry.trackRunButton.mockClear()
state.mode.value = 'graph'
state.isAppMode.value = false
state.executionContextError = null
})
it('builds run button properties from workspace state', () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
expect(
getRunButtonTelemetryProperties({
subscribe_to_run: true,
trigger_source: 'button'
})
).toEqual({
subscribe_to_run: true,
workflow_type: 'custom',
workflow_name: 'Desktop workflow',
custom_node_count: 2,
total_node_count: 4,
subgraph_count: 1,
has_api_nodes: true,
api_node_names: ['LoadImage'],
has_toolkit_nodes: false,
toolkit_node_names: [],
trigger_source: 'button',
view_mode: 'graph',
is_app_mode: false,
dock_state: 'floating'
})
})
it('tracks the completed run button payload', () => {
useRunButtonTelemetry().trackRunButton({ trigger_source: 'linear' })
expect(state.telemetry.trackRunButton).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
subscribe_to_run: false,
trigger_source: 'linear',
workflow_name: 'Desktop workflow'
})
)
})
it('does not throw when run button context collection fails', () => {
const error = new Error('Context unavailable')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
state.executionContextError = error
try {
expect(() =>
useRunButtonTelemetry().trackRunButton({ trigger_source: 'linear' })
).not.toThrow()
expect(state.telemetry.trackRunButton).not.toHaveBeenCalled()
expect(consoleError).toHaveBeenCalledExactlyOnceWith(
'[Telemetry] Run button tracking failed',
error
)
} finally {
consoleError.mockRestore()
}
})
})

View File

@@ -1,52 +0,0 @@
import { useAppMode } from '@/composables/useAppMode'
import { useTelemetry } from '@/platform/telemetry'
import type {
ExecutionTriggerSource,
RunButtonProperties
} from '@/platform/telemetry/types'
import { getActionbarDockState } from '@/platform/telemetry/utils/getActionbarDockState'
import { getExecutionContext } from '@/platform/telemetry/utils/getExecutionContext'
export type RunButtonTelemetryOptions = {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}
export function getRunButtonTelemetryProperties(
options?: RunButtonTelemetryOptions
): RunButtonProperties {
const executionContext = getExecutionContext()
const { mode, isAppMode } = useAppMode()
return {
subscribe_to_run: options?.subscribe_to_run ?? false,
workflow_type: executionContext.is_template ? 'template' : 'custom',
workflow_name: executionContext.workflow_name ?? 'untitled',
custom_node_count: executionContext.custom_node_count,
total_node_count: executionContext.total_node_count,
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
api_node_names: executionContext.api_node_names,
has_toolkit_nodes: executionContext.has_toolkit_nodes,
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
}
}
export function useRunButtonTelemetry() {
function trackRunButton(options?: RunButtonTelemetryOptions): void {
const telemetry = useTelemetry()
if (!telemetry) return
try {
telemetry.trackRunButton(getRunButtonTelemetryProperties(options))
} catch (error) {
console.error('[Telemetry] Run button tracking failed', error)
}
}
return { trackRunButton }
}

View File

@@ -119,6 +119,23 @@ describe('load3dLazy', () => {
expect(spec.upload_subfolder).toBe('3d')
})
it('injects mesh_upload spec flags into the model_file widget for Load3DAdvanced nodes', async () => {
const { hook } = await loadLazyExtensionFresh()
const nodeData = makeNodeDef('Load3DAdvanced', {
input: {
required: { model_file: ['STRING', {}] }
}
} as Partial<ComfyNodeDef>)
await hook({} as typeof LGraphNode, nodeData)
const spec = (
nodeData.input!.required!.model_file as [string, Record<string, unknown>]
)[1]
expect(spec.mesh_upload).toBe(true)
expect(spec.upload_subfolder).toBe('3d')
})
it('does not throw when a Load3D node has no model_file widget spec', async () => {
const { hook } = await loadLazyExtensionFresh()
const nodeData = makeNodeDef('Load3D', {

View File

@@ -61,18 +61,12 @@ useExtensionService().registerExtension({
if (isLoad3dNode(nodeData.name)) {
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
// Load3D's model_file as a mesh upload widget without hardcoding.
if (nodeData.name === 'Load3D') {
if (nodeData.name === 'Load3D' || nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = '3d'
}
} else if (nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = ''
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,

View File

@@ -5194,7 +5194,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
private _drawConnectingLinks(ctx: CanvasRenderingContext2D): void {
const { linkConnector } = this
if (!linkConnector.isConnecting) return
if (!linkConnector.isConnecting || linkConnector.renderLinksHidden) return
const { renderLinks } = linkConnector
const highlightPos = this._getHighlightPosition()

View File

@@ -118,6 +118,13 @@ export class LinkConnector {
/** The reroute beneath the pointer, if it is a valid connection target. */
overReroute?: Reroute
/**
* When `true`, the in-progress dragging links are not rendered even though a
* connection is still active. Used to hide the dangling link while a
* link-release menu holds the connection open.
*/
renderLinksHidden = false
private readonly _setConnectingLinks: (value: ConnectingLink[]) => void
constructor(setConnectingLinks: (value: ConnectingLink[]) => void) {
@@ -1098,6 +1105,8 @@ export class LinkConnector {
const mayContinue = this.events.dispatch('reset', force)
if (mayContinue === false) return
this.renderLinksHidden = false
const {
state,
outputLinks,

View File

@@ -170,6 +170,7 @@ export type { TWidgetType, TWidgetValue, IWidgetOptions } from './types/widgets'
export {
findUsedSubgraphIds,
getDirectSubgraphIds,
isNodeSlot,
isSubgraphInput,
isSubgraphOutput
} from './subgraph/subgraphUtils'

View File

@@ -593,6 +593,12 @@
"Bypass": "Bypass",
"Copy (Clipspace)": "Copy (Clipspace)",
"Add Node": "Add Node",
"Add Reroute": "Add Reroute",
"Most Relevant": "Most Relevant",
"Comfy Nodes": "Comfy Nodes",
"Extensions": "Extensions",
"Partner Nodes": "Partner Nodes",
"Compatible Nodes": "Compatible Nodes",
"Add Group": "Add Group",
"Manage Group Nodes": "Manage Group Nodes",
"Add Group For Selected Nodes": "Add Group For Selected Nodes",
@@ -634,8 +640,7 @@
"Horizontal": "Horizontal",
"Vertical": "Vertical",
"new": "new",
"deprecated": "deprecated",
"Extensions": "Extensions"
"deprecated": "deprecated"
},
"icon": {
"bookmark": "Bookmark",
@@ -3627,6 +3632,10 @@
"hideAdvancedShort": "Hide advanced",
"errors": "Errors",
"noErrors": "No errors",
"errorsDetected": "Error detected | Errors detected",
"resolveBeforeRun": "Resolve before running the workflow",
"expand": "Expand",
"collapse": "Collapse",
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
"errorLog": "Error log",
"findOnGithubTooltip": "Search GitHub issues for related problems",

View File

@@ -143,7 +143,7 @@ const { t } = useI18n()
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
<span
class="text-[2rem] leading-none font-semibold text-base-foreground"
class="text-[2rem]/none font-semibold text-base-foreground"
data-testid="credit-slider-price"
>
{{ formatUsd(displayMonthly) }}

View File

@@ -22,8 +22,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
const { t } = useI18n()
const breakpoints = useBreakpoints(breakpointsTailwind)
@@ -36,11 +36,10 @@ const buttonLabel = computed(() =>
)
const { showSubscriptionDialog } = useBillingContext()
const { trackRunButton } = useRunButtonTelemetry()
const handleSubscribeToRun = () => {
if (isCloud) {
trackRunButton({ subscribe_to_run: true })
useTelemetry()?.trackRunButton({ subscribe_to_run: true })
}
showSubscriptionDialog()

View File

@@ -1394,7 +1394,7 @@ describe('errorMessageResolver', () => {
})
).toEqual({
catalogId: 'missing_node',
displayTitle: 'Missing Node Packs (1)',
displayTitle: 'Missing Node Packs',
displayMessage: 'Install missing packs to use this workflow.',
toastTitle: 'Missing node: FooNode',
toastMessage:
@@ -1410,7 +1410,7 @@ describe('errorMessageResolver', () => {
})
).toEqual({
catalogId: 'missing_node',
displayTitle: 'Unsupported Node Packs (1)',
displayTitle: 'Unsupported Node Packs',
displayMessage:
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
toastTitle: "FooNode isn't available on Cloud",
@@ -1471,7 +1471,7 @@ describe('errorMessageResolver', () => {
})
).toEqual({
catalogId: 'swap_nodes',
displayTitle: 'Swap Nodes (1)',
displayTitle: 'Swap Nodes',
displayMessage: 'Some nodes can be replaced with alternatives',
toastTitle: 'OldNode can be replaced',
toastMessage: 'Replace it with NewNode from the error panel.'
@@ -1520,7 +1520,7 @@ describe('errorMessageResolver', () => {
})
).toEqual({
catalogId: 'missing_model',
displayTitle: 'Missing Models (1)',
displayTitle: 'Missing Models',
displayMessage: 'Download a model, or open the node to replace it.',
toastTitle: 'sdxl.safetensors is missing',
toastMessage: 'Checkpoint Loader Simple is missing a required model file.'
@@ -1535,7 +1535,7 @@ describe('errorMessageResolver', () => {
})
).toEqual({
catalogId: 'missing_model',
displayTitle: 'Missing Models (1)',
displayTitle: 'Missing Models',
displayMessage: 'Import a model, or open the node to replace it.',
toastTitle: "sdxl.safetensors isn't available on Cloud",
toastMessage: "This model isn't supported. Choose a different one."
@@ -1573,7 +1573,7 @@ describe('errorMessageResolver', () => {
})
).toEqual({
catalogId: 'missing_media',
displayTitle: 'Missing Inputs (1)',
displayTitle: 'Missing Inputs',
displayMessage: 'A required media input has no file selected.',
toastTitle: 'Media input missing',
toastMessage: 'Load Image is missing a required media file.'
@@ -1707,7 +1707,7 @@ describe('errorMessageResolver', () => {
isCloud: false
})
).toMatchObject({
displayTitle: 'Missing Inputs (2)',
displayTitle: 'Missing Inputs',
toastTitle: 'Missing media inputs',
toastMessage:
'Please select the missing media inputs before running this workflow.'

View File

@@ -6,10 +6,6 @@ import { normalizeNodeName, translateCatalogMessage } from './catalogI18n'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import { st } from '@/i18n'
function formatCountTitle(title: string, count: number): string {
return `${title} (${count})`
}
function formatNodeTypeName(nodeType: string): string | null {
const trimmed = nodeType.trim()
if (!trimmed) return null
@@ -344,15 +340,12 @@ export function resolveMissingErrorMessage(
case 'missing_node':
return {
catalogId: 'missing_node',
displayTitle: formatCountTitle(
source.isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
source.count
),
displayTitle: source.isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
displayMessage: resolveMissingNodeDisplayMessage(source),
toastTitle: resolveMissingNodeToastTitle(source),
toastMessage: resolveMissingNodeToastMessage(source)
@@ -360,10 +353,7 @@ export function resolveMissingErrorMessage(
case 'swap_nodes':
return {
catalogId: 'swap_nodes',
displayTitle: formatCountTitle(
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
source.count
),
displayTitle: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
displayMessage: resolveSwapNodeDisplayMessage(),
toastTitle: resolveSwapNodeToastTitle(source),
toastMessage: resolveSwapNodeToastMessage(source)
@@ -371,12 +361,9 @@ export function resolveMissingErrorMessage(
case 'missing_model':
return {
catalogId: 'missing_model',
displayTitle: formatCountTitle(
st(
'rightSidePanel.missingModels.missingModelsTitle',
'Missing Models'
),
source.count
displayTitle: st(
'rightSidePanel.missingModels.missingModelsTitle',
'Missing Models'
),
displayMessage: resolveMissingModelDisplayMessage(source),
toastTitle: resolveMissingModelToastTitle(source),
@@ -385,9 +372,9 @@ export function resolveMissingErrorMessage(
case 'missing_media':
return {
catalogId: 'missing_media',
displayTitle: formatCountTitle(
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
source.count
displayTitle: st(
'rightSidePanel.missingMedia.missingMediaTitle',
'Missing Inputs'
),
displayMessage: resolveMissingMediaDisplayMessage(),
toastTitle: resolveMissingMediaToastTitle(source),

View File

@@ -1,5 +1,5 @@
<template>
<div class="px-4 pb-2">
<div class="px-3">
<TransitionGroup
tag="ul"
name="list-scale"
@@ -15,7 +15,7 @@
<span class="flex min-w-0 flex-1">
<button
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="emit('locateNode', item.nodeId)"
>
{{ item.displayItemLabel }}
@@ -25,7 +25,7 @@
data-testid="missing-media-locate-button"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="
t('rightSidePanel.locateNodeFor', {
item: item.displayItemLabel

View File

@@ -1,9 +1,9 @@
<template>
<div class="px-4 pb-2">
<div class="px-3">
<div
v-if="importableModelRows.length > 0"
data-testid="missing-model-importable-rows"
class="flex flex-col gap-1 overflow-hidden py-2"
class="flex flex-col gap-1 overflow-hidden"
>
<MissingModelRow
v-for="row in importableModelRows"
@@ -19,7 +19,7 @@
<div
v-if="unsupportedModelRows.length > 0"
data-testid="missing-model-import-not-supported-section"
class="flex flex-col gap-1 border-t border-interface-stroke pt-3"
class="flex flex-col gap-1 border-t border-secondary-background pt-3"
>
<div class="mb-1">
<p class="m-0 text-sm font-semibold text-warning-background">
@@ -49,7 +49,7 @@
data-testid="missing-model-download-all"
variant="secondary"
size="sm"
class="h-8 min-w-0 flex-1 rounded-lg text-sm"
class="h-8 min-w-0 flex-1 rounded-md text-xs"
@click="downloadAllModels"
>
<i aria-hidden="true" class="icon-[lucide--download] size-4 shrink-0" />

View File

@@ -12,27 +12,27 @@
: t('rightSidePanel.missingModels.expandNodes')
"
:aria-expanded="expanded"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
@click="handleToggleExpand"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
:class="
cn(
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90'
)
"
/>
</Button>
<span class="flex min-w-0 flex-1 flex-col gap-0">
<span class="block min-w-0 text-sm/tight">
<span class="flex min-w-0 items-center gap-1 text-xs/tight">
<button
v-if="hasModelLabelControl"
ref="modelLabelControl"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
class="focus-visible:ring-ring m-0 min-w-0 cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
:title="displayModelName"
@click="handleModelLabelClick"
>
@@ -40,7 +40,7 @@
</button>
<span
v-else
class="font-normal wrap-break-word text-base-foreground"
class="min-w-0 font-normal wrap-break-word text-base-foreground"
:title="displayModelName"
>
{{ displayModelName }}
@@ -48,14 +48,14 @@
<span
v-if="hasMultipleReferences"
data-testid="missing-model-reference-count"
class="ml-2 inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected align-middle text-xs font-bold text-muted-foreground"
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
>
{{ model.referencingNodes.length }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="ml-2 inline-flex size-7 shrink-0 align-middle text-muted-foreground hover:bg-transparent hover:text-base-foreground"
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
:aria-label="linkLabel"
:title="linkLabel"
@click="copyModelLink"
@@ -82,7 +82,7 @@
data-testid="missing-model-import"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
class="shrink-0 focus-visible:ring-inset"
@click="showUploadDialog"
>
{{ t('g.import') }}
@@ -123,7 +123,7 @@
data-testid="missing-model-download"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
class="shrink-0 focus-visible:ring-inset"
:aria-label="`${t('g.download')} ${model.name}`"
@click="handleDownload"
>
@@ -137,7 +137,7 @@
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
@click="handleLocatePrimary"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
@@ -149,7 +149,7 @@
v-if="showReferenceList"
:class="
cn(
'm-0 list-none space-y-0.5 p-0',
'm-0 list-none p-0',
(hasMultipleReferences || isUnknownCategory) && 'pl-5'
)
"
@@ -159,10 +159,10 @@
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
class="min-w-0"
>
<div class="flex min-h-6 min-w-0 items-center gap-2">
<div class="flex min-h-8 min-w-0 items-center gap-2">
<button
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="emit('locateModel', String(ref.nodeId))"
>
{{
@@ -174,7 +174,7 @@
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="ml-auto size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
class="ml-auto size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />

View File

@@ -42,18 +42,6 @@ beforeEach(() => {
delete window.__comfyDesktop2Remote
})
function setLegacyDesktop2Bridge(
downloadModel: NonNullable<
NonNullable<typeof window.__comfyDesktop2>['downloadModel']
>
): void {
Object.defineProperty(window, '__comfyDesktop2', {
configurable: true,
writable: true,
value: { downloadModel }
})
}
describe('fetchModelMetadata', () => {
beforeEach(() => {
mockIsDesktop.value = false
@@ -270,10 +258,7 @@ describe('downloadModel', () => {
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockResolvedValue(true)
window.__comfyDesktop2 = {
isRemote: () => false,
downloadModel: desktopDownloadModel
}
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
downloadModel(
{
@@ -304,10 +289,7 @@ describe('downloadModel', () => {
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockRejectedValue(bridgeError)
window.__comfyDesktop2 = {
isRemote: () => false,
downloadModel: desktopDownloadModel
}
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
downloadModel(
{
@@ -341,10 +323,7 @@ describe('downloadModel', () => {
.mockImplementation(() => {
throw bridgeError
})
window.__comfyDesktop2 = {
isRemote: () => false,
downloadModel: desktopDownloadModel
}
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
downloadModel(
{
@@ -374,62 +353,7 @@ describe('downloadModel', () => {
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockResolvedValue(true)
window.__comfyDesktop2 = {
isRemote: () => true,
downloadModel: desktopDownloadModel
}
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
expect(desktopDownloadModel).not.toHaveBeenCalled()
expect(anchorClick).toHaveBeenCalledTimes(1)
})
it('uses the Desktop2 bridge when the new remote check is not available', () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
const desktopDownloadModel = vi
.fn<
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockResolvedValue(true)
setLegacyDesktop2Bridge(desktopDownloadModel)
downloadModel(
{
name: 'model.safetensors',
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{ checkpoints: ['/models/checkpoints'] }
)
expect(desktopDownloadModel).toHaveBeenCalledWith(
'https://huggingface.co/org/model/resolve/main/model.safetensors',
'model.safetensors',
'checkpoints'
)
expect(anchorClick).not.toHaveBeenCalled()
})
it('honors the legacy Desktop2 remote marker when the new remote check is not available', () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
const desktopDownloadModel = vi
.fn<
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockResolvedValue(true)
setLegacyDesktop2Bridge(desktopDownloadModel)
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
window.__comfyDesktop2Remote = true
downloadModel(

View File

@@ -2,10 +2,20 @@ import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { ComfyDesktop2Bridge } from '@/types'
type Desktop2BridgeWithLegacyRemote = Omit<ComfyDesktop2Bridge, 'isRemote'> & {
isRemote?: ComfyDesktop2Bridge['isRemote']
interface ComfyDesktop2Bridge {
downloadModel: (
url: string,
filename: string,
directory: string
) => Promise<boolean>
}
declare global {
interface Window {
__comfyDesktop2?: ComfyDesktop2Bridge
__comfyDesktop2Remote?: boolean
}
}
const ALLOWED_SOURCES = [
@@ -41,22 +51,16 @@ export interface ModelWithUrl {
}
async function startDesktop2ModelDownload(
bridge: Desktop2BridgeWithLegacyRemote,
bridge: ComfyDesktop2Bridge,
model: ModelWithUrl
): Promise<void> {
try {
await bridge.downloadModel?.(model.url, model.name, model.directory)
await bridge.downloadModel(model.url, model.name, model.directory)
} catch (error: unknown) {
console.error('Failed to start Desktop2 model download:', error)
}
}
function isRemoteDesktop2Bridge(
bridge: Desktop2BridgeWithLegacyRemote
): boolean {
return bridge.isRemote?.() ?? window.__comfyDesktop2Remote ?? false
}
/**
* Converts a model download URL to a browsable page URL.
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
@@ -86,10 +90,7 @@ export function downloadModel(
paths: Record<string, string[]>
): void {
const desktop2Bridge = window.__comfyDesktop2
if (
desktop2Bridge?.downloadModel &&
!isRemoteDesktop2Bridge(desktop2Bridge)
) {
if (desktop2Bridge?.downloadModel && !window.__comfyDesktop2Remote) {
void startDesktop2ModelDownload(desktop2Bridge, model)
return
}

View File

@@ -3,21 +3,26 @@
<div class="flex min-h-8 w-full items-center gap-1">
<Button
v-if="hasMultipleNodeTypes"
data-testid="swap-node-group-expand"
variant="textonly"
size="unset"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
"
aria-hidden="true"
tabindex="-1"
:aria-expanded="expanded"
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
@click="toggleExpand"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
:class="
cn(
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90'
)
"
/>
</Button>
@@ -27,7 +32,7 @@
<button
v-if="hasMultipleNodeTypes"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
:title="group.type"
:aria-label="titleToggleAriaLabel"
:aria-expanded="expanded"
@@ -38,7 +43,7 @@
<button
v-else-if="primaryLocatableNodeType"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
:title="group.type"
@click="handleLocateNode(primaryLocatableNodeType)"
>
@@ -46,7 +51,7 @@
</button>
<span
v-else
class="min-w-0 truncate text-sm/relaxed font-normal text-base-foreground"
class="min-w-0 truncate text-xs/relaxed font-normal text-base-foreground"
:title="group.type"
>
{{ group.type }}
@@ -55,7 +60,7 @@
v-if="hasMultipleNodeTypes"
data-testid="swap-node-group-count"
role="img"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
:aria-label="t('g.nodesCount', group.nodeTypes.length)"
>
{{ group.nodeTypes.length }}
@@ -80,7 +85,7 @@
<Button
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
class="shrink-0 focus-visible:ring-inset"
@click="handleReplaceNode"
>
<i
@@ -96,7 +101,7 @@
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="locateNodeLabel"
@click="handleLocateNode(primaryLocatableNodeType)"
>
@@ -116,14 +121,14 @@
<button
v-if="isLocatableNodeType(nodeType)"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="handleLocateNode(nodeType)"
>
{{ getLabel(nodeType) }}
</button>
<span
v-else
class="text-sm/relaxed wrap-break-word text-muted-foreground"
class="text-xs/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
@@ -132,7 +137,7 @@
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="locateNodeLabel"
@click="handleLocateNode(nodeType)"
>

View File

@@ -1,5 +1,5 @@
<template>
<div class="mt-2 px-4 pb-2">
<div class="px-3">
<SwapNodeGroupRow
v-for="group in swapNodeGroups"
:key="group.type"

View File

@@ -1,5 +1,5 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="sm">
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>

View File

@@ -53,13 +53,16 @@ describe('useSettingsDialog', () => {
isCloudRef.value = false
})
it("show() opens the Reka renderer with size 'full' and 960px content sizing", () => {
it("show() opens the Reka renderer with size 'full' and 1280px content sizing", () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('global-settings')
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('full')
expect(args.dialogComponentProps.contentClass).toContain('max-w-[960px]')
expect(args.dialogComponentProps.contentClass).toContain('max-w-[1280px]')
expect(args.dialogComponentProps.contentClass).not.toContain(
'max-w-[960px]'
)
expect(args.dialogComponentProps.contentClass).toContain('h-[80vh]')
})

View File

@@ -8,8 +8,9 @@ import type { SettingPanelType } from '@/platform/settings/types'
const DIALOG_KEY = 'global-settings'
// The redesigned Settings dialog is 1280px wide (DES 3253-16079).
const SETTINGS_CONTENT_CLASS =
'w-[90vw] max-w-[960px] sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
'w-[90vw] max-w-[1280px] sm:max-w-[1280px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
export function useSettingsDialog() {
const dialogService = useDialogService()

View File

@@ -9,6 +9,7 @@ import type {
ShareLinkOpenedMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
@@ -18,7 +19,6 @@ import type {
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
ShellLayoutMetadata,
@@ -112,8 +112,11 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackApiCreditTopupSucceeded?.())
}
trackRunButton(properties: RunButtonProperties): void {
this.dispatch((provider) => provider.trackRunButton?.(properties))
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
this.dispatch((provider) => provider.trackRunButton?.(options))
}
startTopupTracking(): void {

View File

@@ -1,4 +1,11 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: { value: 'app' },
isAppMode: { value: true }
})
}))
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
@@ -185,22 +192,8 @@ describe('GtmTelemetryProvider', () => {
it('pushes run_workflow with trigger_source', () => {
const provider = createInitializedProvider()
provider.trackRunButton({
subscribe_to_run: false,
workflow_type: 'custom',
workflow_name: 'untitled',
custom_node_count: 0,
total_node_count: 0,
subgraph_count: 0,
has_api_nodes: false,
api_node_names: [],
has_toolkit_nodes: false,
toolkit_node_names: [],
trigger_source: 'button',
view_mode: 'app',
is_app_mode: true,
dock_state: 'floating'
})
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
provider.trackRunButton({ trigger_source: 'button' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'run_workflow',
trigger_source: 'button',

View File

@@ -5,6 +5,7 @@ import type {
EnterLinearMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
@@ -12,7 +13,6 @@ import type {
NodeSearchResultMetadata,
PageViewMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
@@ -29,6 +29,8 @@ import type {
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { useAppMode } from '@/composables/useAppMode'
import { getActionbarDockState } from '../../utils/getActionbarDockState'
/**
* Google Tag Manager telemetry provider.
@@ -181,13 +183,18 @@ export class GtmTelemetryProvider implements TelemetryProvider {
)
}
trackRunButton(properties: RunButtonProperties): void {
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const { mode, isAppMode } = useAppMode()
this.pushEvent('run_workflow', {
subscribe_to_run: properties.subscribe_to_run,
trigger_source: properties.trigger_source ?? 'unknown',
view_mode: properties.view_mode,
is_app_mode: properties.is_app_mode,
dock_state: properties.dock_state
subscribe_to_run: options?.subscribe_to_run ?? false,
trigger_source: options?.trigger_source ?? 'unknown',
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
})
}

View File

@@ -17,6 +17,13 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ onUserResolved: mockOnUserResolved })
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: { value: 'graph' },
isAppMode: { value: false }
})
}))
const topupMocks = vi.hoisted(() => ({
startTopupTracking: vi.fn(),
clearTopupTracking: vi.fn(),
@@ -24,6 +31,20 @@ const topupMocks = vi.hoisted(() => ({
}))
vi.mock('@/platform/telemetry/topupTracker', () => topupMocks)
vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({
getExecutionContext: () => ({
is_template: false,
workflow_name: 'untitled',
custom_node_count: 0,
total_node_count: 0,
subgraph_count: 0,
has_api_nodes: false,
api_node_names: [],
has_toolkit_nodes: false,
toolkit_node_names: []
})
}))
const mockNormalizeSurveyResponses = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry/utils/surveyNormalization', () => ({
normalizeSurveyResponses: mockNormalizeSurveyResponses
@@ -38,7 +59,6 @@ import type {
AuthMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
RunButtonProperties,
ShareFlowMetadata,
ShellLayoutMetadata,
SurveyResponses,
@@ -430,33 +450,27 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
)
})
it('trackRunButton forwards RunButtonProperties', async () => {
it('trackRunButton populates RunButtonProperties from the execution context', async () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const properties: RunButtonProperties = {
provider.trackRunButton({
subscribe_to_run: true,
workflow_type: 'custom',
workflow_name: 'untitled',
custom_node_count: 0,
total_node_count: 0,
subgraph_count: 0,
has_api_nodes: false,
api_node_names: [],
has_toolkit_nodes: false,
toolkit_node_names: [],
trigger_source: 'button',
view_mode: 'graph',
is_app_mode: false,
dock_state: 'floating'
}
provider.trackRunButton(properties)
trigger_source: 'button'
})
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.RUN_BUTTON_CLICKED,
properties
expect.objectContaining({
subscribe_to_run: true,
workflow_type: 'custom',
trigger_source: 'button',
view_mode: 'graph',
is_app_mode: false,
dock_state: 'floating'
})
)
})

View File

@@ -2,6 +2,7 @@ import type { OverridedMixpanel } from 'mixpanel-browser'
import { omit } from 'es-toolkit'
import { watch } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
checkForCompletedTopup as checkTopupUtil,
@@ -10,11 +11,14 @@ import {
} from '@/platform/telemetry/topupTracker'
import type { AuditLog } from '@/services/customerEventsService'
import { getExecutionContext } from '../../utils/getExecutionContext'
import type {
AuthMetadata,
CreditTopupMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
@@ -44,6 +48,7 @@ import type {
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { TelemetryEvents } from '../../types'
import { getActionbarDockState } from '../../utils/getActionbarDockState'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
@@ -271,8 +276,31 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
clearTopupUtil()
}
trackRunButton(properties: RunButtonProperties): void {
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const executionContext = getExecutionContext()
const { mode, isAppMode } = useAppMode()
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
workflow_type: executionContext.is_template ? 'template' : 'custom',
workflow_name: executionContext.workflow_name ?? 'untitled',
custom_node_count: executionContext.custom_node_count,
total_node_count: executionContext.total_node_count,
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
api_node_names: executionContext.api_node_names,
has_toolkit_nodes: executionContext.has_toolkit_nodes,
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
}
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
}
trackSurvey(

View File

@@ -3,6 +3,7 @@ import { watch } from 'vue'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
@@ -14,6 +15,7 @@ import type {
EnterLinearMetadata,
ShareFlowMetadata,
ShareLinkOpenedMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
@@ -44,6 +46,8 @@ import type {
WorkflowSavedMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { getActionbarDockState } from '../../utils/getActionbarDockState'
import { getExecutionContext } from '../../utils/getExecutionContext'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
@@ -370,8 +374,31 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
trackRunButton(properties: RunButtonProperties): void {
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const executionContext = getExecutionContext()
const { mode, isAppMode } = useAppMode()
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
workflow_type: executionContext.is_template ? 'template' : 'custom',
workflow_name: executionContext.workflow_name ?? 'untitled',
custom_node_count: executionContext.custom_node_count,
total_node_count: executionContext.total_node_count,
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
api_node_names: executionContext.api_node_names,
has_toolkit_nodes: executionContext.has_toolkit_nodes,
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
}
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
}
trackSurvey(

View File

@@ -12,11 +12,11 @@
* 3. Check dist/assets/*.js files contain no tracking code
*/
import type { AppMode } from '@/composables/useAppMode'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { AuditLog } from '@/services/customerEventsService'
import type { AppMode } from '@/utils/appMode'
/**
* Authentication metadata for sign-up tracking
@@ -486,7 +486,10 @@ export interface TelemetryProvider {
trackAddApiCreditButtonClicked?(): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
trackApiCreditTopupSucceeded?(): void
trackRunButton?(properties: RunButtonProperties): void
trackRunButton?(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void
// Credit top-up tracking (composition with internal utilities)
startTopupTracking?(): void

View File

@@ -5,7 +5,13 @@ const state = vi.hoisted(() => ({
activeSidebarTabId: null as string | null,
rightSidePanelOpen: false,
bottomPanelVisible: false,
openWorkflows: [] as unknown[]
openWorkflows: [] as unknown[],
mode: { value: 'graph' },
isAppMode: { value: false }
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: state.mode, isAppMode: state.isAppMode })
}))
vi.mock('@/platform/settings/settingStore', () => ({
@@ -42,12 +48,12 @@ describe('getShellLayoutSnapshot', () => {
state.rightSidePanelOpen = false
state.bottomPanelVisible = false
state.openWorkflows = []
state.mode.value = 'graph'
state.isAppMode.value = false
})
it('captures the default layout', () => {
expect(
getShellLayoutSnapshot({ view_mode: 'graph', is_app_mode: false })
).toEqual({
expect(getShellLayoutSnapshot()).toEqual({
view_mode: 'graph',
is_app_mode: false,
dock_state: 'docked',
@@ -65,10 +71,10 @@ describe('getShellLayoutSnapshot', () => {
state.rightSidePanelOpen = true
state.bottomPanelVisible = true
state.openWorkflows = [{}, {}, {}]
state.mode.value = 'app'
state.isAppMode.value = true
expect(
getShellLayoutSnapshot({ view_mode: 'app', is_app_mode: true })
).toEqual({
expect(getShellLayoutSnapshot()).toEqual({
view_mode: 'app',
is_app_mode: true,
dock_state: 'floating',

View File

@@ -1,3 +1,4 @@
import { useAppMode } from '@/composables/useAppMode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
@@ -7,15 +8,11 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { ShellLayoutMetadata } from '../types'
import { getActionbarDockState } from './getActionbarDockState'
type ShellLayoutMode = Pick<ShellLayoutMetadata, 'view_mode' | 'is_app_mode'>
export function getShellLayoutSnapshot({
view_mode,
is_app_mode
}: ShellLayoutMode): ShellLayoutMetadata {
export function getShellLayoutSnapshot(): ShellLayoutMetadata {
const { mode, isAppMode } = useAppMode()
return {
view_mode,
is_app_mode,
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState(),
actionbar_position: useSettingStore().get('Comfy.UseNewMenu'),
active_sidebar_tab: useSidebarTabStore().activeSidebarTabId,

View File

@@ -18,9 +18,9 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { app } from '@/scripts/app'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
import type { AppMode } from '@/utils/appMode'
import { t } from '@/i18n'
function createModeTestWorkflow(

View File

@@ -23,6 +23,7 @@ import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -36,7 +37,6 @@ import {
appendWorkflowJsonExt,
generateUUID
} from '@/utils/formatUtil'
import type { AppMode } from '@/utils/appMode'
function linearModeToAppMode(linearMode: unknown): AppMode | null {
if (typeof linearMode !== 'boolean') return null

View File

@@ -2,13 +2,13 @@ import { markRaw } from 'vue'
import { t } from '@/i18n'
import type { ChangeTracker } from '@/scripts/changeTracker'
import type { AppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { UserFile } from '@/stores/userFileStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingNodeType } from '@/types/comfy'
import type { AppMode } from '@/utils/appMode'
export interface InputWidgetConfig {
height?: number

View File

@@ -187,9 +187,13 @@ vi.mock('@/lib/litegraph/src/LLink', () => ({
LLink: { getReroutes: () => [] }
}))
vi.mock('@/lib/litegraph/src/types/globalEnums', () => ({
LinkDirection: { LEFT: 0, RIGHT: 1, NONE: -1 }
}))
vi.mock('@/lib/litegraph/src/types/globalEnums', async (importOriginal) => {
const original = await importOriginal()
return {
...(original as object),
LinkDirection: { LEFT: 0, RIGHT: 1, NONE: -1 }
}
})
vi.mock('@/utils/rafBatch', () => ({
createRafBatch: (fn: () => void) => ({

View File

@@ -0,0 +1,229 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { capturedHandlers, mockLinkConnector, mockAdapter, cancelLinkRelease } =
vi.hoisted(() => ({
capturedHandlers: {} as Record<string, (...args: unknown[]) => void>,
mockLinkConnector: {
isConnecting: false,
state: { snapLinksPos: null as [number, number] | null },
events: {}
},
mockAdapter: {
beginFromOutput: vi.fn(),
beginFromInput: vi.fn(),
reset: vi.fn(),
renderLinks: [] as unknown[],
linkConnector: null as unknown,
isInputValidDrop: vi.fn(() => false),
isOutputValidDrop: vi.fn(() => false),
dropOnCanvas: vi.fn()
},
cancelLinkRelease: vi.fn()
}))
mockAdapter.linkConnector = mockLinkConnector
// Emulate the real teardown: cancelling a held session clears the connector
// state so the subsequent begin call no longer trips the guard.
cancelLinkRelease.mockImplementation(() => {
mockLinkConnector.isConnecting = false
})
vi.mock('@/stores/workspace/searchBoxStore', () => ({
useSearchBoxStore: () => ({ cancelLinkRelease })
}))
vi.mock('@/renderer/core/canvas/useAutoPan', () => ({
AutoPanController: class {
updatePointer = vi.fn()
start = vi.fn()
stop = vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
ds: { offset: [0, 0], scale: 1 },
graph: {
getNodeById: (id: string) => ({
id,
inputs: [],
outputs: [{ name: 'out', type: '*', links: [], _floatingLinks: null }]
}),
getLink: () => null,
getReroute: () => null
},
linkConnector: mockLinkConnector,
canvas: {
getBoundingClientRect: () => ({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600
})
},
setDirty: vi.fn()
}
}
}))
vi.mock('@/renderer/core/canvas/links/linkConnectorAdapter', () => ({
createLinkConnectorAdapter: () => mockAdapter
}))
vi.mock('@/renderer/core/canvas/links/slotLinkDragUIState', () => {
const pointer = { client: { x: 0, y: 0 }, canvas: { x: 0, y: 0 } }
return {
useSlotLinkDragUIState: () => ({
state: {
active: false,
pointerId: null,
source: null,
pointer,
candidate: null,
compatible: new Map()
},
beginDrag: vi.fn(),
endDrag: vi.fn(),
updatePointerPosition: vi.fn(),
setCandidate: vi.fn(),
setCompatibleForKey: vi.fn(),
clearCompatible: vi.fn()
})
}
})
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
useSharedCanvasPositionConversion: () => ({
clientPosToCanvasPos: (pos: [number, number]): [number, number] => pos
})
}))
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
getSlotLayout: () => ({
nodeId: 'node1',
index: 0,
type: 'output',
position: { x: 100, y: 200 }
}),
getAllSlotKeys: () => [],
getRerouteLayout: () => null,
queryRerouteAtPoint: () => null
}
}))
vi.mock('@/renderer/core/layout/slots/slotIdentifier', () => ({
getSlotKey: (...args: unknown[]) => args.join('-')
}))
vi.mock('@/renderer/core/canvas/interaction/canvasPointerEvent', () => ({
toCanvasPointerEvent: (e: PointerEvent) => e,
clearCanvasPointerHistory: vi.fn()
}))
vi.mock(
'@/renderer/extensions/vueNodes/composables/slotLinkDragContext',
() => ({
createSlotLinkDragContext: () => ({
reset: vi.fn(),
dispose: vi.fn()
})
})
)
vi.mock('@/renderer/extensions/vueNodes/utils/eventUtils', () => ({
augmentToCanvasPointerEvent: vi.fn()
}))
vi.mock('@/renderer/core/canvas/links/linkDropOrchestrator', () => ({
resolveSlotTargetCandidate: () => null,
resolveNodeSurfaceSlotCandidate: () => null
}))
vi.mock('@vueuse/core', () => ({
useEventListener: (event: string, handler: (...args: unknown[]) => void) => {
capturedHandlers[event] = handler
return vi.fn()
},
tryOnScopeDispose: () => {}
}))
vi.mock('@/lib/litegraph/src/LLink', () => ({
LLink: { getReroutes: () => [] }
}))
vi.mock('@/lib/litegraph/src/types/globalEnums', () => ({
LinkDirection: { LEFT: 0, RIGHT: 1, NONE: -1 }
}))
vi.mock('@/utils/rafBatch', () => ({
createRafBatch: (fn: () => void) => ({
schedule: () => {},
cancel: () => {},
flush: fn
})
}))
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
function pointerEvent(pointerId = 1): PointerEvent {
return fromPartial<PointerEvent>({
clientX: 400,
clientY: 300,
button: 0,
pointerId,
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
target: document.createElement('div'),
preventDefault: vi.fn(),
stopPropagation: vi.fn()
})
}
function startDrag() {
const { onPointerDown } = useSlotLinkInteraction({
nodeId: 'node1',
index: 0,
type: 'output'
})
onPointerDown(pointerEvent())
}
describe('useSlotLinkInteraction held-session takeover', () => {
beforeEach(() => {
for (const k of Object.keys(capturedHandlers)) delete capturedHandlers[k]
mockLinkConnector.isConnecting = false
cancelLinkRelease.mockClear()
mockAdapter.beginFromOutput.mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
it('cancels a held link-release session before starting a new drag', () => {
mockLinkConnector.isConnecting = true
startDrag()
expect(cancelLinkRelease).toHaveBeenCalledOnce()
expect(mockAdapter.beginFromOutput).toHaveBeenCalled()
expect(cancelLinkRelease.mock.invocationCallOrder[0]).toBeLessThan(
mockAdapter.beginFromOutput.mock.invocationCallOrder[0]
)
})
it('does not cancel when no session is held', () => {
startDrag()
expect(cancelLinkRelease).not.toHaveBeenCalled()
expect(mockAdapter.beginFromOutput).toHaveBeenCalled()
})
})

View File

@@ -32,6 +32,7 @@ import { toPoint } from '@/renderer/core/layout/utils/geometry'
import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
import { augmentToCanvasPointerEvent } from '@/renderer/extensions/vueNodes/utils/eventUtils'
import { app } from '@/scripts/app'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { createRafBatch } from '@/utils/rafBatch'
interface SlotInteractionOptions {
@@ -605,6 +606,13 @@ export function useSlotLinkInteraction({
const graph = canvas?.graph
if (!canvas || !graph) return
// A held link-release session (menu open, links kept alive) leaves the
// connector mid-drag. Tear it down so this new drag can take over instead
// of tripping LinkConnector's "Already dragging links" guard.
if (canvas.linkConnector.isConnecting && !pointerSession.isActive()) {
useSearchBoxStore().cancelLinkRelease()
}
activeAdapter = createLinkConnectorAdapter()
if (!activeAdapter) return
raf.cancel()

View File

@@ -1,12 +1,11 @@
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import { useSettingStore } from '@/platform/settings/settingStore'
import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats'
import { type StatusWsMessageStatus } from '@/schemas/apiSchema'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { isCloud } from '@/platform/distribution/types'
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats'
import { type StatusWsMessageStatus } from '@/schemas/apiSchema'
import { useLitegraphService } from '@/services/litegraphService'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -489,9 +488,7 @@ export class ComfyUI {
textContent: 'Queue Prompt',
onclick: () => {
if (isCloud) {
useRunButtonTelemetry().trackRunButton({
trigger_source: 'legacy_ui'
})
useTelemetry()?.trackRunButton({ trigger_source: 'legacy_ui' })
useTelemetry()?.trackWorkflowExecution()
}
app.queuePrompt(0, this.batchCount)
@@ -599,9 +596,7 @@ export class ComfyUI {
textContent: 'Queue Front',
onclick: () => {
if (isCloud) {
useRunButtonTelemetry().trackRunButton({
trigger_source: 'legacy_ui'
})
useTelemetry()?.trackRunButton({ trigger_source: 'legacy_ui' })
useTelemetry()?.trackWorkflowExecution()
}
app.queuePrompt(-1, this.batchCount)

View File

@@ -21,6 +21,7 @@ import {
SubgraphNode,
createBounds
} from '@/lib/litegraph/src/litegraph'
import { overlapBounding } from '@/lib/litegraph/src/measure'
import type {
CreateNodeOptions,
GraphAddOptions,
@@ -943,10 +944,40 @@ export const useLitegraphService = () => {
const graph = useWorkflowStore().activeSubgraph ?? app.graph
if (!graph || !node) return null
// Finalize placement before the node joins the graph so the only position
// assignment happens during construction, not as a post-add mutation.
if (!addOptions?.ghost) resolveOverlap(node, graph)
graph.add(node, addOptions)
if (!addOptions?.ghost) centerOnNewNode(node)
return node
}
const OVERLAP_GAP = 20
const OVERLAP_MAX_ITER = 100
function resolveOverlap(
node: LGraphNode,
graph: { nodes: LGraphNode[] }
): void {
node.updateArea()
let iter = 0
while (
iter++ < OVERLAP_MAX_ITER &&
graph.nodes.some(
(n) =>
n.id !== node.id && overlapBounding(node.boundingRect, n.boundingRect)
)
) {
node.pos[1] += node.size[1] + OVERLAP_GAP
node.updateArea()
}
}
function centerOnNewNode(node: LGraphNode): void {
node.updateArea()
app.canvas?.animateToBounds(node.boundingRect, { zoom: 0 })
}
function getCanvasCenter(): Point {
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
const visibleArea = app.canvas?.ds?.visible_area

View File

@@ -2,7 +2,12 @@ import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import {
getWorkflowMode,
isAppModeValue,
useAppMode
} from '@/composables/useAppMode'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -35,8 +40,6 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
import type { AppMode } from '@/utils/appMode'
import { getWorkflowMode, isAppModeValue } from '@/utils/appMode'
interface ExecutionNodeInfo {
title?: string | null

View File

@@ -1,4 +1,5 @@
import { useTimeoutFn } from '@vueuse/core'
import { mapKeys } from 'es-toolkit'
import { defineStore } from 'pinia'
import { ref } from 'vue'
@@ -358,8 +359,12 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
function restoreOutputs(
outputs: Record<string, ExecutedWsMessage['output']>
) {
app.nodeOutputs = outputs
nodeOutputs.value = { ...outputs }
const parsedOutputs = mapKeys(
outputs,
(_, id) => executionIdToNodeLocatorId(app.rootGraph, id) ?? id
)
app.nodeOutputs = parsedOutputs
nodeOutputs.value = { ...parsedOutputs }
}
function updateNodeImages(node: LGraphNode) {

View File

@@ -20,7 +20,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
}))
function createMockPopover(): InstanceType<typeof NodeSearchBoxPopover> {
return { showSearchBox: vi.fn() } as Partial<
return { showSearchBox: vi.fn(), cancelLinkRelease: vi.fn() } as Partial<
InstanceType<typeof NodeSearchBoxPopover>
> as InstanceType<typeof NodeSearchBoxPopover>
}
@@ -135,4 +135,23 @@ describe('useSearchBoxStore', () => {
expect(store.visible).toBe(false)
})
})
describe('cancelLinkRelease', () => {
it('delegates to the popover to tear down a held link-release session', () => {
const store = useSearchBoxStore()
const mockPopover = createMockPopover()
store.setPopoverRef(mockPopover)
store.cancelLinkRelease()
expect(vi.mocked(mockPopover.cancelLinkRelease)).toHaveBeenCalled()
})
it('does nothing when the popover is not ready', () => {
const store = useSearchBoxStore()
store.setPopoverRef(null)
expect(() => store.cancelLinkRelease()).not.toThrow()
})
})
})

View File

@@ -28,6 +28,10 @@ export const useSearchBoxStore = defineStore('searchBox', () => {
popoverRef.value = popover
}
function cancelLinkRelease() {
popoverRef.value?.cancelLinkRelease()
}
const visible = ref(false)
function toggleVisible() {
if (newSearchBoxEnabled.value) {
@@ -49,6 +53,7 @@ export const useSearchBoxStore = defineStore('searchBox', () => {
useSearchBoxV2,
newSearchBoxEnabled,
setPopoverRef,
cancelLinkRelease,
toggleVisible,
visible
}

View File

@@ -1,4 +1,3 @@
import type { ComfyDesktop2Bridge } from '@comfyorg/comfyui-desktop-bridge-types'
import type {
DeviceStats,
EmbeddingsResponse,
@@ -26,7 +25,6 @@ import type {
} from './extensionTypes'
export type { ComfyExtension } from './comfy'
export type { ComfyDesktop2Bridge } from '@comfyorg/comfyui-desktop-bridge-types'
export type { ComfyApi } from '@/scripts/api'
export type { ComfyApp } from '@/scripts/app'
export type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
@@ -90,8 +88,5 @@ declare global {
/** For use in tests to track app initialization state */
__appReadiness?: AppReadiness
__comfyDesktop2?: ComfyDesktop2Bridge
__comfyDesktop2Remote?: boolean
}
}

View File

@@ -1,21 +0,0 @@
export type AppMode =
| 'graph'
| 'app'
| 'builder:inputs'
| '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'
}

View File

@@ -111,7 +111,7 @@ const queueStore = useQueueStore()
const assetsStore = useAssetsStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
const { isBuilderMode, mode, isAppMode } = useAppMode()
const { isBuilderMode } = useAppMode()
const { linearMode } = storeToRefs(useCanvasStore())
watch(linearMode, (isLinear) => {
@@ -354,12 +354,7 @@ const onGraphReady = () => {
// Shell layout snapshot, once per session (cloud only)
if (isCloud && telemetry) {
telemetry.trackShellLayout(
getShellLayoutSnapshot({
view_mode: mode.value,
is_app_mode: isAppMode.value
})
)
telemetry.trackShellLayout(getShellLayoutSnapshot())
}
// Setting values now available after comfyApp.setup.

39
vercel.json Normal file
View File

@@ -0,0 +1,39 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "pnpm build:cloud",
"outputDirectory": "dist",
"installCommand": "pnpm install --frozen-lockfile",
"framework": null,
"env": {
"DISTRIBUTION": "cloud",
"USE_PROD_CONFIG": "true",
"ALGOLIA_APP_ID": "4E0RO38HS8",
"ALGOLIA_API_KEY": "684d998c36b67a9a9fce8fc2d8860579"
},
"rewrites": [
{
"source": "/api/:path*",
"destination": "https://cloud.comfy.org/api/:path*"
},
{
"source": "/internal/:path*",
"destination": "https://cloud.comfy.org/internal/:path*"
},
{
"source": "/extensions/:path*",
"destination": "https://cloud.comfy.org/extensions/:path*"
},
{
"source": "/workflow_templates/:path*",
"destination": "https://cloud.comfy.org/workflow_templates/:path*"
},
{
"source": "/oauth/:path*",
"destination": "https://cloud.comfy.org/oauth/:path*"
},
{
"source": "/((?!api/|assets/|.*\\..*).*)",
"destination": "/index.html"
}
]
}