Compare commits

...

22 Commits

Author SHA1 Message Date
Robin Huang
8a9d8d134c feat: allow server-side PostHog init config overrides
Replace individual posthog_api_host/posthog_debug fields with a generic
posthog_config object in RemoteConfig that gets spread into the PostHog
init call, allowing any PostHog init parameter to be overridden from
the /api/features endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:25:13 -07:00
Robin Huang
7bcdd8fcea feat: add remote config support for PostHog debug mode
Allow PostHog debug mode to be controlled via the /api/features
endpoint (posthog_debug), with VITE_POSTHOG_DEBUG env var as fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:27:48 -07:00
Robin Huang
fd7ce3a852 feat: add telemetry for workflow save and default view (#9734)
Add two new telemetry events: `app:workflow_saved` (with `is_app` and
`is_new` metadata) and `app:default_view_set` for App Builder (with the
chosen view mode). Instrumented in workflowService and
useAppSetDefaultView.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9734-feat-add-telemetry-for-workflow-save-and-default-view-3206d73d3650814e8678f83c8419625f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:31:21 -07:00
Dante
08a2f8ae15 ci: deploy Storybook to fixed production URL on main merge (#9373)
## Summary
- Add `deploy-production` job to Storybook CI workflow
- Triggers on push to `main` branch
- Deploys to fixed Cloudflare Pages URL:
`https://comfy-storybook.pages.dev`
- PR preview deployments remain unchanged

## Linked Issues
- Notion:
[COM-15826](https://www.notion.so/Deploy-Storybook-to-fixed-production-URL-on-main-branch-merge-3196d73d36508159a875d42694a619d1)

## Test Plan
- [ ] Merge to main and verify `deploy-production` job runs in GitHub
Actions
- [ ] Confirm `https://comfy-storybook.pages.dev` serves latest
Storybook
- [ ] Verify existing PR preview deployments still work
- [ ] Verify other jobs (comment, chromatic) are not affected by the new
trigger

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9373-ci-deploy-Storybook-to-fixed-production-URL-on-main-merge-3196d73d3650816795ead1a5b839a571)
by [Unito](https://www.unito.io)
2026-03-11 16:38:29 +01:00
Johnpaul Chiwetelu
7b9f24f515 fix: Rename keybindingService.forwarding.test.ts to reflect canvas keybinding tests (#9498) (#9658) 2026-03-11 13:50:54 +01:00
AustinMroz
faed80e99a Support tooltips on DynamicCombos (#9717)
Tooltips are normally resolved through the node definition. Since
DynamicCombo added widgets are nested in the spec definition, this
lookup fails to find them. This PR makes it so that when a widget is
dynamically added using `litegraphService:addNodeInput`, any 'tooltiptip
defined in the provided inputSpec is applied on the widget.

The tooltip system does not current support tooltips for dynamiclly
added inputs. That can be considered for a followup PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9717-Support-tooltips-on-DynamicCombos-31f6d73d365081dc93f9eadd98572b3c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-11 00:17:37 -07:00
Robin Huang
b129d64c5d fix: update PostHog api_host fallback domain (#9733)
Update the PostHog `api_host` fallback from `ph.comfy.org` to
`t.comfy.org`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9733-fix-update-PostHog-api_host-fallback-domain-3206d73d36508107a5d1e1fdfd3ccaec)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:44:26 -07:00
Jin Yi
4c9b83a224 fix: resolve extraneous attrs warning in TreeExplorerV2Node (#9735)
## Summary

Fix Vue warning about extraneous non-props attributes (`data-index`,
`style`) in `TreeExplorerV2Node`, which caused the Node Library sidebar
to freeze.

## Changes

- **What**: Added `defineOptions({ inheritAttrs: false })` and
`v-bind="$attrs"` on both node/folder `<div>` elements so the
virtualizer's positioning attributes are properly applied to the
rendered DOM.

## Review Focus

`TreeExplorerV2Node` has a fragment root (`<TreeItem as-child>` +
`<Teleport>`), so Vue cannot auto-inherit attrs. The virtualizer's
`style` (positioning) merges cleanly with `rowStyle` (`paddingLeft`
only).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9735-fix-resolve-extraneous-attrs-warning-in-TreeExplorerV2Node-3206d73d3650817c8619e2145e98813d)
by [Unito](https://www.unito.io)
2026-03-11 13:34:44 +09:00
Dante
e973efb44a fix: improve canvas menu keyboard navigation and ARIA accessibility (#9526)
## Summary

The canvas mode selector popover (Select/Hand mode) uses plain `div`
elements for its menu items, making them completely inaccessible to
keyboard-only users and screen readers. This PR replaces them with
proper semantic HTML and ARIA attributes.

## Problem (AS-IS)

As reported in #9519, the canvas mode selector popover has the following
accessibility issues:

1. **Menu items are `div` elements** — they cannot receive keyboard
focus, so users cannot Tab into the popover or activate items with
Enter/Space. Keyboard-only users are completely locked out of switching
between Select and Hand (pan) mode via the popover.

2. **No ARIA roles** — screen readers announce the popover content as
generic text rather than an interactive menu. Users relying on assistive
technology have no way to understand that these are selectable options.

3. **No keyboard navigation within the popover** — even if a user
somehow focuses an item, there are no ArrowUp/ArrowDown handlers to move
between options, which is the standard interaction pattern for
`role="menu"` widgets (WAI-ARIA Menu Pattern).

4. **Decorative icons are not hidden from assistive technology** — icon
elements (`<i>` tags) are exposed to screen readers, adding noise to the
announcement.

5. **The bottom toolbar (`GraphCanvasMenu`) lacks semantic grouping** —
the `ButtonGroup` container has no `role="toolbar"` or `aria-label`, so
screen readers cannot convey that these buttons form a related control
group.

> Note: Pan mode itself already has keyboard shortcuts (`H` for
Hand/Lock, `V` for Select/Unlock), but the popover UI that surfaces
these options is not keyboard-accessible.

## Solution (TO-BE)

1. **Replace `div` → `button`** for menu items in `CanvasModeSelector` —
buttons are natively focusable and activatable via Enter/Space without
extra work.

2. **Add `role="menu"` on the container and `role="menuitem"` on each
option** — screen readers now announce "Canvas Mode menu" with two menu
items.

3. **Add ArrowUp/ArrowDown keyboard navigation** with wrap-around —
pressing ArrowDown on "Select" moves focus to "Hand", and vice versa.
This follows the WAI-ARIA Menu Pattern.

4. **Add `aria-label` to each menu item and `aria-hidden="true"` to
decorative icons** — screen readers announce "Select" / "Hand" clearly
without icon noise.

5. **Add `role="toolbar"` with `aria-label="Canvas Toolbar"` to the
`ButtonGroup`** — screen readers identify the bottom control bar as a
coherent toolbar.

## Changes

- **What**: Accessibility improvements to `CanvasModeSelector` popover
and `GraphCanvasMenu` toolbar
- **Dependencies**: None — only HTML/ARIA attribute changes and two new
i18n keys (`canvasMode`, `canvasToolbar`)

## Review Focus

- Verify the `button` elements render visually identical to the previous
`div` elements (same padding, hover styles, cursor)
- Confirm ArrowUp/ArrowDown navigation works correctly in the popover
- Check that screen readers announce the menu and toolbar correctly

Fixes #9519

> Note: The issue also requests Space-bar hold-to-pan, Tab through node
ports, and link creation mode keyboard shortcuts. These are significant
new features beyond the scope of this accessibility fix and should be
tracked separately.

## Test plan

- [x] Unit tests for ARIA roles, button elements, aria-labels,
aria-hidden, and arrow key navigation (7 tests)
- [ ] Manual: open canvas mode selector popover → Tab focuses first item
→ ArrowDown/ArrowUp navigates → Enter/Space selects
- [ ] Screen reader: verify "Canvas Mode menu" with "Select" and "Hand"
menu items are announced

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9526-fix-improve-canvas-menu-keyboard-navigation-and-ARIA-accessibility-31c6d73d3650814c9487ecf748cf6a99)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:15:17 +09:00
Luke Mino-Altherr
9ecb100d11 fix: make zPreviewOutput accept text-only job outputs (#9724)
## Summary

Fixes Zod validation crash when the jobs batch contains text-only
preview outputs (e.g. from LLM nodes), which caused the entire Assets
sidebar to show nothing.

## Changes

- **What**: Made `filename`, `subfolder`, and `type` optional in
`zPreviewOutput` and added `.passthrough()` for extra fields like
`content`. Text-only jobs are safely filtered out downstream by
`supportsPreview`.
- Added tests in `fetchJobs.test.ts` verifying a mixed batch (image +
text-only + no-preview) parses successfully.
- Added test in `assetsStore.test.ts` verifying text-only jobs are
skipped without breaking sibling image jobs. Improved `TaskItemImpl`
mock to realistically handle media types.

## Review Focus

- The `zPreviewOutput` schema now uses `.passthrough()` to allow extra
fields from new preview types (like `content` on text previews). This is
consistent with `zRawJobListItem` and `zExecutionError` which also use
`.passthrough()`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9724-fix-make-zPreviewOutput-accept-text-only-job-outputs-31f6d73d36508119a7aef99f9b765ecd)
by [Unito](https://www.unito.io)
2026-03-10 18:18:54 -07:00
Jin Yi
dc3e455993 fix: cloud login page stuck on splash loader for unauthenticated users (#9725)
## Summary

Fix cloud login page showing only the splash loader (wave SVG) instead
of the login form when the user is not authenticated.

## Changes

- **What**: Remove splash loader on `CloudLayoutView` mount. Cloud
onboarding pages do not use workspace initialization, so the
`workspaceStore.spinner` transition (`true→false`) that normally removes
the splash never occurs — leaving it visible indefinitely.

Co-authored-by: Amp <amp@ampcode.com>
2026-03-11 08:35:48 +09:00
Dante
76006fca52 feat: add text widget stories and Number input stories (#9527)
<img width="842" height="488" alt="스크린샷 2026-03-07 오후 9 39 20"
src="https://github.com/user-attachments/assets/9ac8bfcd-c882-4661-851f-b08838d4fed1"
/>

## Summary
- Add Storybook stories for WidgetInputText, WidgetTextarea, and
ScrubableNumberInput
- Reorganize story titles under `Components/Input/` to align with Figma
design system
- Fix PrimeIcons not rendering in Storybook (caused by
`[&_*]:!font-inter` override)
- Fix knip unused export warnings (dead code removal + workspace config)

## Test plan
- [ ] Run `pnpm storybook` and verify Components/Input/InputText stories
render
- [ ] Verify Components/Input/TextArea stories render with label and
copy button
- [ ] Verify Components/Input/Number stories render with -/+ icons
- [ ] Toggle Storybook theme between Light/Dark and confirm Number story
adapts
- [ ] Verify existing Button stories still render correctly

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9527-feat-add-text-widget-stories-and-Number-input-stories-31c6d73d3650817ba351cdef26a356c8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:34:57 +09:00
Dante
d2792cfac6 feat: add Storybook stories for Display components (#9702)
## Summary
- Add Storybook stories for `WidgetImageCompare` (Default,
WithBatchNavigation, SingleImageFallback, NoImages)
- WidgetGalleria and ImagePreview stories are deferred pending PrimeVue
removal

## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] Verified all stories render correctly in Storybook

Figma ref:
https://www.figma.com/design/vALUV83vIdBzEsTJAhQgXq/Comfy-Design-System?node-id=55-1536

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9702-feat-add-Storybook-stories-for-Display-components-31f6d73d365081e781faf3a8735aa3dc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:30:03 +09:00
Jin Yi
a786825093 feat: replace PrimeVue AutoComplete with SearchAutocomplete in ManagerDialog (#9645)
## Summary

Replace legacy PrimeVue `AutoCompletePlus` with a new
`SearchAutocomplete` component built on Reka UI, matching the
`SearchInput` design system.

## Changes

- **What**: Add `SearchAutocomplete` component extending `SearchInput`
with dropdown suggestions, IME composition handling, and generic typed
`optionLabel` support. Replace `AutoCompletePlus` usage in
`ManagerDialog`.
- **Dependencies**: None (uses existing Reka UI Combobox primitives)

## Review Focus

- `SearchAutocomplete` feature parity with the replaced
`AutoCompletePlus` (suggestions, option selection, IME handling)
- Dropdown styling and positioning via Reka UI `ComboboxContent`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9645-feat-replace-PrimeVue-AutoComplete-with-SearchAutocomplete-in-ManagerDialog-31e6d73d36508117ba0bef3d30dd0863)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-11 08:14:06 +09:00
Jin Yi
b0f3b69bda fix: add text color and increase size for nav badge count (#9713)
## Summary

Fix nav sidebar badge count not visible due to missing text color, and
increase badge size for better readability.

## Changes

- **What**: Added explicit `text-base-background` color and increased
min size (`min-h-5 min-w-5`) with padding to the StatusBadge in NavItem
so the count number is visible in dark mode.

## Review Focus

The parent NavItem div sets `text-base-foreground` which was overriding
the StatusBadge's contrast severity text color, making the count
invisible against the badge background.

## As-is
<img width="1607" height="1076" alt="스크린샷 2026-03-10 오후 9 28 17"
src="https://github.com/user-attachments/assets/f34de3fa-8930-4328-ba2b-03990a5e6f22"
/>

## To-be
<img width="1607" height="1058" alt="스크린샷 2026-03-10 오후 9 34 48"
src="https://github.com/user-attachments/assets/e420c359-78b7-4f5d-9d03-a600c51b880c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9713-fix-add-text-color-and-increase-size-for-nav-badge-count-31f6d73d36508114874be2e31627099a)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-11 08:13:48 +09:00
Jin Yi
d11a0f6c5e feat: replace loading indicator with C logo fill loader and pre-Vue splash screen (#9516) 2026-03-11 08:00:10 +09:00
Jin Yi
f97c38e6ee fix: detect missing nodes when registry API fails to resolve packs (#9697) 2026-03-11 07:56:46 +09:00
Robin Huang
e89a0f96cd feat: track app mode entry and shared workflow loading (#9720)
## Summary

- Track entering app mode from template URL (`source: template_url`) and
default view dialog (`source: default_view_dialog`)
- Tag shared workflow loads with `openSource: 'shared'` instead of
defaulting to `'unknown'`
- Rename telemetry event from `app:toggle_linear_mode` to
`app:app_mode_opened`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9720-feat-track-app-mode-entry-and-shared-workflow-loading-31f6d73d365081af8c6ae3247a50cf3f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:05:19 -07:00
Robin Huang
12989e8b63 feat: add copy button to System Info panel (#9719)
## Summary

Adds a "Copy System Info" button next to the System Info heading in
Settings > About. Copies all system and device details as markdown text
for easy pasting into Slack or Notion.
<img width="1175" height="725" alt="Screenshot 2026-03-10 at 1 30 51 PM"
src="https://github.com/user-attachments/assets/6a091b6d-2246-4dc7-bc1d-8b5ebc2f9f9b"
/>


## Test plan
- Open Settings > About
- Click "Copy System Info" button
- Paste into Slack/Notion and verify formatting

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9719-feat-add-copy-button-to-System-Info-panel-31f6d73d36508148a06ae5f478ba62bf)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:57:11 -07:00
Hunter
c084605e4d fix: default frontend preview variant to cpu (#9718)
Frontend previews don't need GPU resources. Default to cpu variant and
only use gpu when the `preview-gpu` label is explicitly added.

The plain `preview` label now deploys a cpu-only ephemeral env.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9718-fix-default-frontend-preview-variant-to-cpu-31f6d73d3650811f878cd5dd5ad3881c)
by [Unito](https://www.unito.io)
2026-03-10 15:31:03 -04:00
Hunter
b368a865cf feat: dispatch frontend PR preview environments to cloud (#9715)
## Summary

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

### Changes

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

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

### Companion PR

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

### How it works

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

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9686-Mobile-input-tweaks-31f6d73d3650811d9025d0cd1ac58534)
by [Unito](https://www.unito.io)
2026-03-09 23:08:42 -07:00
71 changed files with 1619 additions and 486 deletions

View File

@@ -38,6 +38,9 @@ TEST_COMFYUI_DIR=/home/ComfyUI
ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

View File

@@ -4,6 +4,8 @@ name: 'CI: Tests Storybook'
on:
workflow_dispatch: # Allow manual triggering
pull_request:
push:
branches: [main]
jobs:
# Post starting comment for non-forked PRs
@@ -138,6 +140,29 @@ jobs:
"${{ github.head_ref }}" \
"completed"
# Deploy Storybook to production URL on main branch push
deploy-production:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build Storybook
run: pnpm build-storybook
- name: Deploy to Cloudflare Pages (production)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx wrangler@^4.0.0 pages deploy storybook-static \
--project-name=comfy-storybook \
--branch=main
# Update comment with Chromatic URLs for version-bump branches
update-comment-with-chromatic:
needs: [chromatic-deployment, deploy-and-comment]

View File

@@ -46,18 +46,30 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
ACTION: ${{ github.event.action }}
LABEL_NAME: ${{ github.event.label.name }}
PR_LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
run: |
if [ "${EVENT_NAME}" = "pull_request" ]; then
REF="${PR_HEAD_SHA}"
BRANCH="${PR_HEAD_REF}"
# Derive variant from all PR labels (default to cpu for frontend-only previews)
VARIANT="cpu"
echo "${PR_LABELS}" | grep -q '"preview-gpu"' && VARIANT="gpu"
else
REF="${GITHUB_SHA}"
BRANCH="${GITHUB_REF_NAME}"
PR_NUMBER=""
VARIANT=""
fi
payload="$(jq -nc \
--arg ref "${REF}" \
--arg branch "${BRANCH}" \
'{ref: $ref, branch: $branch}')"
--arg pr_number "${PR_NUMBER}" \
--arg variant "${VARIANT}" \
'{ref: $ref, branch: $branch, pr_number: $pr_number, variant: $variant}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
- name: Dispatch to cloud repo

View File

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

View File

@@ -58,7 +58,7 @@ export const withTheme = (Story: StoryFn, context: StoryContext) => {
document.documentElement.classList.remove('dark-theme')
document.body.classList.remove('dark-theme')
}
document.body.classList.add('[&_*]:!font-inter')
document.body.classList.add('font-inter')
return Story(context.args, context)
}

2
global.d.ts vendored
View File

@@ -34,7 +34,7 @@ interface Window {
ga_measurement_id?: string
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Record<string, unknown>
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

File diff suppressed because one or more lines are too long

View File

@@ -20,6 +20,10 @@ const config: KnipConfig = {
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
'packages/shared-frontend-utils': {
project: ['src/**/*.{js,ts}'],
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
},
'packages/registry-types': {
project: ['src/**/*.{js,ts}']
}
@@ -32,7 +36,9 @@ const config: KnipConfig = {
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'
'@primevue/icons',
// Used by lucideStrokePlugin.js (CSS @plugin)
'@iconify/utils'
],
ignore: [
// Auto generated manager types
@@ -47,7 +53,9 @@ const config: KnipConfig = {
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js'
'.agents/checks/eslint.strict.config.js',
// Loaded via @plugin directive in CSS, not detected by knip
'packages/design-system/src/css/lucideStrokePlugin.js'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

51
public/splash.css Normal file
View File

@@ -0,0 +1,51 @@
/* Pre-Vue splash loader — colors set by inline script */
#splash-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
contain: strict;
}
#splash-loader svg {
width: min(200px, 50vw);
height: auto;
transform: translateZ(0);
}
#splash-loader .wave-group {
animation: splash-rise 4s ease-in-out infinite alternate;
will-change: transform;
transform: translateZ(0);
}
#splash-loader .wave-path {
animation: splash-wave 1.2s linear infinite;
will-change: transform;
transform: translateZ(0);
}
@keyframes splash-rise {
from {
transform: translateY(280px);
}
to {
transform: translateY(-80px);
}
}
@keyframes splash-wave {
from {
transform: translateX(0);
}
to {
transform: translateX(-880px);
}
}
@media (prefers-reduced-motion: reduce) {
#splash-loader .wave-group,
#splash-loader .wave-path {
animation: none;
}
#splash-loader .wave-group {
transform: translateY(-80px);
}
}

View File

@@ -2,20 +2,13 @@
<router-view />
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
<div
v-if="isLoading"
class="pointer-events-none fixed inset-0 z-1200 flex items-center justify-center"
>
<LogoComfyWaveLoader size="xl" color="yellow" />
</div>
</template>
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted } from 'vue'
import { computed, onMounted, watch } from 'vue'
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -31,6 +24,16 @@ app.extensionManager = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
watch(
isLoading,
(loading, prevLoading) => {
if (prevLoading && !loading) {
document.getElementById('splash-loader')?.remove()
}
},
{ flush: 'post' }
)
const showContextMenu = (event: MouseEvent) => {
const { target } = event
switch (true) {

View File

@@ -1,6 +1,7 @@
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
@@ -42,6 +43,9 @@ export function useAppSetDefaultView() {
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})
closeDialog()
showAppliedDialog(openAsApp)
}
@@ -54,6 +58,7 @@ export function useAppSetDefaultView() {
appliedAsApp,
onViewApp: () => {
closeAppliedDialog()
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
setMode('app')
},
onExitToWorkflow: () => {

View File

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

View File

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

View File

@@ -0,0 +1,160 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref, toRefs } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import ScrubableNumberInput from './ScrubableNumberInput.vue'
type StoryArgs = ComponentPropsAndSlots<typeof ScrubableNumberInput>
const meta: Meta<StoryArgs> = {
title: 'Components/Input/Number',
component: ScrubableNumberInput,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
min: { control: 'number' },
max: { control: 'number' },
step: { control: 'number' },
disabled: { control: 'boolean' },
hideButtons: { control: 'boolean' }
},
args: {
min: 0,
max: 100,
step: 1,
disabled: false,
hideButtons: false
},
decorators: [
(story) => ({
components: { story },
template: '<div class="w-60"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { ScrubableNumberInput },
setup() {
const { min, max, step, disabled, hideButtons } = toRefs(args)
const value = ref(42)
return { value, min, max, step, disabled, hideButtons }
},
template:
'<ScrubableNumberInput v-model="value" :min :max :step :disabled :hideButtons />'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { ScrubableNumberInput },
setup() {
const { disabled } = toRefs(args)
const value = ref(50)
return { value, disabled }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" :disabled />'
})
}
export const AtMinimum: Story = {
render: () => ({
components: { ScrubableNumberInput },
setup() {
const value = ref(0)
return { value }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" />'
})
}
export const AtMaximum: Story = {
render: () => ({
components: { ScrubableNumberInput },
setup() {
const value = ref(100)
return { value }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" />'
})
}
export const FloatPrecision: Story = {
args: { min: 0, max: 1, step: 0.01 },
render: (args) => ({
components: { ScrubableNumberInput },
setup() {
const { min, max, step } = toRefs(args)
const value = ref(0.75)
return { value, min, max, step }
},
template:
'<ScrubableNumberInput v-model="value" :min :max :step display-value="0.75" />'
})
}
export const LargeNumber: Story = {
render: () => ({
components: { ScrubableNumberInput },
setup() {
const value = ref(1809000312992)
return { value }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="Number.MAX_SAFE_INTEGER" :step="1" />'
})
}
export const HiddenButtons: Story = {
args: { hideButtons: true },
render: (args) => ({
components: { ScrubableNumberInput },
setup() {
const { hideButtons } = toRefs(args)
const value = ref(42)
return { value, hideButtons }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" :hideButtons />'
})
}
export const WithControlButton: Story = {
render: () => ({
components: { ScrubableNumberInput, Button, Popover },
setup() {
const value = ref(1809000312992)
return { value }
},
template: `
<ScrubableNumberInput v-model="value" :min="0" :max="Number.MAX_SAFE_INTEGER" :step="1">
<Popover>
<template #button>
<Button
variant="textonly"
size="sm"
class="h-4 w-7 self-center rounded-xl bg-primary-background/30 p-0 hover:bg-primary-background-hover/30"
>
<i class="icon-[lucide--shuffle] w-full text-xs text-primary-background" />
</Button>
</template>
<div class="p-4 text-sm">Control popover content</div>
</Popover>
</ScrubableNumberInput>
`
})
}

View File

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

View File

@@ -1,9 +1,15 @@
<template>
<div class="system-stats">
<div class="mb-6">
<h2 class="mb-4 text-2xl font-semibold">
{{ $t('g.systemInfo') }}
</h2>
<div class="mb-4 flex items-center gap-2">
<h2 class="text-2xl font-semibold">
{{ $t('g.systemInfo') }}
</h2>
<Button variant="secondary" @click="copySystemInfo">
<i class="pi pi-copy" />
{{ $t('g.copySystemInfo') }}
</Button>
</div>
<div class="grid grid-cols-2 gap-2">
<template v-for="col in systemColumns" :key="col.field">
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
@@ -46,6 +52,8 @@ import TabView from 'primevue/tabview'
import { computed } from 'vue'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
@@ -55,6 +63,8 @@ const props = defineProps<{
stats: SystemStats
}>()
const { copyToClipboard } = useCopyToClipboard()
const systemInfo = computed(() => ({
...props.stats.system,
argv: props.stats.system.argv.join(' ')
@@ -108,7 +118,7 @@ function isOutdated(column: ColumnDef): boolean {
return !!installed && !!required && installed !== required
}
const getDisplayValue = (column: ColumnDef) => {
function getDisplayValue(column: ColumnDef) {
const value = systemInfo.value[column.field]
if (column.formatNumber && typeof value === 'number') {
return column.formatNumber(value)
@@ -118,4 +128,33 @@ const getDisplayValue = (column: ColumnDef) => {
}
return value
}
function formatSystemInfoText(): string {
const lines: string[] = ['## System Info']
for (const col of systemColumns.value) {
const display = getDisplayValue(col)
if (display !== undefined && display !== '') {
lines.push(`${col.header}: ${display}`)
}
}
if (hasDevices.value) {
lines.push('')
lines.push('## Devices')
for (const device of props.stats.devices) {
lines.push(`- ${device.name} (${device.type})`)
lines.push(` VRAM Total: ${formatSize(device.vram_total)}`)
lines.push(` VRAM Free: ${formatSize(device.vram_free)}`)
lines.push(` Torch VRAM Total: ${formatSize(device.torch_vram_total)}`)
lines.push(` Torch VRAM Free: ${formatSize(device.torch_vram_free)}`)
}
}
return lines.join('\n')
}
function copySystemInfo() {
copyToClipboard(formatSystemInfoText())
}
</script>

View File

@@ -8,6 +8,7 @@
<!-- Node -->
<div
v-if="item.value.type === 'node'"
v-bind="$attrs"
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
draggable="true"
@@ -48,6 +49,7 @@
<!-- Folder -->
<div
v-else
v-bind="$attrs"
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
@click.stop="handleClick($event, handleToggle, handleSelect)"
@@ -98,6 +100,10 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
const ROW_CLASS =
'group/tree-node flex cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'

View File

@@ -0,0 +1,153 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import CanvasModeSelector from '@/components/graph/CanvasModeSelector.vue'
const mockExecute = vi.fn()
const mockGetCommand = vi.fn().mockReturnValue({
keybinding: {
combo: {
getKeySequences: () => ['V']
}
}
})
const mockFormatKeySequence = vi.fn().mockReturnValue('V')
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mockExecute,
getCommand: mockGetCommand,
formatKeySequence: mockFormatKeySequence
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: { read_only: false }
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
graphCanvasMenu: {
select: 'Select',
hand: 'Hand',
canvasMode: 'Canvas Mode'
}
}
}
})
const mockPopoverHide = vi.fn()
function createWrapper() {
return mount(CanvasModeSelector, {
global: {
plugins: [i18n],
stubs: {
Popover: {
template: '<div><slot /></div>',
methods: {
toggle: vi.fn(),
hide: mockPopoverHide
}
}
}
}
})
}
describe('CanvasModeSelector', () => {
it('should render menu with menuitemradio roles and aria-checked', () => {
const wrapper = createWrapper()
const menu = wrapper.find('[role="menu"]')
expect(menu.exists()).toBe(true)
const menuItems = wrapper.findAll('[role="menuitemradio"]')
expect(menuItems).toHaveLength(2)
// Select mode is active (read_only: false), so select is checked
expect(menuItems[0].attributes('aria-checked')).toBe('true')
expect(menuItems[1].attributes('aria-checked')).toBe('false')
})
it('should render menu items as buttons with aria-labels', () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
menuItems.forEach((btn) => {
expect(btn.element.tagName).toBe('BUTTON')
expect(btn.attributes('type')).toBe('button')
})
expect(menuItems[0].attributes('aria-label')).toBe('Select')
expect(menuItems[1].attributes('aria-label')).toBe('Hand')
})
it('should use roving tabindex based on active mode', () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
// Select is active (read_only: false) → tabindex 0
expect(menuItems[0].attributes('tabindex')).toBe('0')
// Hand is inactive → tabindex -1
expect(menuItems[1].attributes('tabindex')).toBe('-1')
})
it('should mark icons as aria-hidden', () => {
const wrapper = createWrapper()
const icons = wrapper.findAll('[role="menuitemradio"] i')
icons.forEach((icon) => {
expect(icon.attributes('aria-hidden')).toBe('true')
})
})
it('should expose trigger button with aria-haspopup and aria-expanded', () => {
const wrapper = createWrapper()
const trigger = wrapper.find('[aria-haspopup="menu"]')
expect(trigger.exists()).toBe(true)
expect(trigger.attributes('aria-label')).toBe('Canvas Mode')
expect(trigger.attributes('aria-expanded')).toBe('false')
})
it('should call focus on next item when ArrowDown is pressed', async () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const secondItemEl = menuItems[1].element as HTMLElement
const focusSpy = vi.spyOn(secondItemEl, 'focus')
await menuItems[0].trigger('keydown', { key: 'ArrowDown' })
expect(focusSpy).toHaveBeenCalled()
})
it('should call focus on previous item when ArrowUp is pressed', async () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const firstItemEl = menuItems[0].element as HTMLElement
const focusSpy = vi.spyOn(firstItemEl, 'focus')
await menuItems[1].trigger('keydown', { key: 'ArrowUp' })
expect(focusSpy).toHaveBeenCalled()
})
it('should close popover on Escape and restore focus to trigger', async () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const trigger = wrapper.find('[aria-haspopup="menu"]')
const triggerEl = trigger.element as HTMLElement
const focusSpy = vi.spyOn(triggerEl, 'focus')
await menuItems[0].trigger('keydown', { key: 'Escape' })
expect(mockPopoverHide).toHaveBeenCalled()
expect(focusSpy).toHaveBeenCalled()
})
})

View File

@@ -4,15 +4,21 @@
variant="secondary"
class="group h-8 rounded-none! bg-comfy-menu-bg p-0 transition-none! hover:rounded-lg! hover:bg-interface-button-hover-surface!"
:style="buttonStyles"
:aria-label="$t('graphCanvasMenu.canvasMode')"
aria-haspopup="menu"
:aria-expanded="isOpen"
@click="toggle"
>
<div class="flex items-center gap-1 pr-0.5">
<div
class="rounded-lg bg-interface-panel-selected-surface p-2 group-hover:bg-interface-button-hover-surface"
>
<i :class="currentModeIcon" class="block size-4" />
<i :class="currentModeIcon" class="block size-4" aria-hidden="true" />
</div>
<i class="icon-[lucide--chevron-down] block size-4 pr-1.5" />
<i
class="icon-[lucide--chevron-down] block size-4 pr-1.5"
aria-hidden="true"
/>
</div>
</Button>
@@ -24,31 +30,54 @@
:close-on-escape="true"
unstyled
:pt="popoverPt"
@show="onPopoverShow"
@hide="onPopoverHide"
>
<div class="flex flex-col gap-1">
<div
class="flex cursor-pointer items-center justify-between px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
<div
ref="menuRef"
class="flex flex-col gap-1"
role="menu"
:aria-label="$t('graphCanvasMenu.canvasMode')"
>
<button
type="button"
role="menuitemradio"
:aria-checked="!isCanvasReadOnly"
:tabindex="!isCanvasReadOnly ? 0 : -1"
class="flex w-full cursor-pointer items-center justify-between rounded-sm border-none bg-transparent px-3 py-2 text-sm text-text-primary outline-none hover:bg-node-component-surface-hovered focus-visible:bg-node-component-surface-hovered"
:aria-label="$t('graphCanvasMenu.select')"
@click="setMode('select')"
@keydown.arrow-down.prevent="focusNextItem"
@keydown.arrow-up.prevent="focusPrevItem"
@keydown.escape.prevent="closeAndRestoreFocus"
>
<div class="flex items-center gap-2">
<i class="icon-[lucide--mouse-pointer-2] size-4" />
<i class="icon-[lucide--mouse-pointer-2] size-4" aria-hidden="true" />
<span>{{ $t('graphCanvasMenu.select') }}</span>
</div>
<span class="text-[9px] text-text-primary">{{
unlockCommandText
}}</span>
</div>
</button>
<div
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
<button
type="button"
role="menuitemradio"
:aria-checked="isCanvasReadOnly"
:tabindex="isCanvasReadOnly ? 0 : -1"
class="flex w-full cursor-pointer items-center justify-between rounded-sm border-none bg-transparent px-3 py-2 text-sm text-text-primary outline-none hover:bg-node-component-surface-hovered focus-visible:bg-node-component-surface-hovered"
:aria-label="$t('graphCanvasMenu.hand')"
@click="setMode('hand')"
@keydown.arrow-down.prevent="focusNextItem"
@keydown.arrow-up.prevent="focusPrevItem"
@keydown.escape.prevent="closeAndRestoreFocus"
>
<div class="flex items-center gap-2">
<i class="icon-[lucide--hand] size-4" />
<i class="icon-[lucide--hand] size-4" aria-hidden="true" />
<span>{{ $t('graphCanvasMenu.hand') }}</span>
</div>
<span class="text-[9px] text-text-primary">{{ lockCommandText }}</span>
</div>
</button>
</div>
</Popover>
</template>
@@ -56,7 +85,7 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import type { ComponentPublicInstance } from 'vue'
import { computed, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -69,6 +98,8 @@ interface Props {
defineProps<Props>()
const buttonRef = ref<ComponentPublicInstance | null>(null)
const popover = ref<InstanceType<typeof Popover>>()
const menuRef = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
@@ -106,6 +137,43 @@ const setMode = (mode: 'select' | 'hand') => {
popover.value?.hide()
}
async function onPopoverShow() {
isOpen.value = true
await nextTick()
const checkedItem = menuRef.value?.querySelector<HTMLElement>(
'[aria-checked="true"]'
)
checkedItem?.focus()
}
function onPopoverHide() {
isOpen.value = false
}
function closeAndRestoreFocus() {
popover.value?.hide()
const el = buttonRef.value?.$el || buttonRef.value
;(el as HTMLElement)?.focus()
}
function focusNextItem(event: KeyboardEvent) {
const items = getMenuItems(event)
const index = items.indexOf(event.target as HTMLElement)
items[(index + 1) % items.length]?.focus()
}
function focusPrevItem(event: KeyboardEvent) {
const items = getMenuItems(event)
const index = items.indexOf(event.target as HTMLElement)
items[(index - 1 + items.length) % items.length]?.focus()
}
function getMenuItems(event: KeyboardEvent): HTMLElement[] {
const menu = (event.target as HTMLElement).closest('[role="menu"]')
if (!menu) return []
return Array.from(menu.querySelectorAll('[role="menuitemradio"]'))
}
const popoverPt = computed(() => ({
root: {
class: 'absolute z-50 -translate-y-2'

View File

@@ -479,50 +479,53 @@ useEventListener(
onMounted(async () => {
comfyApp.vueAppReady = true
workspaceStore.spinner = true
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init()
try {
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init()
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
if (settingsError.value) {
if (settingsError.value instanceof UnauthorizedError) {
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
return
if (settingsError.value) {
if (settingsError.value instanceof UnauthorizedError) {
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
return
}
throw settingsError.value
}
throw settingsError.value
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
useNewUserService().initializeIfNewUser()
])
if (i18nError.value) {
console.warn(
'[GraphCanvas] Failed to load custom nodes i18n:',
i18nError.value
)
}
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
window.app = comfyApp
window.graph = comfyApp.graph
comfyAppReady.value = true
vueNodeLifecycle.setupEmptyGraphListener()
} finally {
workspaceStore.spinner = false
}
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
useNewUserService().initializeIfNewUser()
])
if (i18nError.value) {
console.warn(
'[GraphCanvas] Failed to load custom nodes i18n:',
i18nError.value
)
}
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
window.app = comfyApp
window.graph = comfyApp.graph
comfyAppReady.value = true
vueNodeLifecycle.setupEmptyGraphListener()
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,
() => canvasStore.updateSelectedItems()

View File

@@ -10,6 +10,8 @@
></div>
<ButtonGroup
role="toolbar"
:aria-label="t('graphCanvasMenu.canvasToolbar')"
class="absolute right-0 bottom-0 z-1200 flex-row gap-1 border border-interface-stroke bg-comfy-menu-bg p-2"
:style="{
...stringifiedMinimapStyles.buttonGroupStyles
@@ -30,7 +32,7 @@
class="size-8 bg-comfy-menu-bg p-0 hover:bg-interface-button-hover-surface!"
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
>
<i class="icon-[lucide--focus] size-4" />
<i class="icon-[lucide--focus] size-4" aria-hidden="true" />
</Button>
<Button
@@ -44,7 +46,7 @@
>
<span class="inline-flex items-center gap-1 px-2 text-xs">
<span>{{ canvasStore.appScalePercentage }}%</span>
<i class="icon-[lucide--chevron-down] size-4" />
<i class="icon-[lucide--chevron-down] size-4" aria-hidden="true" />
</span>
</Button>
@@ -59,7 +61,7 @@
:class="minimapButtonClass"
@click="onMinimapToggleClick"
>
<i class="icon-[lucide--map] size-4" />
<i class="icon-[lucide--map] size-4" aria-hidden="true" />
</Button>
<Button
@@ -78,7 +80,7 @@
:style="stringifiedMinimapStyles.buttonStyles"
@click="onLinkVisibilityToggleClick"
>
<i class="icon-[lucide--route-off] size-4" />
<i class="icon-[lucide--route-off] size-4" aria-hidden="true" />
</Button>
</ButtonGroup>
</div>

View File

@@ -1,96 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import LogoCFillLoader from './LogoCFillLoader.vue'
const meta: Meta<typeof LogoCFillLoader> = {
title: 'Components/Loader/LogoCFillLoader',
component: LogoCFillLoader,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: { default: 'dark' }
},
argTypes: {
size: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl']
},
color: {
control: 'select',
options: ['yellow', 'blue', 'white', 'black']
},
bordered: {
control: 'boolean'
},
disableAnimation: {
control: 'boolean'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Small: Story = {
args: { size: 'sm' }
}
export const Large: Story = {
args: { size: 'lg' }
}
export const ExtraLarge: Story = {
args: { size: 'xl' }
}
export const NoBorder: Story = {
args: { bordered: false }
}
export const Static: Story = {
args: { disableAnimation: true }
}
export const BrandColors: Story = {
render: () => ({
components: { LogoCFillLoader },
template: `
<div class="flex items-end gap-12">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">Yellow</span>
<LogoCFillLoader size="lg" color="yellow" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">Blue</span>
<LogoCFillLoader size="lg" color="blue" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">White</span>
<LogoCFillLoader size="lg" color="white" />
</div>
<div class="p-4 bg-white rounded" style="background: white">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-600">Black</span>
<LogoCFillLoader size="lg" color="black" />
</div>
</div>
</div>
`
})
}
export const AllSizes: Story = {
render: () => ({
components: { LogoCFillLoader },
template: `
<div class="flex items-end gap-8">
<LogoCFillLoader size="sm" color="yellow" />
<LogoCFillLoader size="md" color="yellow" />
<LogoCFillLoader size="lg" color="yellow" />
<LogoCFillLoader size="xl" color="yellow" />
</div>
`
})
}

View File

@@ -1,100 +0,0 @@
<template>
<span role="status" :class="cn('inline-flex', colorClass)">
<svg
:width="Math.round(heightMap[size] * (VB_W / VB_H))"
:height="heightMap[size]"
:viewBox="`0 0 ${VB_W} ${VB_H}`"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<mask :id="maskId">
<path :d="C_PATH" fill="white" />
</mask>
</defs>
<path
v-if="bordered"
:d="C_PATH"
stroke="currentColor"
stroke-width="2"
fill="none"
opacity="0.4"
/>
<g :mask="`url(#${maskId})`">
<rect
:class="disableAnimation ? undefined : 'c-fill-rect'"
:x="-BLEED"
:y="-BLEED"
:width="VB_W + BLEED * 2"
:height="VB_H + BLEED * 2"
fill="currentColor"
/>
</g>
</svg>
<span class="sr-only">{{ t('g.loading') }}</span>
</span>
</template>
<script setup lang="ts">
import { useId, computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
const {
size = 'md',
color = 'black',
bordered = true,
disableAnimation = false
} = defineProps<{
size?: 'sm' | 'md' | 'lg' | 'xl'
color?: 'yellow' | 'blue' | 'white' | 'black'
bordered?: boolean
disableAnimation?: boolean
}>()
const { t } = useI18n()
const maskId = `c-mask-${useId()}`
const VB_W = 185
const VB_H = 201
const BLEED = 1
// Larger than LogoComfyWaveLoader because the C logo is near-square (185×201)
// while the COMFY wordmark is wide (879×284), so larger heights are needed
// for visually comparable perceived size.
const heightMap = { sm: 48, md: 80, lg: 120, xl: 200 } as const
const colorMap = {
yellow: 'text-brand-yellow',
blue: 'text-brand-blue',
white: 'text-white',
black: 'text-black'
} as const
const colorClass = computed(() => colorMap[color])
const C_PATH =
'M42.1217 200.812C37.367 200.812 33.5304 199.045 31.0285 195.703C28.4569 192.27 27.7864 187.477 29.1882 182.557L34.8172 162.791C35.2661 161.217 34.9537 159.523 33.9747 158.214C32.9958 156.908 31.464 156.139 29.8371 156.139L13.6525 156.139C8.89521 156.139 5.05862 154.374 2.55797 151.032C-0.0136533 147.597-0.684085 142.804 0.71869 137.883L20.0565 70.289L22.1916 62.8625C25.0617 52.7847 35.5288 44.5943 45.528 44.5943L64.8938 44.5943C67.2048 44.5943 69.2376 43.0535 69.8738 40.8175L76.2782 18.3344C79.1454 8.26681 89.6127 0.0763962 99.6117 0.0763945L141.029 0.00258328L171.349-2.99253e-05C176.104-3.0756e-05 179.941 1.765 182.442 5.10626C185.013 8.53932 185.684 13.3324 184.282 18.2528L175.612 48.6947C172.746 58.7597 162.279 66.9475 152.28 66.9475L110.771 67.0265L91.4113 67.0265C89.1029 67.0265 87.0727 68.5647 86.4326 70.7983L70.2909 127.179C69.8394 128.756 70.1518 130.454 71.1334 131.763C72.1123 133.07 73.6441 133.839 75.2697 133.839C75.2736 133.839 102.699 133.785 102.699 133.785L132.929 133.785C137.685 133.785 141.522 135.55 144.023 138.892C146.594 142.327 147.265 147.12 145.862 152.041L137.192 182.478C134.326 192.545 123.859 200.733 113.86 200.733L72.3517 200.812L42.1217 200.812Z'
</script>
<style scoped>
.c-fill-rect {
animation: c-fill-up 2.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;
will-change: transform;
}
@keyframes c-fill-up {
0% {
transform: translateY(calc(v-bind(VB_H) * 1px + v-bind(BLEED) * 1px));
}
100% {
transform: translateY(calc(v-bind(BLEED) * -1px));
}
}
@media (prefers-reduced-motion: reduce) {
.c-fill-rect {
animation: none;
}
}
</style>

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const buttonVariants = cva({
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer touch-manipulation whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
variants: {
variant: {
secondary:

View File

@@ -0,0 +1,213 @@
<template>
<ComboboxRoot
v-model="modelValue"
v-model:open="isOpen"
ignore-filter
:disabled
:class="className"
>
<ComboboxAnchor
:class="
cn(
searchInputVariants({ size }),
disabled && 'pointer-events-none opacity-50'
)
"
@click="focus"
>
<Button
v-if="modelValue"
:class="cn('absolute', sizeConfig.clearPos)"
variant="textonly"
size="icon-sm"
:aria-label="$t('g.clear')"
@click.stop="clearSearch"
>
<i :class="cn('icon-[lucide--x]', sizeConfig.icon)" />
</Button>
<i
v-else-if="loading"
:class="
cn(
'pointer-events-none absolute icon-[lucide--loader-circle] animate-spin',
sizeConfig.iconPos,
sizeConfig.icon
)
"
/>
<i
v-else
:class="
cn(
'pointer-events-none absolute',
sizeConfig.iconPos,
sizeConfig.icon,
icon
)
"
/>
<ComboboxInput
ref="inputRef"
v-model="modelValue"
:class="
cn(
'size-full border-none bg-transparent outline-none',
sizeConfig.inputPl,
sizeConfig.inputText
)
"
:placeholder="placeholderText"
:auto-focus="autofocus"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
@keydown.enter="onEnterKey"
/>
</ComboboxAnchor>
<ComboboxContent
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
:class="
cn(
'z-50 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
)
"
>
<ComboboxItem
v-for="(suggestion, index) in suggestions"
:key="suggestionKey(suggestion, index)"
:value="suggestionValue(suggestion)"
:class="
cn(
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
'data-highlighted:bg-secondary-background-hover'
)
"
@select.prevent="onSelectSuggestion(suggestion)"
>
<slot name="suggestion" :suggestion>
{{ suggestionLabel(suggestion) }}
</slot>
</ComboboxItem>
</ComboboxContent>
</ComboboxRoot>
</template>
<script setup lang="ts" generic="T">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { SearchInputVariants } from './searchInput.variants'
import {
searchInputSizeConfig,
searchInputVariants
} from './searchInput.variants'
const { t } = useI18n()
const {
placeholder,
icon = 'icon-[lucide--search]',
autofocus = false,
loading = false,
disabled = false,
size = 'md',
suggestions = [],
optionLabel,
optionKey,
class: className
} = defineProps<{
placeholder?: string
icon?: string
autofocus?: boolean
loading?: boolean
disabled?: boolean
size?: SearchInputVariants['size']
suggestions?: T[]
optionLabel?: keyof T & string
optionKey?: keyof T & string
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
select: [item: T]
}>()
const sizeConfig = computed(() => searchInputSizeConfig[size])
const modelValue = defineModel<string>({ required: true })
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
const isOpen = ref(false)
const isComposing = ref(false)
function focus() {
inputRef.value?.$el?.focus()
}
defineExpose({ focus })
const placeholderText = computed(
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
)
function clearSearch() {
modelValue.value = ''
focus()
}
function getItemProperty(item: T, key: keyof T & string): string {
if (typeof item === 'object' && item !== null) {
return String(item[key])
}
return String(item)
}
function suggestionLabel(item: T): string {
if (optionLabel) return getItemProperty(item, optionLabel)
return String(item)
}
function suggestionKey(item: T, index: number): string {
if (optionKey) return getItemProperty(item, optionKey)
return `${suggestionLabel(item)}-${index}`
}
function suggestionValue(item: T): string {
return suggestionLabel(item)
}
function onSelectSuggestion(item: T) {
modelValue.value = suggestionLabel(item)
isOpen.value = false
emit('select', item)
}
function onEnterKey(e: KeyboardEvent) {
if (isComposing.value) {
e.preventDefault()
e.stopPropagation()
}
}
watch(
() => suggestions,
(items) => {
isOpen.value = items.length > 0 && !!modelValue.value
}
)
</script>

View File

@@ -1,10 +1,5 @@
<template>
<ComboboxRoot
:ignore-filter="true"
:open="false"
:disabled="disabled"
:class="className"
>
<ComboboxRoot :open="false" ignore-filter :disabled :class="className">
<ComboboxAnchor
:class="
cn(

View File

@@ -25,7 +25,7 @@
:label="String(badge)"
severity="contrast"
variant="circle"
class="ml-auto"
class="ml-auto min-h-5 min-w-5 px-1 text-base-background"
/>
</div>
</template>

View File

@@ -324,7 +324,8 @@ function safeWidgetMapper(
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
slotMetadata: slotInfo,
slotName: name !== widget.name ? widget.name : undefined
slotName: name !== widget.name ? widget.name : undefined,
tooltip: widget.tooltip
}
} catch (error) {
return {

View File

@@ -27,7 +27,7 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
`${namePrefix}.${depth}.${inputIndex}`,
Array.isArray(input)
? ['COMFY_DYNAMICCOMBO_V3', { options: getSpec(input, depth + 1) }]
: [input, {}]
: [input, { tooltip: `${groupIndex}` }]
])
return {
key: `${groupIndex}`,
@@ -106,6 +106,13 @@ describe('Dynamic Combos', () => {
expect(node.inputs[1].name).toBe('0.0.0.0')
expect(node.inputs[3].name).toBe('2.2.0.0')
})
test('Dynamically added widgets have tooltips', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['STRING']])
expect.soft(node.widgets[1].tooltip).toBe('0')
node.widgets[0].value = '1'
expect.soft(node.widgets[1].tooltip).toBe('1')
})
})
describe('Autogrow', () => {
const inputsSpec = { required: { image: ['IMAGE', {}] } }

View File

@@ -93,6 +93,7 @@
"reportIssueTooltip": "Submit the error report to Comfy Org",
"reportSent": "Report Submitted",
"copyToClipboard": "Copy to Clipboard",
"copySystemInfo": "Copy System Info",
"copyAll": "Copy All",
"openNewIssue": "Open New Issue",
"showReport": "Show Report",
@@ -339,7 +340,8 @@
"conflicting": "Conflicting",
"inWorkflowSection": "IN WORKFLOW",
"allInWorkflow": "All in: {workflowName}",
"missingNodes": "Missing Nodes"
"missingNodes": "Missing Nodes",
"unresolvedNodes": "Unresolved Nodes"
},
"infoPanelEmpty": "Click an item to see the info",
"applyChanges": "Apply Changes",
@@ -404,6 +406,10 @@
"noDescription": "No description available",
"installSelected": "Install Selected",
"installAllMissingNodes": "Install All",
"unresolvedNodes": {
"title": "Unresolved Missing Nodes",
"message": "The following nodes are not installed and could not be found in the registry."
},
"allMissingNodesInstalled": "All missing nodes have been successfully installed",
"packsSelected": "packs selected",
"mixedSelectionMessage": "Cannot perform bulk action on mixed selection",
@@ -1046,6 +1052,8 @@
"logoProviderSeparator": " & "
},
"graphCanvasMenu": {
"canvasMode": "Canvas Mode",
"canvasToolbar": "Canvas Toolbar",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"resetView": "Reset View",
@@ -3181,6 +3189,7 @@
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"viewGraph": "View node graph",
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",

View File

@@ -8,9 +8,14 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import CloudTemplate from './CloudTemplate.vue'
onMounted(() => {
document.getElementById('splash-loader')?.remove()
})
</script>

View File

@@ -150,6 +150,41 @@ describe('fetchJobs', () => {
expect(result).toEqual([])
})
it('parses batch containing text-only preview outputs', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve(
createMockResponse([
createMockJob('image-job', 'completed', {
preview_output: {
filename: 'output.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createMockJob('text-job', 'completed', {
preview_output: {
content: 'some generated text',
nodeId: '5',
mediaType: 'text'
}
}),
createMockJob('no-preview-job', 'completed')
])
)
})
const result = await fetchHistory(mockFetch)
expect(result).toHaveLength(3)
expect(result[0].id).toBe('image-job')
expect(result[1].id).toBe('text-job')
expect(result[2].id).toBe('no-preview-job')
})
})
describe('fetchQueue', () => {

View File

@@ -18,14 +18,16 @@ const zJobStatus = z.enum([
'cancelled'
])
const zPreviewOutput = z.object({
filename: z.string(),
subfolder: z.string(),
type: resultItemType,
nodeId: z.string(),
mediaType: z.string(),
display_name: z.string().optional()
})
const zPreviewOutput = z
.object({
filename: z.string().optional(),
subfolder: z.string().optional(),
type: resultItemType.optional(),
nodeId: z.string(),
mediaType: z.string(),
display_name: z.string().optional()
})
.passthrough()
/**
* Execution error from Jobs API.

View File

@@ -1,3 +1,5 @@
import type { PostHogConfig } from 'posthog-js'
import type { TelemetryEventName } from '@/platform/telemetry/types'
/**
@@ -30,7 +32,7 @@ export type RemoteConfig = {
ga_measurement_id?: string
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Partial<PostHogConfig>
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
max_upload_size?: number

View File

@@ -3,7 +3,9 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
@@ -26,7 +28,8 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
WorkflowImportMetadata,
WorkflowSavedMetadata
} from './types'
/**
@@ -156,10 +159,22 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackWorkflowOpened?.(metadata))
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.dispatch((provider) => provider.trackWorkflowSaved?.(metadata))
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.dispatch((provider) => provider.trackDefaultViewSet?.(metadata))
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.dispatch((provider) => provider.trackShareFlow?.(metadata))
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.dispatch((provider) => provider.trackPageVisibilityChanged?.(metadata))
}

View File

@@ -14,7 +14,9 @@ import { getExecutionContext } from '../../utils/getExecutionContext'
import type {
AuthMetadata,
CreditTopupMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionTriggerSource,
ExecutionErrorMetadata,
@@ -39,7 +41,8 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
@@ -358,10 +361,22 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}

View File

@@ -40,8 +40,12 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))
const mockRemoteConfig = vi.hoisted(
() => ({ value: null }) as { value: Record<string, unknown> | null }
)
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: { value: null }
remoteConfig: mockRemoteConfig
}))
vi.mock('posthog-js', () => hoisted.mockPosthog)
@@ -61,6 +65,7 @@ function createProvider(
describe('PostHogTelemetryProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRemoteConfig.value = null
window.__CONFIG__ = {
posthog_project_token: 'phc_test_token'
} as typeof window.__CONFIG__
@@ -80,26 +85,35 @@ describe('PostHogTelemetryProvider', () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith('phc_test_token', {
api_host: 'https://ph.comfy.org',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
})
expect(hoisted.mockInit).toHaveBeenCalledWith(
'phc_test_token',
expect.objectContaining({
api_host: 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
})
)
})
it('uses custom api_host from config when provided', async () => {
window.__CONFIG__ = {
posthog_project_token: 'phc_test_token',
posthog_api_host: 'https://custom.host.com'
} as typeof window.__CONFIG__
new PostHogTelemetryProvider()
it('applies posthog_config overrides from remote config', async () => {
mockRemoteConfig.value = {
posthog_config: {
debug: true,
api_host: 'https://custom.host.com'
}
}
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith(
'phc_test_token',
expect.objectContaining({ api_host: 'https://custom.host.com' })
expect.objectContaining({
debug: true,
api_host: 'https://custom.host.com'
})
)
})

View File

@@ -7,7 +7,9 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AuthMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
@@ -33,7 +35,8 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { getExecutionContext } from '../../utils/getExecutionContext'
@@ -98,13 +101,16 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
void import('posthog-js')
.then((posthogModule) => {
this.posthog = posthogModule.default
const serverConfig = remoteConfig.value?.posthog_config ?? {}
this.posthog!.init(apiKey, {
api_host:
window.__CONFIG__?.posthog_api_host || 'https://ph.comfy.org',
api_host: 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
...serverConfig
})
this.isInitialized = true
this.flushEventQueue()
@@ -342,10 +348,22 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}

View File

@@ -137,13 +137,38 @@ export interface WorkflowImportMetadata {
/**
* The source of the workflow open/import action
*/
open_source?: 'file_button' | 'file_drop' | 'template' | 'unknown'
open_source?:
| 'file_button'
| 'file_drop'
| 'template'
| 'shared_url'
| 'unknown'
}
export interface EnterLinearMetadata {
source?: string
}
export interface WorkflowSavedMetadata {
is_app: boolean
is_new: boolean
}
export interface DefaultViewSetMetadata {
default_view: 'app' | 'graph'
}
type ShareFlowStep =
| 'dialog_opened'
| 'save_prompted'
| 'link_created'
| 'link_copied'
export interface ShareFlowMetadata {
step: ShareFlowStep
source?: 'app_mode' | 'graph_mode'
}
/**
* Workflow open metadata
*/
@@ -361,7 +386,10 @@ export interface TelemetryProvider {
// Workflow management events
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
trackWorkflowSaved?(metadata: WorkflowSavedMetadata): void
trackDefaultViewSet?(metadata: DefaultViewSetMetadata): void
trackEnterLinear?(metadata: EnterLinearMetadata): void
trackShareFlow?(metadata: ShareFlowMetadata): void
// Page visibility events
trackPageVisibilityChanged?(metadata: PageVisibilityMetadata): void
@@ -447,7 +475,8 @@ export const TelemetryEvents = {
// Workflow Management
WORKFLOW_IMPORTED: 'app:workflow_imported',
WORKFLOW_OPENED: 'app:workflow_opened',
ENTER_LINEAR_MODE: 'app:toggle_linear_mode',
ENTER_LINEAR_MODE: 'app:app_mode_opened',
SHARE_FLOW: 'app:share_flow',
// Page Visibility
PAGE_VISIBILITY_CHANGED: 'app:page_visibility_changed',
@@ -472,6 +501,8 @@ export const TelemetryEvents = {
// Workflow Creation
WORKFLOW_CREATED: 'app:workflow_created',
WORKFLOW_SAVED: 'app:workflow_saved',
DEFAULT_VIEW_SET: 'app:default_view_set',
// Execution Lifecycle
EXECUTION_START: 'execution_start',
@@ -521,4 +552,7 @@ export type TelemetryEventProperties =
| HelpCenterClosedMetadata
| WorkflowCreatedMetadata
| EnterLinearMetadata
| ShareFlowMetadata
| WorkflowSavedMetadata
| DefaultViewSetMetadata
| SubscriptionMetadata

View File

@@ -149,6 +149,8 @@ export const useWorkflowService = () => {
await openWorkflow(tempWorkflow)
await workflowStore.saveWorkflow(tempWorkflow)
}
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })
return true
}
@@ -189,6 +191,7 @@ export const useWorkflowService = () => {
}
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
}
}

View File

@@ -26,17 +26,24 @@ import { refAutoReset } from '@vueuse/core'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useTelemetry } from '@/platform/telemetry'
const { url } = defineProps<{
url: string
}>()
const { copyToClipboard } = useCopyToClipboard()
const { isAppMode } = useAppMode()
const copied = refAutoReset(false, 2000)
async function handleCopy() {
await copyToClipboard(url)
copied.value = true
useTelemetry()?.trackShareFlow({
step: 'link_copied',
source: isAppMode.value ? 'app_mode' : 'graph_mode'
})
}
</script>

View File

@@ -167,7 +167,9 @@ import type {
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useAppMode } from '@/composables/useAppMode'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTelemetry } from '@/platform/telemetry'
import { appendJsonExt } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -182,6 +184,11 @@ const publishDialog = useComfyHubPublishDialog()
const shareService = useWorkflowShareService()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const { isAppMode } = useAppMode()
function getShareSource() {
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
}
type DialogState = 'loading' | 'unsaved' | 'ready' | 'shared' | 'stale'
type DialogMode = 'shareLink' | 'publishToHub'
@@ -298,6 +305,10 @@ async function refreshDialogState() {
if (!workflow || workflow.isTemporary || workflow.isModified) {
dialogState.value = 'unsaved'
useTelemetry()?.trackShareFlow({
step: 'save_prompted',
source: getShareSource()
})
if (workflow) {
workflowName.value = stripJsonExtension(workflow.filename)
}
@@ -379,6 +390,10 @@ const {
)
dialogState.value = 'shared'
acknowledged.value = false
useTelemetry()?.trackShareFlow({
step: 'link_created',
source: getShareSource()
})
return result
},

View File

@@ -1,4 +1,6 @@
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useWorkflowStore } from '../../management/stores/workflowStore'
@@ -13,6 +15,7 @@ export function useShareDialog() {
const dialogStore = useDialogStore()
const { pruneLinearData } = useAppModeStore()
const workflowStore = useWorkflowStore()
const { isAppMode } = useAppMode()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
@@ -51,7 +54,15 @@ export function useShareDialog() {
share()
}
function getShareSource() {
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
}
function showShareDialog() {
useTelemetry()?.trackShareFlow({
step: 'dialog_opened',
source: getShareSource()
})
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: ShareWorkflowDialogContent,

View File

@@ -164,7 +164,8 @@ describe('useSharedWorkflowUrlLoader', () => {
{ nodes: [] },
true,
true,
'Test Workflow'
'Test Workflow',
{ openSource: 'shared_url' }
)
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
@@ -360,7 +361,8 @@ describe('useSharedWorkflowUrlLoader', () => {
expect.anything(),
true,
true,
'Open shared workflow'
'Open shared workflow',
{ openSource: 'shared_url' }
)
})
})

View File

@@ -138,7 +138,9 @@ export function useSharedWorkflowUrlLoader() {
const nonOwnedAssets = payload.assets.filter((a) => !a.in_library)
try {
await app.loadGraphData(payload.workflowJson, true, true, workflowName)
await app.loadGraphData(payload.workflowJson, true, true, workflowName, {
openSource: 'shared_url'
})
} catch (error) {
console.error(
'[useSharedWorkflowUrlLoader] Failed to load workflow graph:',

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useTemplateWorkflows } from './useTemplateWorkflows'
@@ -121,6 +122,7 @@ export function useTemplateUrlLoader() {
})
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
useTelemetry()?.trackEnterLinear({ source: 'template_url' })
canvasStore.linearMode = true
}
} catch (error) {

View File

@@ -81,18 +81,16 @@ describe('WorkspaceAuthGate', () => {
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
})
})
describe('cloud builds - unauthenticated user', () => {
it('shows spinner while waiting for Firebase auth', () => {
it('hides slot while waiting for Firebase auth', () => {
mockIsInitialized.value = false
const wrapper = mountComponent()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
})
@@ -100,7 +98,7 @@ describe('WorkspaceAuthGate', () => {
mockIsInitialized.value = false
const wrapper = mountComponent()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
mockIsInitialized.value = true
mockCurrentUser.value = null
@@ -179,8 +177,8 @@ describe('WorkspaceAuthGate', () => {
const wrapper = mountComponent()
await flushPromises()
// Still showing spinner before timeout
expect(wrapper.find('[role="status"]').exists()).toBe(true)
// Slot not yet rendered before timeout
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
// Advance past the 10 second timeout
await vi.advanceTimersByTimeAsync(10_001)

View File

@@ -1,11 +1,5 @@
<template>
<slot v-if="isReady" />
<div
v-else
class="fixed inset-0 z-1100 flex items-center justify-center bg-(--p-mask-background)"
>
<LogoComfyWaveLoader size="xl" color="yellow" disable-animation />
</div>
</template>
<script setup lang="ts">
@@ -20,6 +14,9 @@
*
* This prevents race conditions where API calls use Firebase tokens
* instead of workspace tokens when the workspace feature is enabled.
*
* The splash loader in index.html (z-9999) covers the screen during this
* phase, so no separate loading indicator is needed here.
*/
import { promiseTimeout, until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
@@ -30,7 +27,6 @@ import { isCloud } from '@/platform/distribution/types'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
const FIREBASE_INIT_TIMEOUT_MS = 16_000
const CONFIG_REFRESH_TIMEOUT_MS = 10_000

View File

@@ -120,7 +120,7 @@ function getDropIndicator(node: LGraphNode) {
return {
iconClass: 'icon-[lucide--image]',
imageUrl: buildImageUrl(),
label: t('linearMode.dragAndDropImage'),
label: props.mobile ? undefined : t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined)
}
}
@@ -206,14 +206,14 @@ defineExpose({ runButtonClick })
<DropZone
:on-drag-over="nodeData.onDragOver"
:on-drag-drop="nodeData.onDragDrop"
:drop-indicator="mobile ? undefined : nodeData.dropIndicator"
:drop-indicator="nodeData.dropIndicator"
class="text-muted-foreground"
>
<NodeWidgets
:node-data
:class="
cn(
'gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 **:[.h-7]:h-10',
'gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
nodeData.hasErrors &&
'ring-2 ring-node-stroke-error ring-inset'
)
@@ -325,4 +325,9 @@ defineExpose({ runButtonClick })
</section>
</div>
</div>
<div
v-else-if="mobile"
class="flex size-full items-center bg-base-background p-4 text-center"
v-text="t('linearMode.mobileNoWorkflow')"
/>
</template>

View File

@@ -8,7 +8,6 @@ import DropdownMenu from '@/components/common/DropdownMenu.vue'
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -76,13 +75,16 @@ function onClick(index: number) {
}
const workflowsEntries = computed(() => {
return workflowStore.openWorkflows.map((w) => ({
label: w.filename,
icon: w.activeState?.extra?.linearMode
? 'icon-[lucide--panels-top-left] bg-primary-background'
: undefined,
command: () => workflowService.openWorkflow(w)
}))
return [
...workflowStore.openWorkflows.map((w) => ({
label: w.filename,
icon: w.activeState?.extra?.linearMode
? 'icon-[lucide--panels-top-left] bg-primary-background'
: undefined,
command: () => workflowService.openWorkflow(w),
checked: workflowStore.activeWorkflow === w
}))
]
})
const menuEntries = computed<MenuItem[]>(() => [
@@ -157,9 +159,9 @@ const menuEntries = computed<MenuItem[]>(() => [
class="flex h-16 w-full items-center gap-3 border-b border-border-subtle bg-base-background px-4 py-3"
>
<DropdownMenu :entries="menuEntries" />
<Popover
<DropdownMenu
:entries="workflowsEntries"
class="w-(--reka-popover-content-available-width)"
class="max-h-[40vh] w-(--reka-dropdown-menu-content-available-width)"
:collision-padding="20"
>
<template #button>
@@ -179,7 +181,7 @@ const menuEntries = computed<MenuItem[]>(() => [
/>
</div>
</template>
</Popover>
</DropdownMenu>
<CurrentUserButton v-if="isLoggedIn" :show-arrow="false" />
</header>
<div class="size-full rounded-b-4xl contain-content">

View File

@@ -0,0 +1,104 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { ImageCompareValue } from './WidgetImageCompare.vue'
import WidgetImageCompare from './WidgetImageCompare.vue'
function createSampleImage(label: string, fill: string): string {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">` +
`<rect width="512" height="512" fill="${fill}" />` +
`<text x="50%" y="50%" fill="white" font-size="40"` +
` text-anchor="middle" dominant-baseline="middle">` +
`${label}</text></svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
const SAMPLE_BEFORE = createSampleImage('Before', '#475569')
const SAMPLE_AFTER = createSampleImage('After', '#0f766e')
const meta: Meta<typeof WidgetImageCompare> = {
title: 'Components/Display/ImageCompare',
component: WidgetImageCompare,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(story) => ({
components: { story },
template: '<div class="w-88 h-80"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { WidgetImageCompare },
setup() {
const widget = ref<SimplifiedWidget<ImageCompareValue>>({
name: 'compare',
type: 'IMAGE_COMPARE',
value: {
beforeImages: [SAMPLE_BEFORE],
afterImages: [SAMPLE_AFTER]
}
})
return { widget }
},
template: '<WidgetImageCompare :widget="widget" />'
})
}
export const WithBatchNavigation: Story = {
render: () => ({
components: { WidgetImageCompare },
setup() {
const widget = ref<SimplifiedWidget<ImageCompareValue>>({
name: 'compare',
type: 'IMAGE_COMPARE',
value: {
beforeImages: [SAMPLE_BEFORE, SAMPLE_AFTER],
afterImages: [SAMPLE_AFTER, SAMPLE_BEFORE],
beforeAlt: 'Before batch',
afterAlt: 'After batch'
}
})
return { widget }
},
template: '<WidgetImageCompare :widget="widget" />'
})
}
export const SingleImageFallback: Story = {
render: () => ({
components: { WidgetImageCompare },
setup() {
const widget = ref<SimplifiedWidget<string>>({
name: 'compare',
type: 'IMAGE_COMPARE',
value: SAMPLE_BEFORE
})
return { widget }
},
template: '<WidgetImageCompare :widget="widget" />'
})
}
export const NoImages: Story = {
render: () => ({
components: { WidgetImageCompare },
setup() {
const widget = ref<SimplifiedWidget<ImageCompareValue>>({
name: 'compare',
type: 'IMAGE_COMPARE',
value: {}
})
return { widget }
},
template: '<WidgetImageCompare :widget="widget" />'
})
}

View File

@@ -58,7 +58,7 @@ describe('WidgetImageCompare Display', () => {
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
images.forEach((img) => {
expect(img.classes()).toContain('object-contain')
expect(img.classes()).toContain('object-cover')
})
})
})
@@ -290,7 +290,6 @@ describe('WidgetImageCompare Display', () => {
const slider = wrapper.find('[role="presentation"]')
expect(slider.exists()).toBe(true)
expect(slider.classes()).toContain('bg-white')
})
it('does not render slider when no images', () => {

View File

@@ -26,14 +26,14 @@
<div
v-if="beforeImage || afterImage"
ref="containerRef"
class="relative min-h-0 flex-1"
class="relative min-h-0 flex-1 overflow-hidden rounded-lg bg-node-component-surface py-4"
>
<img
v-if="afterImage"
:src="afterImage"
:alt="afterAlt"
draggable="false"
class="size-full object-contain"
class="absolute inset-0 size-full object-cover"
/>
<img
@@ -41,12 +41,18 @@
:src="beforeImage"
:alt="beforeAlt"
draggable="false"
class="absolute inset-0 size-full object-contain"
:style="{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }"
class="absolute inset-0 size-full object-cover"
:style="
hasCompareImages
? { clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }
: undefined
"
/>
<!-- Circular drag handle -->
<div
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 bg-white shadow-md"
v-if="hasCompareImages"
class="pointer-events-none absolute top-1/2 z-10 size-6 -translate-1/2 rounded-full border-2 border-white bg-white/30 shadow-lg backdrop-blur-sm"
:style="{ left: `${sliderPosition}%` }"
role="presentation"
/>
@@ -142,6 +148,10 @@ const afterImage = computed(() => {
return value?.afterImages?.[afterIndex.value] ?? ''
})
const hasCompareImages = computed(() =>
Boolean(beforeImage.value && afterImage.value)
)
const beforeAlt = computed(() => {
const value = props.widget.value
return !isSingleImage(value) && value?.beforeAlt

View File

@@ -121,12 +121,6 @@ const buttonsDisabled = computed(() => {
)
})
function updateValueBy(delta: number) {
const max = filteredProps.value.max ?? Number.MAX_VALUE
const min = filteredProps.value.min ?? -Number.MAX_VALUE
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
}
const buttonTooltip = computed(() => {
if (buttonsDisabled.value) {
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
@@ -171,10 +165,6 @@ const inputAriaAttrs = computed(() => ({
:parse-value="parseWidgetValue"
:input-attrs="inputAriaAttrs"
:class="cn(WidgetInputBaseClass, 'relative flex h-7 grow text-xs')"
@keydown.up.prevent="updateValueBy(stepValue)"
@keydown.down.prevent="updateValueBy(-stepValue)"
@keydown.page-up.prevent="updateValueBy(10 * stepValue)"
@keydown.page-down.prevent="updateValueBy(-10 * stepValue)"
>
<template #background>
<div

View File

@@ -17,7 +17,7 @@ interface StoryArgs extends ComponentPropsAndSlots<typeof WidgetInputText> {
}
const meta: Meta<StoryArgs> = {
title: 'Widgets/WidgetInputText',
title: 'Components/Input/InputText',
component: WidgetInputText,
tags: ['autodocs'],
parameters: { layout: 'centered' },

View File

@@ -16,7 +16,7 @@ interface StoryArgs extends ComponentPropsAndSlots<typeof WidgetTextarea> {
}
const meta: Meta<StoryArgs> = {
title: 'Widgets/WidgetTextarea',
title: 'Components/Input/TextArea',
component: WidgetTextarea,
tags: ['autodocs'],
parameters: {

View File

@@ -101,7 +101,7 @@ export const useColorPaletteService = () => {
linkColorPalette: Colors['node_slot']
) {
if (!linkColorPalette) return
const rootStyle = document.body?.style
const rootStyle = document.documentElement?.style
if (!rootStyle) return
for (const dataType of nodeDefStore.nodeDataTypes) {
@@ -121,7 +121,7 @@ export const useColorPaletteService = () => {
colorPaletteId: string
) {
if (!palette) return
const rootStyle = document.body?.style
const rootStyle = document.documentElement?.style
if (!rootStyle) return
for (const themeVar of Object.keys(THEME_PROPERTY_MAP)) {
@@ -206,7 +206,10 @@ export const useColorPaletteService = () => {
*
* @param comfyColorPalette - The palette to set.
*/
const loadComfyColorPalette = (comfyColorPalette: Colors['comfy_base']) => {
const loadComfyColorPalette = (
comfyColorPalette: Colors['comfy_base'],
isLightTheme: boolean
) => {
if (!comfyColorPalette) return
const rootStyle = document.documentElement.style
for (const [key, value] of Object.entries(comfyColorPalette)) {
@@ -228,6 +231,14 @@ export const useColorPaletteService = () => {
} else {
rootStyle.removeProperty('--bg-img')
}
try {
const splashBg = isLightTheme ? '#FFFFFF' : comfyColorPalette['bg-color']
localStorage.setItem('comfy-splash-bg', splashBg)
localStorage.setItem('comfy-splash-fg', comfyColorPalette['fg-color'])
} catch (_) {
/* empty */
}
}
/**
@@ -249,7 +260,10 @@ export const useColorPaletteService = () => {
colorPaletteId
)
loadLinkColorPaletteForVueNodes(completedPalette.colors.node_slot)
loadComfyColorPalette(completedPalette.colors.comfy_base)
loadComfyColorPalette(
completedPalette.colors.comfy_base,
completedPalette.light_theme === true
)
app.canvas.setDirty(true, true)
colorPaletteStore.activePaletteId = colorPaletteId

View File

@@ -216,14 +216,18 @@ export const useLitegraphService = () => {
*/
function addNodeInput(node: LGraphNode, inputSpec: InputSpec) {
addInputSocket(node, inputSpec)
addInputWidget(node, inputSpec)
addInputWidget(node, inputSpec, { dynamic: true })
}
/**
* @internal Add a widget to the node. For both primitive types and custom widgets
* (unless `socketless`), an input socket is also added.
*/
function addInputWidget(node: LGraphNode, inputSpec: InputSpec) {
function addInputWidget(
node: LGraphNode,
inputSpec: InputSpec,
{ dynamic }: { dynamic?: boolean } = {}
) {
const widgetInputSpec = { ...inputSpec }
if (inputSpec.widgetType) {
widgetInputSpec.type = inputSpec.widgetType
@@ -254,6 +258,7 @@ export const useLitegraphService = () => {
advanced: inputSpec.advanced,
hidden: inputSpec.hidden
})
if (dynamic) widget.tooltip = inputSpec.tooltip
}
if (!widget?.options?.socketless) {

View File

@@ -72,6 +72,8 @@ vi.mock('@/stores/modelToNodeStore', () => ({
}))
// Mock TaskItemImpl
const PREVIEWABLE_MEDIA_TYPES = new Set(['images', 'video', 'audio'])
vi.mock('@/stores/queueStore', () => ({
TaskItemImpl: class {
public flatOutputs: Array<{
@@ -91,19 +93,28 @@ vi.mock('@/stores/queueStore', () => ({
}
| undefined
public jobId: string
public outputsCount: number | null
constructor(public job: JobListItem) {
this.jobId = job.id
this.flatOutputs = [
{
this.outputsCount = job.outputs_count ?? null
const preview = job.preview_output
const isPreviewable =
!!preview?.filename && PREVIEWABLE_MEDIA_TYPES.has(preview.mediaType)
if (preview && isPreviewable) {
const item = {
supportsPreview: true,
filename: 'test.png',
subfolder: '',
type: 'output',
url: 'http://test.com/test.png'
filename: preview.filename!,
subfolder: preview.subfolder ?? '',
type: preview.type ?? 'output',
url: `http://test.com/${preview.filename}`
}
]
this.previewOutput = this.flatOutputs[0]
this.flatOutputs = [item]
this.previewOutput = item
} else {
this.flatOutputs = []
this.previewOutput = undefined
}
}
get previewableOutputs() {
@@ -200,6 +211,33 @@ describe('assetsStore - Refactored (Option A)', () => {
expect(store.historyError).toBe(error)
expect(store.historyLoading).toBe(false)
})
it('should skip text-only jobs without breaking sibling image jobs', async () => {
const mockHistory: JobListItem[] = [
createMockJobItem(0),
{
id: 'text-only-job',
status: 'completed',
create_time: 2000,
priority: 2000,
preview_output: {
content: 'some generated text',
nodeId: '5',
mediaType: 'text'
} satisfies JobListItem['preview_output']
},
createMockJobItem(2)
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(2)
expect(store.historyAssets.map((a) => a.id)).toEqual([
'prompt_0',
'prompt_2'
])
})
})
describe('Pagination', () => {

View File

@@ -191,6 +191,23 @@ describe('TaskItemImpl', () => {
})
})
it('should produce no previewable outputs for text-only preview_output', () => {
const job: JobListItem = {
...createHistoryJob(0, 'text-job'),
preview_output: {
nodeId: '5',
mediaType: 'text'
} satisfies JobListItem['preview_output']
}
const task = new TaskItemImpl(job)
expect(task.flatOutputs).toHaveLength(1)
expect(task.flatOutputs[0].filename).toBe('')
expect(task.previewableOutputs).toHaveLength(0)
expect(task.previewOutput).toBeUndefined()
})
describe('error extraction getters', () => {
it('errorMessage returns undefined when no execution_error', () => {
const job = createHistoryJob(0, 'job-id')

View File

@@ -74,6 +74,8 @@ export interface SimplifiedWidget<
/** Optional input specification backing this widget */
spec?: InputSpecV2
tooltip?: string
controlWidget?: SafeControlWidget
}

View File

@@ -132,7 +132,6 @@ watch(
} else {
document.body.classList.add(DARK_THEME_CLASS)
}
if (isDesktop) {
electronAPI().changeTheme({
color: 'rgba(0, 0, 0, 0)',

View File

@@ -87,6 +87,8 @@ const login = async () => {
}
onMounted(async () => {
document.getElementById('splash-loader')?.remove()
if (!userStore.initialized) {
await userStore.initialize()
}

View File

@@ -15,50 +15,22 @@
<template #header>
<div class="flex w-full items-center justify-between gap-2">
<div class="flex items-center gap-2">
<div class="flex w-full items-center gap-2">
<SingleSelect
v-model="searchMode"
class="min-w-34"
:options="filterOptions"
/>
<AutoCompletePlus
v-model.lazy="searchQuery"
<SearchAutocomplete
v-model="searchQuery"
:suggestions="suggestions"
:placeholder="$t('manager.searchPlaceholder')"
:complete-on-focus="false"
:delay="8"
option-label="query"
class="w-full max-w-lg min-w-md"
:pt="{
root: { class: 'relative' },
pcInputText: {
root: {
autofocus: true,
class:
'w-full h-10 rounded-lg bg-comfy-input text-comfy-input-foreground border-none outline-none text-sm'
}
},
overlay: {
class:
'bg-comfy-input rounded-lg mt-1 shadow-lg border border-border-default'
},
list: { class: 'p-1' },
option: {
class:
'px-3 py-2 rounded hover:bg-button-hover-surface cursor-pointer text-sm'
},
loader: { style: 'display: none' }
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
>
<template #dropdownicon>
<i
class="pi pi-search absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground"
/>
</template>
</AutoCompletePlus>
autofocus
size="lg"
class="max-w-96 flex-1"
@select="onOptionSelect"
/>
</div>
<PackInstallButton
v-if="isMissingTab && missingNodePacks.length > 0"
@@ -128,6 +100,10 @@
<div v-if="isLoading" class="size-full scrollbar-hide overflow-auto">
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
</div>
<UnresolvedNodesMessage
v-else-if="isUnresolvedTab"
:node-names="unresolvedNodeNames"
/>
<NoResultsPlaceholder
v-else-if="displayPacks.length === 0"
:title="emptyStateTitle"
@@ -166,8 +142,7 @@
<script setup lang="ts">
import { until, whenever } from '@vueuse/core'
import { merge, stubTrue } from 'es-toolkit/compat'
import type { AutoCompleteOptionSelectEvent } from 'primevue/autocomplete'
import { merge } from 'es-toolkit/compat'
import {
computed,
onBeforeUnmount,
@@ -183,7 +158,7 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
import SearchAutocomplete from '@/components/ui/search-input/SearchAutocomplete.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
@@ -192,6 +167,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components } from '@/types/comfyRegistryTypes'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import type { QuerySuggestion } from '@/types/searchServiceTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import PackUpdateButton from '@/workbench/extensions/manager/components/manager/button/PackUpdateButton.vue'
@@ -199,6 +175,7 @@ import InfoPanel from '@/workbench/extensions/manager/components/manager/infoPan
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue'
import GridSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/GridSkeleton.vue'
import UnresolvedNodesMessage from '@/workbench/extensions/manager/components/manager/UnresolvedNodesMessage.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useUpdateAvailableNodes } from '@/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
@@ -246,6 +223,7 @@ const {
// Missing nodes composable
const {
missingNodePacks,
unresolvedNodeNames,
isLoading: isMissingLoading,
error: missingError
} = useMissingNodes()
@@ -309,7 +287,17 @@ const navItems = computed<(NavItemData | NavGroupData)[]>(() => [
id: ManagerTab.Missing,
label: t('manager.nav.missingNodes'),
icon: 'icon-[lucide--triangle-alert]'
}
},
...(unresolvedNodeNames.value.length > 0
? [
{
id: ManagerTab.Unresolved,
label: t('manager.nav.unresolvedNodes'),
icon: 'icon-[lucide--help-circle]',
badge: unresolvedNodeNames.value.length
}
]
: [])
]
}
])
@@ -337,6 +325,12 @@ const selectedTab = computed(() =>
findNavItemById(navItems.value, selectedNavId.value)
)
watch(navItems, (items) => {
if (selectedNavId.value && !findNavItemById(items, selectedNavId.value)) {
selectedNavId.value = ManagerTab.Missing
}
})
const {
searchQuery,
pageNumber,
@@ -377,8 +371,8 @@ const availableSortOptions = computed(() => {
}))
})
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
searchQuery.value = event.value.query
const onOptionSelect = (suggestion: QuerySuggestion) => {
searchQuery.value = suggestion.query
}
const onApproachEnd = () => {
@@ -403,6 +397,9 @@ const isUpdateAvailableTab = computed(
const isMissingTab = computed(
() => selectedTab.value?.id === ManagerTab.Missing
)
const isUnresolvedTab = computed(
() => selectedTab.value?.id === ManagerTab.Unresolved
)
// Map of tab IDs to their empty state i18n key suffixes
const tabEmptyStateKeys: Partial<Record<ManagerTab, string>> = {

View File

@@ -0,0 +1,26 @@
<template>
<div class="flex flex-col items-center gap-3 p-8">
<i class="icon-[lucide--triangle-alert] text-4xl text-warning-background" />
<h3 class="text-base font-semibold">
{{ $t('manager.unresolvedNodes.title') }}
</h3>
<p class="text-center text-sm text-muted-foreground">
{{ $t('manager.unresolvedNodes.message') }}
</p>
<ul class="mt-2 flex flex-col gap-1 rounded-lg bg-secondary-background p-2">
<li
v-for="name in nodeNames"
:key="name"
class="px-3 py-1.5 font-mono text-sm"
>
{{ name }}
</li>
</ul>
</div>
</template>
<script setup lang="ts">
defineProps<{
nodeNames: string[]
}>()
</script>

View File

@@ -94,6 +94,7 @@ describe('useMissingNodes', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -119,6 +120,7 @@ describe('useMissingNodes', () => {
it('filters out installed packs correctly', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -140,6 +142,7 @@ describe('useMissingNodes', () => {
it('returns empty array when all packs are installed', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -158,6 +161,7 @@ describe('useMissingNodes', () => {
it('returns all packs when none are installed', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -191,6 +195,7 @@ describe('useMissingNodes', () => {
it('fetches even when packs already exist (watch always fires with immediate:true)', async () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -206,6 +211,7 @@ describe('useMissingNodes', () => {
it('fetches even when already loading (watch fires regardless of loading state)', async () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -223,6 +229,7 @@ describe('useMissingNodes', () => {
it('exposes loading state from useWorkflowPacks', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -239,6 +246,7 @@ describe('useMissingNodes', () => {
const testError = 'Failed to fetch workflow packs'
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(testError),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -257,6 +265,7 @@ describe('useMissingNodes', () => {
const workflowPacksRef = ref<WorkflowPack[]>([])
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: workflowPacksRef,
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -283,6 +292,7 @@ describe('useMissingNodes', () => {
const workflowPacksRef = ref(mockWorkflowPacks)
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: workflowPacksRef,
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
@@ -306,6 +316,68 @@ describe('useMissingNodes', () => {
})
})
describe('unresolved nodes', () => {
it('reports hasMissingNodes when unresolvedNodeNames is non-empty', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: ref(['UnknownNode1', 'UnknownNode2']),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { hasMissingNodes, unresolvedNodeNames } = useMissingNodes()
expect(hasMissingNodes.value).toBe(true)
expect(unresolvedNodeNames.value).toEqual([
'UnknownNode1',
'UnknownNode2'
])
})
it('does not report hasMissingNodes when unresolvedNodeNames is empty', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { hasMissingNodes } = useMissingNodes()
expect(hasMissingNodes.value).toBe(false)
})
it('updates reactively when unresolvedNodeNames changes', async () => {
const unresolvedRef = ref<string[]>([])
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
unresolvedNodeNames: unresolvedRef,
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { hasMissingNodes, unresolvedNodeNames } = useMissingNodes()
expect(hasMissingNodes.value).toBe(false)
expect(unresolvedNodeNames.value).toEqual([])
unresolvedRef.value = ['NewMissingNode']
await nextTick()
expect(hasMissingNodes.value).toBe(true)
expect(unresolvedNodeNames.value).toEqual(['NewMissingNode'])
})
})
describe('missing core nodes detection', () => {
const createMockNode = (type: string, packId?: string, version?: string) =>
createMockLGraphNode({

View File

@@ -21,8 +21,13 @@ export const useMissingNodes = createSharedComposable(() => {
const nodeDefStore = useNodeDefStore()
const comfyManagerStore = useComfyManagerStore()
const workflowStore = useWorkflowStore()
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
useWorkflowPacks()
const {
workflowPacks,
unresolvedNodeNames,
isLoading,
error,
startFetchWorkflowPacks
} = useWorkflowPacks()
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
@@ -67,7 +72,8 @@ export const useMissingNodes = createSharedComposable(() => {
const hasMissingNodes = computed(() => {
return (
missingNodePacks.value.length > 0 ||
Object.keys(missingCoreNodes.value).length > 0
Object.keys(missingCoreNodes.value).length > 0 ||
unresolvedNodeNames.value.length > 0
)
})
@@ -83,6 +89,7 @@ export const useMissingNodes = createSharedComposable(() => {
return {
missingNodePacks,
missingCoreNodes,
unresolvedNodeNames,
hasMissingNodes,
isLoading,
error

View File

@@ -31,6 +31,7 @@ const _useWorkflowPacks = () => {
const { inferPackFromNodeName } = useComfyRegistryStore()
const workflowPacks = ref<WorkflowPack[]>([])
const unresolvedNodeNames = ref<string[]>([])
const getWorkflowNodePackId = (node: LGraphNode): string | undefined => {
if (typeof node.properties?.cnr_id === 'string') {
@@ -111,12 +112,39 @@ const _useWorkflowPacks = () => {
/**
* Get the node packs for all nodes in the workflow (including subgraphs).
* Nodes that have no local definition and no registry match are tracked
* as unresolved so downstream consumers can surface them to the user.
*/
const getWorkflowPacks = async () => {
if (!app.rootGraph) return []
const packPromises = mapAllNodes(app.rootGraph, workflowNodeToPack)
const packs = await Promise.all(packPromises)
workflowPacks.value = packs.filter((pack) => pack !== undefined)
if (!app.rootGraph) {
workflowPacks.value = []
unresolvedNodeNames.value = []
return
}
const resolvedPacks: WorkflowPack[] = []
const unresolved: string[] = []
await Promise.all(
mapAllNodes(app.rootGraph, async (node) => {
const pack = await workflowNodeToPack(node)
if (pack) {
resolvedPacks.push(pack)
} else {
const nodeName = node.type
if (
nodeName &&
getWorkflowNodePackId(node) === undefined &&
!nodeDefStore.nodeDefsByName[nodeName]
) {
unresolved.push(nodeName)
}
}
})
)
workflowPacks.value = resolvedPacks
unresolvedNodeNames.value = [...new Set(unresolved)]
}
const packsToUniqueIds = (packs: WorkflowPack[]) =>
@@ -147,6 +175,7 @@ const _useWorkflowPacks = () => {
isLoading,
isReady,
workflowPacks: nodePacks,
unresolvedNodeNames,
startFetchWorkflowPacks: async () => {
await getWorkflowPacks() // Parse the packs from the workflow nodes
await startFetch() // Fetch the packs infos from the registry

View File

@@ -176,6 +176,9 @@ export function useManagerDisplayPacks(
return sortPacks(filterNotInstalled(base))
}
case ManagerTab.Unresolved:
return []
default:
return searchResults.value
}

View File

@@ -19,7 +19,8 @@ export enum ManagerTab {
UpdateAvailable = 'updateAvailable',
Conflicting = 'conflicting',
Workflow = 'workflow',
Missing = 'missing'
Missing = 'missing',
Unresolved = 'unresolved'
}
export type TaskLog = {