Compare commits

..

13 Commits

Author SHA1 Message Date
CodeRabbit Fixer
0ed9a379c6 fix: test(assetsStore): improve test comprehensiveness and avoid inline type redefinition (#9728)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:15:24 +01: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
56 changed files with 1291 additions and 479 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

@@ -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)
}

View File

@@ -40,8 +40,6 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
// Selection toolbox should be visible with multiple nodes selected
await expect(comfyPage.selectionToolbox).toBeVisible()
// Wait for the canvas render queue to settle before taking the screenshot
await comfyPage.nextFrame()
// Border is now drawn on canvas, check via screenshot
await expect(comfyPage.canvas).toHaveScreenshot(
'selection-toolbox-multiple-nodes-border.png'

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'
@@ -54,6 +55,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

@@ -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

@@ -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

@@ -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",
@@ -3181,6 +3187,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

@@ -4,6 +4,7 @@ import type {
AuthMetadata,
BeginCheckoutMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
@@ -160,6 +161,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
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

@@ -15,6 +15,7 @@ import type {
AuthMetadata,
CreditTopupMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionTriggerSource,
ExecutionErrorMetadata,
@@ -362,6 +363,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
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

@@ -85,7 +85,8 @@ describe('PostHogTelemetryProvider', () => {
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
persistence: 'localStorage+cookie',
debug: false
})
})

View File

@@ -8,6 +8,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AuthMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
@@ -104,7 +105,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true'
})
this.isInitialized = true
this.flushEventQueue()
@@ -346,6 +348,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
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,29 @@ 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
}
type ShareFlowStep =
| 'dialog_opened'
| 'save_prompted'
| 'link_created'
| 'link_copied'
export interface ShareFlowMetadata {
step: ShareFlowStep
source?: 'app_mode' | 'graph_mode'
}
/**
* Workflow open metadata
*/
@@ -362,6 +378,7 @@ export interface TelemetryProvider {
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
trackEnterLinear?(metadata: EnterLinearMetadata): void
trackShareFlow?(metadata: ShareFlowMetadata): void
// Page visibility events
trackPageVisibilityChanged?(metadata: PageVisibilityMetadata): void
@@ -447,7 +464,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',
@@ -521,4 +539,5 @@ export type TelemetryEventProperties =
| HelpCenterClosedMetadata
| WorkflowCreatedMetadata
| EnterLinearMetadata
| ShareFlowMetadata
| SubscriptionMetadata

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

@@ -71,46 +71,15 @@ vi.mock('@/stores/modelToNodeStore', () => ({
})
}))
// Mock TaskItemImpl
vi.mock('@/stores/queueStore', () => ({
TaskItemImpl: class {
public flatOutputs: Array<{
supportsPreview: boolean
filename: string
subfolder: string
type: string
url: string
}>
public previewOutput:
| {
supportsPreview: boolean
filename: string
subfolder: string
type: string
url: string
}
| undefined
public jobId: string
constructor(public job: JobListItem) {
this.jobId = job.id
this.flatOutputs = [
{
supportsPreview: true,
filename: 'test.png',
subfolder: '',
type: 'output',
url: 'http://test.com/test.png'
}
]
this.previewOutput = this.flatOutputs[0]
}
get previewableOutputs() {
return this.flatOutputs.filter((o) => o.supportsPreview)
}
// Use the real TaskItemImpl and ResultItemImpl from queueStore
// to avoid redefining types and filtering logic inline.
vi.mock('@/stores/queueStore', async (importOriginal) => {
const original = (await importOriginal()) as Record<string, unknown>
return {
TaskItemImpl: original.TaskItemImpl,
ResultItemImpl: original.ResultItemImpl
}
}))
})
// Mock asset mappers - add unique timestamps
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
@@ -140,20 +109,29 @@ describe('assetsStore - Refactored (Option A)', () => {
let store: ReturnType<typeof useAssetsStore>
// Helper function to create mock job items
const createMockJobItem = (index: number): JobListItem => ({
const createMockJobItem = (
index: number,
overrides?: {
status?: JobListItem['status']
preview_output?: JobListItem['preview_output'] | null
}
): JobListItem => ({
id: `prompt_${index}`,
status: 'completed',
status: overrides?.status ?? 'completed',
create_time: 1000 + index,
update_time: 1000 + index,
last_state_update: 1000 + index,
priority: 1000 + index,
preview_output: {
filename: `output_${index}.png`,
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: 'images'
}
preview_output:
overrides?.preview_output === null
? undefined
: (overrides?.preview_output ?? {
filename: `output_${index}.png`,
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: 'images'
})
})
beforeEach(() => {
@@ -494,6 +472,140 @@ describe('assetsStore - Refactored (Option A)', () => {
expect(Array.isArray(asset.user_metadata!.allOutputs)).toBe(true)
})
})
describe('Media Type Filtering', () => {
it('should include image outputs as previewable', async () => {
const mockHistory = [
createMockJobItem(0, {
preview_output: {
filename: 'photo.png',
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: 'images'
}
})
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
})
it('should include video outputs as previewable', async () => {
const mockHistory = [
createMockJobItem(0, {
preview_output: {
filename: 'clip.mp4',
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: 'video'
}
})
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
})
it('should include audio outputs as previewable', async () => {
const mockHistory = [
createMockJobItem(0, {
preview_output: {
filename: 'sound.mp3',
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: 'audio'
}
})
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
})
it('should include 3D outputs as previewable', async () => {
const mockHistory = [
createMockJobItem(0, {
preview_output: {
filename: 'model.glb',
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: '3d'
}
})
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
})
it('should skip non-previewable outputs (e.g., text files)', async () => {
const mockHistory = [
createMockJobItem(0, {
preview_output: {
filename: 'data.txt',
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: 'text'
}
})
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(0)
})
})
describe('Edge Cases', () => {
it('should skip jobs without preview_output', async () => {
const mockHistory = [
createMockJobItem(0, { preview_output: null }),
createMockJobItem(1)
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
})
it('should skip non-completed jobs', async () => {
const mockHistory = [
createMockJobItem(0, { status: 'failed' }),
createMockJobItem(1, { status: 'cancelled' }),
createMockJobItem(2, { status: 'pending' }),
createMockJobItem(3)
]
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
})
it('should handle empty history response', async () => {
vi.mocked(api.getHistory).mockResolvedValue([])
await store.updateHistory()
expect(store.historyAssets).toHaveLength(0)
expect(store.hasMoreHistory).toBe(false)
})
})
})
describe('assetsStore - Model Assets Cache (Cloud)', () => {

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 = {